diff --git a/.build/Add-OpenApiResponseSchemas.ps1 b/.build/Add-OpenApiResponseSchemas.ps1 new file mode 100644 index 0000000000000..3e90a214a1b13 --- /dev/null +++ b/.build/Add-OpenApiResponseSchemas.ps1 @@ -0,0 +1,349 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Enriches a CIPP openapi.json with typed 200 response schemas derived by static + analysis of the API and frontend repositories. + +.DESCRIPTION + The generated CIPP spec types every request body but leaves every 200 response + as the generic StandardResults envelope. This stage fills typed per-endpoint + response schemas for the read surface, using two deterministic sources that are + already checked into the repositories (no live API calls): + + 1. Captured response shape baselines (CIPP/Tests/Shapes/*.json) - carry real + field types and nesting. Preferred when present. + 2. Frontend table column declarations (simpleColumns in CIPP/src pages) - + carry field names only. Used when no baseline exists; fields are typed as + string and marked x-cipp-field-source: frontend so consumers know the type + is a name-only inference, not a verified type. + + Endpoints with neither source keep the StandardResults envelope, which is the + correct shape for write/exec operations. Output is deterministic: the same input + repositories always produce a byte-identical spec. + +.PARAMETER InputSpec + Path to the source openapi.json. Defaults to the repo-root spec relative to this + script (.build/.. ). + +.PARAMETER OutputSpec + Path to write the enriched spec. Defaults to InputSpec (in-place rewrite). + +.PARAMETER FrontendRepoPath + Path to a checkout of the CIPP frontend repository. Provides both the shape + baselines (Tests/Shapes) and the page column declarations (src). + +.PARAMETER PassThru + Return the enriched spec object instead of only writing it. Used by tests. + +.EXAMPLE + ./Add-OpenApiResponseSchemas.ps1 -FrontendRepoPath ../CIPP + + Rewrites the repo-root openapi.json in place with typed response schemas. +#> +[CmdletBinding()] +param( + [string]$InputSpec = (Join-Path $PSScriptRoot '..' 'openapi.json'), + [string]$OutputSpec, + [string]$FrontendRepoPath, + [switch]$PassThru +) + +$ErrorActionPreference = 'Stop' + +$script:CippHttpMethods = @('get', 'post', 'put', 'patch', 'delete') + +function ConvertFrom-ShapeNode { + <# + .SYNOPSIS + Converts one node of a captured shape tree into an OpenAPI schema fragment. + #> + param($Node) + + if ($Node -is [string]) { + switch ($Node) { + 'string' { return @{ type = 'string' } } + 'number' { return @{ type = 'number' } } + 'bool' { return @{ type = 'boolean' } } + 'datetime' { return [ordered]@{ type = 'string'; format = 'date-time' } } + # 'null' (captured as null at sample time) and 'truncated' (below the + # capture depth limit) carry no reliable type, so stay permissive. + default { return @{} } + } + } + + if ($Node -is [System.Collections.IDictionary]) { + if ($Node['_type'] -eq 'array') { + return [ordered]@{ type = 'array'; items = (ConvertFrom-ShapeNode -Node $Node['_element']) } + } + $properties = [ordered]@{} + foreach ($key in ($Node.Keys | Sort-Object)) { + $properties[[string]$key] = ConvertFrom-ShapeNode -Node $Node[$key] + } + return [ordered]@{ type = 'object'; properties = $properties } + } + + return @{} +} + +function Get-ShapeBaselineMap { + <# + .SYNOPSIS + Maps endpoint name -> per-record OpenAPI schema, from captured shape baselines. + .DESCRIPTION + Reads only files carrying both _metadata and shape; the sibling + test-results.json and any non-baseline file is skipped. The per-record schema + is the baseline shape itself (the CIPP envelope's Results[] element). + #> + param([string]$ShapesDir) + + $map = @{} + if (-not (Test-Path $ShapesDir)) { + Write-Warning "Shapes directory not found: $ShapesDir" + return $map + } + + foreach ($file in (Get-ChildItem -Path $ShapesDir -Filter '*.json' | Sort-Object -Property FullName)) { + $doc = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -AsHashtable -Depth 100 + if (-not ($doc -is [System.Collections.IDictionary] -and $doc.ContainsKey('_metadata') -and $doc.ContainsKey('shape'))) { + continue + } + $endpoint = $doc['_metadata']['endpoint'] + if (-not $endpoint) { continue } + $map[$endpoint] = ConvertFrom-ShapeNode -Node $doc['shape'] + } + return $map +} + +function Get-FrontendColumnMap { + <# + .SYNOPSIS + Maps endpoint name -> sorted unique field names, from page simpleColumns. + .DESCRIPTION + Intent: skips conditional simpleColumns arrays to avoid non-column branch strings; false negatives beat junk fields. + Scans frontend page sources for files that pair an /api/ reference + with a simpleColumns array, and unions the declared column names per endpoint. + Field names are deterministic; their types are not, so callers type them as + string with a provenance marker. + #> + param([string]$SrcDir) + + $map = @{} + if (-not (Test-Path $SrcDir)) { + Write-Warning "Frontend src directory not found: $SrcDir" + return $map + } + + $endpointPattern = [regex]'/api/([A-Za-z0-9_]+)' + $columnsPattern = [regex]'(?s)\bsimpleColumns\s*(?:=|:)\s*(?:\{\s*)?\[(?[^\]]*)\]' + $stringPattern = [regex]'"([^"]+)"|''([^'']+)''' + + $files = Get-ChildItem -Path $SrcDir -Recurse -File -Include '*.js', '*.jsx' + foreach ($file in $files) { + $text = Get-Content -LiteralPath $file.FullName -Raw + if ([string]::IsNullOrEmpty($text) -or $text -notmatch 'simpleColumns') { continue } + + $endpoints = $endpointPattern.Matches($text) | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique + if (-not $endpoints) { continue } + + $columns = foreach ($colMatch in $columnsPattern.Matches($text)) { + foreach ($strMatch in $stringPattern.Matches($colMatch.Groups['columns'].Value)) { + $value = if ($strMatch.Groups[1].Success) { $strMatch.Groups[1].Value } else { $strMatch.Groups[2].Value } + if ($value) { $value } + } + } + if (-not $columns) { continue } + + foreach ($endpoint in $endpoints) { + if (-not $map.ContainsKey($endpoint)) { $map[$endpoint] = [System.Collections.Generic.HashSet[string]]::new() } + foreach ($column in $columns) { [void]$map[$endpoint].Add($column) } + } + } + return $map +} + +function ConvertTo-ColumnRecordSchema { + <# + .SYNOPSIS + Builds a per-record object schema from a set of frontend column names. + #> + param([System.Collections.Generic.HashSet[string]]$Columns) + + $properties = [ordered]@{} + foreach ($column in ($Columns | Sort-Object)) { + $properties[$column] = [ordered]@{ type = 'string'; 'x-cipp-field-source' = 'frontend' } + } + return [ordered]@{ type = 'object'; properties = $properties } +} + +function ConvertTo-ResponseEnvelopeSchema { + <# + .SYNOPSIS + Wraps a per-record schema in the CIPP { Results: [...], Metadata: {...} } envelope. + #> + param($RecordSchema) + + return [ordered]@{ + type = 'object' + properties = [ordered]@{ + Results = [ordered]@{ type = 'array'; items = $RecordSchema } + Metadata = [ordered]@{ type = 'object' } + } + } +} + + +function Get-CippOperationId { + <# + .SYNOPSIS + Builds the deterministic operationId for one CIPP path and method. + .DESCRIPTION + Riftwing imports OpenAPI operations by operationId. CIPP upstream does not + currently emit operationIds, so this keeps importer keys stable without + depending on display labels or external data. + #> + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Method, + [Parameter(Mandatory)][string[]]$PathMethods + ) + + $endpointName = $Path -replace '^/api/', '' + if ($PathMethods.Count -eq 1) { + return $endpointName + } + + $methodName = [System.Globalization.CultureInfo]::InvariantCulture.TextInfo.ToTitleCase($Method.ToLowerInvariant()) + return "$methodName$endpointName" +} + +function Add-CippOperationId { + <# + .SYNOPSIS + Injects missing operationIds and fails on duplicate operationIds. + .DESCRIPTION + Existing non-empty operationIds are preserved so this pass can retire itself + when upstream starts emitting operationIds. Duplicate operationIds are fatal + because importers commonly key operations by operationId. + #> + param([Parameter(Mandatory)][System.Collections.IDictionary]$Spec) + + if (-not $Spec['paths']) { throw 'Spec has no paths.' } + + $operationCount = 0 + $injectedCount = 0 + $operationIds = @{} + + foreach ($pathEntry in $Spec['paths'].GetEnumerator()) { + $pathMethods = @($pathEntry.Value.Keys | Where-Object { $_ -in $script:CippHttpMethods }) + foreach ($methodEntry in $pathEntry.Value.GetEnumerator()) { + if ($methodEntry.Key -notin $script:CippHttpMethods) { continue } + + $operationCount++ + $operation = $methodEntry.Value + $operationId = $operation['operationId'] + if ([string]::IsNullOrWhiteSpace([string]$operationId)) { + $operationId = Get-CippOperationId -Path $pathEntry.Key -Method $methodEntry.Key -PathMethods $pathMethods + $operation['operationId'] = $operationId + $injectedCount++ + } + + if ($operationIds.ContainsKey($operationId)) { + throw "Duplicate operationId found: $operationId" + } + $operationIds[$operationId] = $true + } + } + + return [pscustomobject]@{ Operations = $operationCount; Injected = $injectedCount; Unique = $operationIds.Count } +} + +function Resolve-SpecResponse { + <# + .SYNOPSIS + Adds typed 200 response schemas to a parsed spec, in place, and returns counts. + .DESCRIPTION + The pure core of this stage: operates on an already-parsed spec hashtable and + the two endpoint maps, with no file or repository access, so it is unit + testable. Only existing 200 responses on get/post/put/patch/delete operations + are touched; everything else (including operations with no matching source) is + left exactly as found. + #> + param( + [Parameter(Mandatory)][System.Collections.IDictionary]$Spec, + [Parameter(Mandatory)][hashtable]$BaselineMap, + [Parameter(Mandatory)][hashtable]$ColumnMap + ) + + if (-not $Spec['paths']) { throw 'Spec has no paths.' } + + $operationCount = 0 + $typedCount = 0 + + foreach ($pathEntry in $Spec['paths'].GetEnumerator()) { + $endpoint = $pathEntry.Key -replace '^/api/', '' + + $recordSchema = $null + if ($BaselineMap.ContainsKey($endpoint)) { + $recordSchema = $BaselineMap[$endpoint] + } elseif ($ColumnMap.ContainsKey($endpoint)) { + $recordSchema = ConvertTo-ColumnRecordSchema -Columns $ColumnMap[$endpoint] + } + + foreach ($methodEntry in $pathEntry.Value.GetEnumerator()) { + if ($methodEntry.Key -notin $script:CippHttpMethods) { continue } + $operationCount++ + if ($null -eq $recordSchema) { continue } + + $responses = $methodEntry.Value['responses'] + if ($null -eq $responses) { continue } + + $okResponse = $responses['200'] + if (-not $okResponse) { continue } + + $okResponse['content'] = [ordered]@{ + 'application/json' = [ordered]@{ schema = (ConvertTo-ResponseEnvelopeSchema -RecordSchema $recordSchema) } + } + $typedCount++ + } + } + + return [pscustomobject]@{ + Operations = $operationCount + Typed = $typedCount + } +} + +function Add-CippResponseSchema { + <# + .SYNOPSIS + File-level orchestration: read spec + repo sources, enrich, write output. + #> + param( + [Parameter(Mandatory)][string]$InputSpec, + [Parameter(Mandatory)][string]$OutputSpec, + [Parameter(Mandatory)][string]$FrontendRepoPath, + [switch]$PassThru + ) + + if (-not (Test-Path $InputSpec)) { throw "Input spec not found: $InputSpec" } + + $spec = Get-Content -LiteralPath $InputSpec -Raw | ConvertFrom-Json -AsHashtable -Depth 100 + $baselineMap = Get-ShapeBaselineMap -ShapesDir (Join-Path $FrontendRepoPath 'Tests' 'Shapes') + $columnMap = Get-FrontendColumnMap -SrcDir (Join-Path $FrontendRepoPath 'src') + + $operationIdResult = Add-CippOperationId -Spec $spec + $result = Resolve-SpecResponse -Spec $spec -BaselineMap $baselineMap -ColumnMap $columnMap + Write-Information "Operations: $($result.Operations) | typed responses added: $($result.Typed) | operationIds injected: $($operationIdResult.Injected) | unique operationIds: $($operationIdResult.Unique)" -InformationAction Continue + + # Serialization is deterministic for the object this stage builds, but it does not globally canonicalize pre-existing spec keys. + [System.IO.File]::WriteAllText($OutputSpec, ($spec | ConvertTo-Json -Depth 100)) + + if ($PassThru) { return $spec } +} + +# Run orchestration only when invoked as a script, not when dot-sourced for testing. +if ($MyInvocation.InvocationName -ne '.') { + if (-not $FrontendRepoPath) { throw 'FrontendRepoPath is required when running the script.' } + if (-not $OutputSpec) { $OutputSpec = $InputSpec } + Add-CippResponseSchema -InputSpec $InputSpec -OutputSpec $OutputSpec -FrontendRepoPath $FrontendRepoPath -PassThru:$PassThru +} diff --git a/.build/README.md b/.build/README.md new file mode 100644 index 0000000000000..81d8ce08a6dd6 --- /dev/null +++ b/.build/README.md @@ -0,0 +1,38 @@ +# OpenAPI enrichment + +`Add-OpenApiResponseSchemas.ps1` post-processes the generated CIPP `openapi.json`. It adds deterministic operationIds and typed `200` response schemas where response shape data can be derived from the CIPP frontend repository. It does not replace the upstream OpenAPI generator. + +The enriched spec is published on each GitHub Release as the `openapi.enriched.json` release asset. + +The PR check and release workflow strictly lint the CI-generated `openapi.enriched.json` with Redocly. The committed `.redocly.lint-ignore.yaml` baseline pins findings that already exist in the generated enriched spec because of upstream `openapi.json` issues. Any new Redocly error or warning that is not in the baseline fails CI. + +To regenerate locally, check out the CIPP frontend repository and run: + +```powershell +pwsh -NoProfile -File .build/Add-OpenApiResponseSchemas.ps1 ` + -FrontendRepoPath ` + -InputSpec ./openapi.json -OutputSpec ./openapi.enriched.json +``` + +If upstream `openapi.json` legitimately changes and the pinned Redocly findings must be refreshed, regenerate the enriched spec first, then regenerate the ignore baseline from that enriched output: + +```powershell +pwsh -NoProfile -File .build/Add-OpenApiResponseSchemas.ps1 ` + -FrontendRepoPath ` + -InputSpec ./openapi.json -OutputSpec ./openapi.enriched.json +npx --yes @redocly/cli@2.35.1 lint ./openapi.enriched.json --generate-ignore-file +``` + +Do not generate the baseline from the base `openapi.json`. The lint subject is always the generated `openapi.enriched.json`. + +## Known limitations + +- Only `get`, `post`, `put`, `patch`, and `delete` operations are processed. `head`, `options`, and `trace` are not present in the current spec. +- Paths are assumed to start with `/api/`. All 580 current paths do. +- When a typed `200` response is added, it replaces the existing `200.content`. Today that content is only the generic `StandardResults` envelope. +- Conditional/ternary `simpleColumns` expressions are intentionally not parsed. + +## Release workflow notes + +- `openapi-enriched-release.yml` builds and uploads from the same tag. On `workflow_dispatch`, the `tag` input is checked out and used as the upload target. On `release: published`, the release tag is checked out and used as the upload target. +- `.github/workflows/` is gitignored in this repository, so the OpenAPI workflow files require `git add -f` when they are intentionally added or updated. diff --git a/.github/workflows/Conventional_Commits.yml b/.github/workflows/Conventional_Commits.yml new file mode 100644 index 0000000000000..537e86d6823f9 --- /dev/null +++ b/.github/workflows/Conventional_Commits.yml @@ -0,0 +1,65 @@ +name: Conventional Commits Check + +on: + # Using pull_request_target instead of pull_request for secure handling of fork PRs + pull_request_target: + # Re-run on title edits so a corrected title clears the check + types: [opened, synchronize, reopened, edited] + # Only check PRs targeting the dev branch + branches: + - dev + +permissions: + pull-requests: write + issues: write + +jobs: + conventional-commits: + name: Validate Conventional Commits + runs-on: ubuntu-slim + steps: + - name: Validate PR title + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const title = context.payload.pull_request.title || ''; + const pattern = /^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?!?: .+/; + + if (pattern.test(title)) { + console.log(`✓ PR title follows Conventional Commits format: "${title}"`); + return; + } + + console.log(`❌ PR title does not follow Conventional Commits format: "${title}"`); + + const message = [ + '❌ **This PR title does not follow the [Conventional Commits](https://www.conventionalcommits.org/) format.**', + '', + 'Expected format: `type(scope)?: description`', + '', + 'Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`', + '', + 'Examples:', + '- `feat: add SharePoint external user management`', + '- `fix(auth): handle expired refresh tokens`', + '- `docs: update self-hosting guide`', + '', + `Received: \`${title}\``, + '', + '🔒 This PR has been automatically closed. Please update the title to match the format above and reopen the PR.' + ].join('\n'); + + await github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body: message + }); + + await github.rest.pulls.update({ + ...context.repo, + pull_number: context.issue.number, + state: 'closed' + }); + + core.setFailed(`PR title does not follow Conventional Commits format: "${title}"`); diff --git a/.github/workflows/dev_cippahmcc.yml b/.github/workflows/dev_cippahmcc.yml new file mode 100644 index 0000000000000..11f85d798405d --- /dev/null +++ b/.github/workflows/dev_cippahmcc.yml @@ -0,0 +1,32 @@ +# Docs for the Azure Web Apps Deploy action: https://github.com/azure/functions-action +# More GitHub Actions for Azure: https://github.com/Azure/actions + +name: Build and deploy Powershell project to Azure Function App - cippahmcc + +on: + push: + branches: + - dev + workflow_dispatch: + +env: + AZURE_FUNCTIONAPP_PACKAGE_PATH: '.' # set this to the path to your web app project, defaults to the repository root + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: 'Checkout GitHub Action' + uses: actions/checkout@v4 + + - name: 'Run Azure Functions Action' + uses: Azure/functions-action@v1 + id: fa + with: + app-name: 'cippahmcc' + slot-name: 'Production' + package: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }} + publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_D3F28C6FE40549659A230F7C9B4F2B98 }} + sku: 'flexconsumption' + \ No newline at end of file diff --git a/.github/workflows/openapi-enriched-check.yml b/.github/workflows/openapi-enriched-check.yml new file mode 100644 index 0000000000000..cb93afbf9aba2 --- /dev/null +++ b/.github/workflows/openapi-enriched-check.yml @@ -0,0 +1,83 @@ +name: OpenAPI Enriched Spec Check + +on: + pull_request: + paths: + - '.build/**' + - '.github/workflows/openapi-enriched-check.yml' + - '.github/workflows/openapi-enriched-release.yml' + - '.redocly.lint-ignore.yaml' + - 'redocly.yaml' + - 'Tests/Build/**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + openapi-enriched-check: + if: github.repository_owner == 'KelvinTegelaar' + runs-on: ubuntu-latest + + steps: + - name: Checkout CIPP-API + uses: actions/checkout@v4 + + - name: Checkout CIPP frontend + uses: actions/checkout@v4 + with: + repository: KelvinTegelaar/CIPP + path: _frontend + + - name: Install PowerShell test modules + shell: pwsh + run: | + Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force + Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force + + - name: Run build tests + shell: pwsh + run: ./Tests/Build/Invoke-BuildTests.ps1 + + - name: Generate enriched OpenAPI spec + shell: pwsh + run: | + pwsh -NoProfile -File .build/Add-OpenApiResponseSchemas.ps1 ` + -FrontendRepoPath ./_frontend ` + -InputSpec ./openapi.json ` + -OutputSpec ./openapi.enriched.json + + - name: Strict lint enriched OpenAPI spec + run: | + set +e + npx --yes @redocly/cli@2.35.1 lint ./openapi.enriched.json --format=json > redocly-results.json + REDOCLY_STATUS=$? + set -e + + node - redocly-results.json "$REDOCLY_STATUS" <<'NODE' + const fs = require('fs'); + + const resultsPath = process.argv[2]; + const redoclyStatus = Number(process.argv[3]); + const raw = fs.readFileSync(resultsPath, 'utf8').trim(); + if (!raw) { + throw new Error(`${resultsPath} did not contain Redocly JSON output.`); + } + + const doc = JSON.parse(raw); + const totals = doc.totals || {}; + const errors = totals.errors || 0; + const warnings = totals.warnings || 0; + const ignored = totals.ignored || 0; + console.log(`Redocly new findings: errors=${errors}, warnings=${warnings}, ignored=${ignored}`); + + if (errors + warnings > 0) { + console.error('Enriched spec introduced new Redocly finding(s). Update the enrichment or regenerate the baseline only for legitimate upstream changes.'); + process.exit(1); + } + + if (redoclyStatus !== 0) { + console.error(`Redocly exited with status ${redoclyStatus}.`); + process.exit(redoclyStatus); + } + NODE diff --git a/.github/workflows/openapi-enriched-release.yml b/.github/workflows/openapi-enriched-release.yml new file mode 100644 index 0000000000000..853e5542bc1a9 --- /dev/null +++ b/.github/workflows/openapi-enriched-release.yml @@ -0,0 +1,86 @@ +name: Publish Enriched OpenAPI Spec + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Release tag to upload the enriched spec to + required: true + type: string + +permissions: + contents: write + +jobs: + openapi-enriched-release: + if: github.repository_owner == 'KelvinTegelaar' + runs-on: ubuntu-latest + + steps: + - name: Select release tag + id: release_tag + env: + RESOLVED_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} + run: printf 'tag=%s\n' "$RESOLVED_TAG" >> "$GITHUB_OUTPUT" + + - name: Checkout CIPP-API + uses: actions/checkout@v4 + with: + ref: ${{ steps.release_tag.outputs.tag }} + + - name: Checkout CIPP frontend + uses: actions/checkout@v4 + with: + repository: KelvinTegelaar/CIPP + path: _frontend + + - name: Generate enriched OpenAPI spec + shell: pwsh + run: | + pwsh -NoProfile -File .build/Add-OpenApiResponseSchemas.ps1 ` + -FrontendRepoPath ./_frontend ` + -InputSpec ./openapi.json ` + -OutputSpec ./openapi.enriched.json + + - name: Strict lint enriched OpenAPI spec + run: | + set +e + npx --yes @redocly/cli@2.35.1 lint ./openapi.enriched.json --format=json > redocly-results.json + REDOCLY_STATUS=$? + set -e + + node - redocly-results.json "$REDOCLY_STATUS" <<'NODE' + const fs = require('fs'); + + const resultsPath = process.argv[2]; + const redoclyStatus = Number(process.argv[3]); + const raw = fs.readFileSync(resultsPath, 'utf8').trim(); + if (!raw) { + throw new Error(`${resultsPath} did not contain Redocly JSON output.`); + } + + const doc = JSON.parse(raw); + const totals = doc.totals || {}; + const errors = totals.errors || 0; + const warnings = totals.warnings || 0; + const ignored = totals.ignored || 0; + console.log(`Redocly new findings: errors=${errors}, warnings=${warnings}, ignored=${ignored}`); + + if (errors + warnings > 0) { + console.error('Enriched spec introduced new Redocly finding(s). Update the enrichment or regenerate the baseline only for legitimate upstream changes.'); + process.exit(1); + } + + if (redoclyStatus !== 0) { + console.error(`Redocly exited with status ${redoclyStatus}.`); + process.exit(redoclyStatus); + } + NODE + + - name: Upload enriched OpenAPI spec to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.release_tag.outputs.tag }} + run: gh release upload "$RELEASE_TAG" openapi.enriched.json --clobber diff --git a/.github/workflows/psscriptanalyzer.yml b/.github/workflows/psscriptanalyzer.yml new file mode 100644 index 0000000000000..da71991767877 --- /dev/null +++ b/.github/workflows/psscriptanalyzer.yml @@ -0,0 +1,47 @@ +# SAST for PowerShell: PSScriptAnalyzer with SARIF upload to the GitHub Security tab +# ISO 27001:2022 A.8.28/8.29 evidence — CodeQL does not support PowerShell, so +# PSScriptAnalyzer is the SAST tool for CIPP-API. +name: SAST - PSScriptAnalyzer +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + schedule: + - cron: "30 5 * * 1" # Weekly, catches newly added rules against unchanged code + +permissions: + contents: read + security-events: write + actions: read + +jobs: + psscriptanalyzer: + if: github.repository_owner == 'KelvinTegelaar' + name: Run PSScriptAnalyzer + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Run PSScriptAnalyzer + uses: microsoft/psscriptanalyzer-action@v1.1 + with: + path: .\ + recurse: true + # Security-focused rules; extend with full default ruleset if desired + includeRule: + '"PSAvoidUsingPlainTextForPassword", + "PSAvoidUsingConvertToSecureStringWithPlainText", + "PSAvoidUsingUsernameAndPasswordParams", + "PSUsePSCredentialType", + "PSAvoidUsingInvokeExpression", + "PSAvoidGlobalVars", + "PSAvoidUsingComputerNameHardcoded", + "PSUseDeclaredVarsMoreThanAssignments"' + output: results.sarif + + - name: Upload SARIF to Security tab + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 4f4c7911d7505..326a9b52c6735 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ Shared/CIPPSharp/obj/ !/profile.ps1 .DS_Store proxyman.pem + +# Pester test runner artifacts (Invoke-CippTests.ps1 -CI / -Coverage) +Tests/TestResults.xml +Tests/coverage.xml diff --git a/.redocly.lint-ignore.yaml b/.redocly.lint-ignore.yaml new file mode 100644 index 0000000000000..2aaa3434d8bf2 --- /dev/null +++ b/.redocly.lint-ignore.yaml @@ -0,0 +1,12 @@ +# This file instructs Redocly's linter to ignore the rules contained for specific parts of your API. +# See https://redocly.com/docs/cli/ for more information. +openapi.enriched.json: + info-license: + - '#/info' + no-schema-type-mismatch: + - >- + #/paths/~1api~1DeployContactTemplates/post/requestBody/content/application~1json/schema/properties/TemplateList/items + no-unused-components: + - '#/components/parameters/selectedTenants' + - '#/components/schemas/LabelValueNumber' + - '#/components/schemas/DynamicExtensionFields' diff --git a/Config/CIPPDBCacheTypes.json b/Config/CIPPDBCacheTypes.json index 003fb3457dd1c..ee0dc3de42c32 100644 --- a/Config/CIPPDBCacheTypes.json +++ b/Config/CIPPDBCacheTypes.json @@ -294,6 +294,11 @@ "friendlyName": "SharePoint Site Usage", "description": "SharePoint site usage statistics" }, + { + "type": "SharePointSharingLinks", + "friendlyName": "SharePoint & OneDrive Sharing Links", + "description": "Sharing links and external grants on SharePoint and OneDrive files and folders" + }, { "type": "OfficeActivations", "friendlyName": "Office Activations", @@ -383,5 +388,10 @@ "type": "ExoTransportConfig", "friendlyName": "Exchange Transport Config", "description": "Exchange Online transport configuration including SMTP authentication settings" + }, + { + "type": "DefenderCVEs", + "friendlyName": "Defender CVEs", + "description": "All Defender CVEs for Devices" } ] diff --git a/Config/CIPPTimers.json b/Config/CIPPTimers.json index 63203c303ab83..ac1ce7ba5b1b6 100644 --- a/Config/CIPPTimers.json +++ b/Config/CIPPTimers.json @@ -19,34 +19,14 @@ }, { "Id": "44a40668-ed71-403c-8c26-b32e320086ad", - "Command": "Start-AuditLogOrchestrator", - "Description": "Orchestrator to download audit logs", + "Command": "Start-AuditLogPlannerV2", + "Description": "Audit log V2 planner: create searches, download and process", "Cron": "0 */15 * * * *", "Priority": 2, "RunOnProcessor": true, "PreferredProcessor": "auditlog", "IsSystem": true }, - { - "Id": "01cd512a-15c4-44a9-b8cb-1e5d879cfd2d", - "Command": "Start-AuditLogProcessingOrchestrator", - "Description": "Orchestrator to process audit logs", - "Cron": "0 */15 * * * *", - "Priority": 3, - "RunOnProcessor": true, - "PreferredProcessor": "auditlog", - "IsSystem": true - }, - { - "Id": "03475c86-4314-4d7b-90f2-5a0639e3899b", - "Command": "Start-AuditLogSearchCreation", - "Description": "Timer to create audit log searches", - "Cron": "0 */30 * * * *", - "Priority": 4, - "RunOnProcessor": true, - "PreferredProcessor": "auditlog", - "IsSystem": true - }, { "Id": "5ff6c500-e420-4a3b-8532-ace2e4da4f7d", "Command": "Start-ApplicationOrchestrator", diff --git a/Config/FeatureFlags.json b/Config/FeatureFlags.json index e90b238e94beb..e0b5e01998d69 100644 --- a/Config/FeatureFlags.json +++ b/Config/FeatureFlags.json @@ -44,31 +44,6 @@ ], "Hidden": true }, - { - "Id": "CopilotAI", - "Name": "Copilot & AI", - "Description": "Under Development: Microsoft 365 Copilot and AI management pages including settings, usage reports, Agent365 packages, and Shadow AI analysis.", - "Enabled": false, - "AllowUserToggle": false, - "Timers": [], - "Endpoints": [ - "ListCopilotSettings", - "ExecCopilotSettings", - "ListCopilotUsage", - "ListAgent365Packages", - "ListAgent365PackageDetail", - "ListShadowAI" - ], - "Pages": [ - "/copilot/settings", - "/copilot/shadow-ai", - "/copilot/agent365/packages", - "/copilot/reports/copilot-adoption", - "/copilot/reports/copilot-usage", - "/copilot/reports/copilot-trend" - ], - "Hidden": true - }, { "Id": "MCPServer", "Name": "MCP Server", @@ -96,5 +71,35 @@ "/cipp/advanced/diagnostics" ], "Hidden": true + }, + { + "Id": "BackendSettings", + "Name": "Backend Settings", + "Description": "Backend settings page showing Azure resource URLs. Hidden on hosted instances.", + "Enabled": true, + "AllowUserToggle": false, + "Timers": [], + "Endpoints": [ + "ExecBackendURLs" + ], + "Pages": [ + "/cipp/settings/backend" + ], + "Hidden": true + }, + { + "Id": "FunctionOffloading", + "Name": "Function Offloading", + "Description": "Function offloading configuration page. Hidden in CIPP-NG.", + "Enabled": true, + "AllowUserToggle": false, + "Timers": [], + "Endpoints": [ + "ExecOffloadFunctions" + ], + "Pages": [ + "/cipp/advanced/super-admin/function-offloading" + ], + "Hidden": true } ] diff --git a/Config/MaliciousApps.json b/Config/MaliciousApps.json new file mode 100644 index 0000000000000..b212e1153a8ea --- /dev/null +++ b/Config/MaliciousApps.json @@ -0,0 +1,1074 @@ +{ + "metadata": { + "name": "CIPP Malicious Applications", + "description": "Curated list of OAuth / Entra ID enterprise applications that have been observed being abused by threat actors against Microsoft 365 tenants. Entries are matched on appId (the Application/Client ID of the service principal that appears in a tenant after consent).", + "permissionTypes": [ + "Delegated", + "Application" + ], + "resources": [ + "Microsoft Graph", + "Office 365 Exchange Online", + "SharePoint" + ], + "categoryDefinitions": { + "Mailbox exfiltration": "Application synchronizes or exports the contents of a user's mailbox, enabling bulk theft of email.", + "Address book exfiltration": "Application harvests contacts or directory information for reconnaissance and follow-on phishing.", + "Sharepoint/OneDrive exfiltration": "Application reads or downloads files from SharePoint Online or OneDrive for Business.", + "Data exfiltration": "Application is used to move data out of the tenant to an attacker-controlled location.", + "Reconnaissance": "Application is used to enumerate users, contacts, or organizational data to identify targets.", + "Phishing": "Application is used to send phishing email or otherwise facilitate phishing campaigns.", + "Persistence": "Application grants the attacker durable, consent-based access that survives a password reset.", + "Business Email Compromise": "Application observed as part of a BEC intrusion (mailbox access, fraudulent email, financial redirection)." + }, + "applicationFields": { + "name": "Display name of the application as it appears on the consent screen / enterprise application.", + "appId": "Application (client) ID of the service principal. Primary key used for detection.", + "description": "What the application is / does.", + "categories": "Controlled-vocabulary classifications (see categoryDefinitions).", + "tags": "Free-form labels.", + "permissions": "Array of { scope, type, resource } granted to or requested by the app.", + "mitreAttackIds": "Relevant MITRE ATT&CK technique IDs.", + "publisher": "Publisher identity { name, id, verified } where known.", + "appOwnerOrganizationId": "Tenant ID that owns the multi-tenant application, where known.", + "references": "Write-ups, threat-intel articles, or official documentation.", + "detection": "Hunting / detection guidance specific to this application.", + "relatedAppIds": "appIds of related entries (e.g. renamed versions of the same app).", + "notes": "Caveats, data-quality flags, or additional context." + } + }, + "applications": [ + { + "name": "CloudSponge", + "appId": "a43e5392-f48b-46a4-a0f1-098b5eeb4757", + "description": "CloudSponge is a software-as-a-service product that imports contacts from all the major address books. It is embedded by websites so that users do not have to type email addresses into referral / invite forms.", + "categories": [ + "Address book exfiltration" + ], + "tags": [], + "permissions": [], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "CubeBackup", + "appId": "412445a2-0794-487e-9dd6-d57d9593b249", + "description": "CubeBackup is a self-hosted cloud backup solution that integrates with Microsoft 365, including Exchange Online, SharePoint and OneDrive. With sufficient privileges it can be leveraged by threat actors to conduct automated, mass exfiltration from a victim's environment.", + "categories": [ + "Data exfiltration", + "Mailbox exfiltration", + "Sharepoint/OneDrive exfiltration", + "Business Email Compromise" + ], + "tags": [ + "BEC", + "Exfiltration" + ], + "permissions": [ + { + "scope": "Calendars.ReadWrite", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Channel.Create", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Channel.ReadBasic.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "ChannelMember.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "ChannelMessage.Read.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "ChannelSettings.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Contacts.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Directory.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Files.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Group.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.ReadWrite", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Sites.FullControl.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Team.Create", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Team.ReadBasic.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "TeamMember.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "TeamSettings.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "TeamsTab.Create", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "TeamsTab.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "TeamsAppInstallation.ReadWriteForTeam.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "User.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "full_access_as_app", + "type": "Application", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "Sites.FullControl.All", + "type": "Application", + "resource": "SharePoint" + } + ], + "mitreAttackIds": [ + "T1537", + "T1567", + "T1020" + ], + "publisher": { + "name": "CubeBackup, Inc.", + "id": "cubebackupinc1662619479161", + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://www.cubebackup.com/" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Edison Mail", + "appId": "62db40a4-2c7e-4373-a609-eda138798962", + "description": "Email client with full Microsoft 365 synchronization capabilities.", + "categories": [ + "Mailbox exfiltration" + ], + "tags": [], + "permissions": [], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [], + "detection": "Unified Audit Log typically records synced items as MailItemsAccessed events. Hunt by App ID.", + "relatedAppIds": [], + "notes": null + }, + { + "name": "eM Client", + "appId": "e9a7fea1-1cc0-4cd9-a31b-9137ca5deedd", + "description": "eM Client is a desktop email client with full Microsoft Office 365 synchronization. Abused to bulk-synchronize and exfiltrate a compromised mailbox and to maintain access.", + "categories": [ + "Mailbox exfiltration", + "Persistence" + ], + "tags": [], + "permissions": [ + { + "scope": "EWS.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "email", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://www.huntress.com/blog/legitimate-apps-as-traitorware-for-persistent-microsoft-365-compromise", + "https://cybercorner.tech/malicious-usage-of-em-client-in-business-email-compromise/" + ], + "detection": "Unified Audit Log typically records synced items as MailItemsAccessed events. Hunt by App ID.", + "relatedAppIds": [], + "notes": null + }, + { + "name": "Fastmail", + "appId": "77468577-4f6e-40e7-b745-11d3d0c28095", + "description": "Fastmail is an alternative email service that allows import/export from various email providers, including Microsoft 365. If a victim consents, all email can be exfiltrated to an attacker-controlled Fastmail account, with the option to continue exfiltrating mail post-consent.", + "categories": [ + "Mailbox exfiltration", + "Persistence" + ], + "tags": [ + "Persistence", + "Exfiltration" + ], + "permissions": [ + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "email", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "IMAP.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "SMTP.Send", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + } + ], + "mitreAttackIds": [ + "T1114.002", + "T1567.002", + "T1136.003", + "T1098.001" + ], + "publisher": { + "name": null, + "id": null, + "verified": false + }, + "appOwnerOrganizationId": "9188040d-6c67-4c5b-b112-36a304b66dad", + "references": [ + "https://x.com/mwaski88/status/1775904383382802502", + "https://cybercorner.tech/common-oauth-apps-used-in-business-email-compromise/#Fastmail", + "https://www.fastmail.help/hc/en-us/articles/360060590593-Migrate-to-Fastmail-from-another-provider" + ], + "detection": null, + "relatedAppIds": [], + "notes": "appOwnerOrganizationId 9188040d-6c67-4c5b-b112-36a304b66dad is the Microsoft consumer (personal accounts) tenant. Publisher reported as not verified." + }, + { + "name": "Foxmail", + "appId": "231575bc-9f6c-4539-9241-5cfae696b630", + "description": "Foxmail is a desktop email client. Observed consented in a business email compromise with the full set of legacy Exchange protocol scopes, enabling full mailbox synchronization.", + "categories": [ + "Mailbox exfiltration", + "Business Email Compromise" + ], + "tags": [ + "BEC" + ], + "permissions": [ + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "email", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "POP.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "IMAP.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "SMTP.Send", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "EAS.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "EWS.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Horizon Tech", + "appId": "b1c4926a-5fb6-4aad-b920-709c957be148", + "description": "Pulls email and contacts and sends phishing emails from the compromised mailbox.", + "categories": [ + "Mailbox exfiltration", + "Address book exfiltration", + "Phishing", + "Persistence", + "Business Email Compromise" + ], + "tags": [ + "BEC", + "persistence", + "spam", + "phishing", + "email abuse" + ], + "permissions": [ + { + "scope": "Mail.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.ReadBasic.All", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Send", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxSettings.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [ + "T1114", + "T1566", + "T1071.001" + ], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Jotform", + "appId": "9af771d1-1288-43f0-91a6-adadcbd212b5", + "description": "Jotform is an online form and survey builder. Abused as a native phishing / spam vector after Microsoft 365 SSO consent.", + "categories": [ + "Phishing", + "Business Email Compromise" + ], + "tags": [ + "BEC", + "spam", + "phishing" + ], + "permissions": [ + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": "261450de-982f-4efb-a5fe-0fc2d9d08717", + "references": [ + "https://www.jotform.com", + "https://www.bleepingcomputer.com/news/security/the-rise-of-native-phishing-microsoft-365-apps-abused-in-attacks/" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Mail_Backup", + "appId": "2ef68ccc-8a4d-42ff-ae88-2d7bb89ad139", + "description": "Exports mailboxes for backup purposes; used by threat actors to exfiltrate email. This is the renamed successor to PerfectData Software.", + "categories": [ + "Mailbox exfiltration" + ], + "tags": [], + "permissions": [ + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxFolder.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Contacts.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Calendars.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxSettings.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxFolder.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://cybercorner.tech/malicious-azure-application-perfectdata-software-and-office365-business-email-compromise/", + "https://darktrace.com/blog/how-abuse-of-perfectdata-software-may-create-a-perfect-storm-an-emerging-trend-in-account-takeovers", + "https://www.secureworks.com/blog/qr-phishing-leads-to-microsoft-365-account-compromise" + ], + "detection": "Updated version of PerfectData Software. Unified Audit Log surfaces synced items as MailItemsAccessed events. Hunt by App ID.", + "relatedAppIds": [ + "ff8d92dc-3d82-41d6-bcbd-b9174d163620" + ], + "notes": null + }, + { + "name": "Newsletter Software Supermailer", + "appId": "a245e8c0-b53c-4b67-9b45-751d1dff8e6b", + "description": "Bulk email sending software. Abused to send phishing / spam from a compromised mailbox.", + "categories": [ + "Phishing" + ], + "tags": [], + "permissions": [ + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Send", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Contacts.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://www.huntress.com/blog/legitimate-apps-as-traitorware-for-persistent-microsoft-365-compromise" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "PerfectData Software", + "appId": "ff8d92dc-3d82-41d6-bcbd-b9174d163620", + "description": "Exports mailboxes for backup purposes. Widely abused for Office 365 business email compromise to bulk-export a victim mailbox.", + "categories": [ + "Mailbox exfiltration" + ], + "tags": [], + "permissions": [ + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "EWS.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://cybercorner.tech/malicious-azure-application-perfectdata-software-and-office365-business-email-compromise/", + "https://darktrace.com/blog/how-abuse-of-perfectdata-software-may-create-a-perfect-storm-an-emerging-trend-in-account-takeovers", + "https://www.secureworks.com/blog/qr-phishing-leads-to-microsoft-365-account-compromise" + ], + "detection": "The Unified Audit Log initially did not record items synced by this app; it now surfaces them as MailItemsAccessed events. Hunt by App ID.", + "relatedAppIds": [ + "2ef68ccc-8a4d-42ff-ae88-2d7bb89ad139" + ], + "notes": "Renamed/superseded by 'Mail_Backup' (2ef68ccc-8a4d-42ff-ae88-2d7bb89ad139)." + }, + { + "name": "PostBox", + "appId": "179d5108-412b-4c95-8e34-06786784ab39", + "description": "Desktop email client with full Microsoft 365 synchronization capabilities. Abused for mailbox exfiltration and persistence.", + "categories": [ + "Mailbox exfiltration", + "Persistence" + ], + "tags": [], + "permissions": [ + { + "scope": "IMAP.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "POP.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "SMTP.Send", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "rclone", + "appId": "4761b959-9780-4c2d-87a3-512b4638f767", + "description": "rclone is a command-line program for managing files across cloud storage providers. Abused to bulk-download SharePoint Online / OneDrive content from a compromised tenant.", + "categories": [ + "Data exfiltration", + "Sharepoint/OneDrive exfiltration" + ], + "tags": [], + "permissions": [ + { + "scope": "Files.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Files.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Files.Read.All", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Files.ReadWrite.All", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Sites.Read.All", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://www.kroll.com/en/insights/publications/cyber/new-m365-business-email-compromise-attacks-with-rclone" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "SigParser", + "appId": "caffae8c-0882-4c81-9a27-d1803af53a40", + "description": "SigParser scans emails, calendars, address books, spreadsheets and more to automatically generate profiles on the people and companies who have interacted with a business. Abused for address-book / contact harvesting.", + "categories": [ + "Address book exfiltration" + ], + "tags": [], + "permissions": [], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://sigparser.com/" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Spike", + "appId": "946c777c-bc85-489e-b034-392389ae23d6", + "description": "Spike is a conversational email client and team-collaboration app. Abused for mailbox exfiltration, persistence and phishing.", + "categories": [ + "Mailbox exfiltration", + "Persistence", + "Phishing" + ], + "tags": [], + "permissions": [ + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Send", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "People.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxSettings.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Calendars.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://darktrace.com/blog/breakdown-of-a-multi-account-compromise-within-office-365" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Teleforge Directory", + "appId": "1a9b8d93-0d60-4835-896f-83016de95ff5", + "description": "Used for business email compromise. Installed shortly after a breach and after an additional MFA authentication device was added; data was collected for roughly a week, after which fraudulent emails were sent.", + "categories": [ + "Business Email Compromise", + "Mailbox exfiltration" + ], + "tags": [ + "BEC" + ], + "permissions": [ + { + "scope": "Mail.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.ReadBasic.All", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Send", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxSettings.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [ + "T1119" + ], + "publisher": { + "name": "Unknown (deleted before recorded)", + "id": null, + "verified": null + }, + "appOwnerOrganizationId": "aa68a5d2-2ee2-4ff7-8193-3d037ab704b1", + "references": [], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "ZoomInfo Communitiez Login", + "appId": "497ac034-5120-4c1a-929a-0351f5c09918", + "description": "Service principal consented to when the 'My Connections' feature within ZoomInfo is used. This feature extracts address-book and other contact information from the victim account, enabling target discovery, exfiltration and targeted phishing.", + "categories": [ + "Address book exfiltration", + "Reconnaissance", + "Phishing", + "Persistence" + ], + "tags": [ + "Discovery", + "Exfiltration", + "Persistence", + "Phishing" + ], + "permissions": [ + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Contacts.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.ReadBasic.All", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "email", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxSettings.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [ + "T1530", + "T1567", + "T1087.003" + ], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": "945a9641-35c7-435c-a5a0-c8753e6dff88", + "references": [ + "https://cybercorner.tech/common-oauth-apps-used-in-business-email-compromise/#ZoomInfo" + ], + "detection": null, + "relatedAppIds": [ + "858d7e42-35f0-44b7-9033-df309239a47f" + ], + "notes": null + }, + { + "name": "Zoominfo Login", + "appId": "858d7e42-35f0-44b7-9033-df309239a47f", + "description": "ZoomInfo is a B2B business-intelligence platform with a large database of company and contact information. It can be used via SSO with a Microsoft 365 account, which creates this service principal in the tenant and can be abused for persistence and contact harvesting.", + "categories": [ + "Address book exfiltration", + "Persistence" + ], + "tags": [ + "persistence" + ], + "permissions": [ + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "email", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [ + "T1136.003" + ], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": "945a9641-35c7-435c-a5a0-c8753e6dff88", + "references": [ + "https://cybercorner.tech/common-oauth-apps-used-in-business-email-compromise/#ZoomInfo", + "https://abnormalsecurity.com/blog/cybercriminals-exploit-b2b-lead-generation-tools" + ], + "detection": null, + "relatedAppIds": [ + "497ac034-5120-4c1a-929a-0351f5c09918" + ], + "notes": null + } + ] +} diff --git a/Config/SAMManifest.json b/Config/SAMManifest.json index 8868687473b2f..3b304150e7cf8 100644 --- a/Config/SAMManifest.json +++ b/Config/SAMManifest.json @@ -655,6 +655,10 @@ { "id": "56680e0d-d2a3-4ae1-80d8-3c4f2100e3d0", "type": "Scope" + }, + { + "id": "678536fe-1083-478a-9c59-b99265e6b0d3", + "type": "Role" } ] }, diff --git a/Config/ShadowAI.json b/Config/ShadowAI.json index 081ecc500456c..05481845dce75 100644 --- a/Config/ShadowAI.json +++ b/Config/ShadowAI.json @@ -1,176 +1,176 @@ [ - { "name": "Azure OpenAI", "vendor": "Microsoft", "category": "AI Platform & API", "risk": "Low", "matchNames": ["azure openai"], "appIds": [] }, - { "name": "GitHub Copilot", "vendor": "GitHub", "category": "AI Coding", "risk": "Medium", "matchNames": ["github copilot"], "appIds": [] }, - { "name": "OpenAI Codex", "vendor": "OpenAI", "category": "AI Coding", "risk": "High", "matchNames": ["codex"], "appIds": [] }, - { "name": "OpenAI Sora", "vendor": "OpenAI", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["sora"], "appIds": [] }, - { "name": "ChatGPT", "vendor": "OpenAI", "category": "AI Assistant", "risk": "High", "matchNames": ["chatgpt", "openai"], "appIds": [] }, - { "name": "Claude", "vendor": "Anthropic", "category": "AI Assistant", "risk": "Medium", "matchNames": ["claude", "anthropic"], "appIds": [] }, - { "name": "NotebookLM", "vendor": "Google", "category": "AI Search & Research", "risk": "Low", "matchNames": ["notebooklm"], "appIds": [] }, - { "name": "Google Gemini", "vendor": "Google", "category": "AI Assistant", "risk": "Medium", "matchNames": ["gemini"], "appIds": [] }, - { "name": "Google Antigravity", "vendor": "Google", "category": "AI Coding", "risk": "Medium", "matchNames": ["antigravity"], "appIds": [] }, - { "name": "Vertex AI", "vendor": "Google", "category": "AI Platform & API", "risk": "Low", "matchNames": ["vertex ai"], "appIds": [] }, - { "name": "Microsoft Copilot", "vendor": "Microsoft", "category": "AI Assistant", "risk": "Low", "matchNames": ["copilot"], "appIds": [] }, - { "name": "Perplexity", "vendor": "Perplexity AI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["perplexity"], "appIds": [] }, - { "name": "DeepSeek", "vendor": "DeepSeek", "category": "AI Assistant", "risk": "High", "matchNames": ["deepseek"], "appIds": [] }, - { "name": "Grok", "vendor": "xAI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["grok", "x.ai"], "appIds": [] }, - { "name": "Mistral Le Chat", "vendor": "Mistral AI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["mistral"], "appIds": [] }, - { "name": "Meta AI", "vendor": "Meta", "category": "AI Assistant", "risk": "Medium", "matchNames": ["meta ai", "llama.cpp"], "appIds": [] }, - { "name": "Qwen", "vendor": "Alibaba", "category": "AI Assistant", "risk": "High", "matchNames": ["qwen"], "appIds": [] }, - { "name": "Kimi", "vendor": "Moonshot AI", "category": "AI Assistant", "risk": "High", "matchNames": ["kimi", "moonshot ai"], "appIds": [] }, - { "name": "Poe", "vendor": "Quora", "category": "AI Assistant", "risk": "Medium", "matchNames": ["poe by quora", "quora"], "appIds": [] }, - { "name": "You.com", "vendor": "You.com", "category": "AI Assistant", "risk": "Medium", "matchNames": ["you.com"], "appIds": [] }, - { "name": "Pi", "vendor": "Inflection AI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["inflection"], "appIds": [] }, - { "name": "Character.AI", "vendor": "Character Technologies", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["character.ai", "character ai"], "appIds": [] }, - { "name": "Candy.AI", "vendor": "Candy.AI", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["candy.ai"], "appIds": [] }, - { "name": "Janitor AI", "vendor": "JanitorAI", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["janitor ai", "janitorai"], "appIds": [] }, - { "name": "Crushon AI", "vendor": "Crushon", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["crushon"], "appIds": [] }, - { "name": "FlowGPT", "vendor": "FlowGPT", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["flowgpt"], "appIds": [] }, - { "name": "Cursor", "vendor": "Anysphere", "category": "AI Coding", "risk": "Medium", "matchNames": ["cursor"], "appIds": [] }, - { "name": "Codeium / Windsurf", "vendor": "Codeium", "category": "AI Coding", "risk": "Medium", "matchNames": ["codeium", "windsurf"], "appIds": [] }, - { "name": "Tabnine", "vendor": "Tabnine", "category": "AI Coding", "risk": "Medium", "matchNames": ["tabnine"], "appIds": [] }, - { "name": "Sourcegraph Cody", "vendor": "Sourcegraph", "category": "AI Coding", "risk": "Medium", "matchNames": ["sourcegraph", "cody"], "appIds": [] }, - { "name": "Aider", "vendor": "Aider", "category": "AI Coding", "risk": "Medium", "matchNames": ["aider"], "appIds": [] }, - { "name": "Amazon Q / CodeWhisperer", "vendor": "Amazon", "category": "AI Coding", "risk": "Medium", "matchNames": ["codewhisperer", "amazon q"], "appIds": [] }, - { "name": "Amazon Bedrock", "vendor": "Amazon", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["bedrock"], "appIds": [] }, - { "name": "Amazon Kiro", "vendor": "Amazon", "category": "AI Coding", "risk": "Medium", "matchNames": ["kiro"], "appIds": [] }, - { "name": "Replit", "vendor": "Replit", "category": "AI Coding", "risk": "High", "matchNames": ["replit"], "appIds": [] }, - { "name": "Lovable", "vendor": "Lovable", "category": "AI Coding", "risk": "High", "matchNames": ["lovable"], "appIds": [] }, - { "name": "Bolt.new", "vendor": "StackBlitz", "category": "AI Coding", "risk": "High", "matchNames": ["bolt.new", "stackblitz"], "appIds": [] }, - { "name": "v0", "vendor": "Vercel", "category": "AI Coding", "risk": "High", "matchNames": ["v0.dev"], "appIds": [] }, - { "name": "Devin", "vendor": "Cognition", "category": "AI Coding", "risk": "High", "matchNames": ["devin ai", "cognition labs"], "appIds": [] }, - { "name": "Continue", "vendor": "Continue", "category": "AI Coding", "risk": "Medium", "matchNames": ["continue.dev"], "appIds": [] }, - { "name": "Cline", "vendor": "Cline", "category": "AI Coding", "risk": "Medium", "matchNames": ["cline"], "appIds": [] }, - { "name": "Roo Code", "vendor": "Roo Code", "category": "AI Coding", "risk": "Medium", "matchNames": ["roo code"], "appIds": [] }, - { "name": "Qodo", "vendor": "Qodo", "category": "AI Coding", "risk": "Medium", "matchNames": ["qodo"], "appIds": [] }, - { "name": "Warp", "vendor": "Warp", "category": "AI Coding", "risk": "Medium", "matchNames": ["warp.dev"], "appIds": [] }, - { "name": "Visual Studio Code", "vendor": "Microsoft", "category": "AI-Capable Editor", "risk": "Informational", "matchNames": ["visual studio code", "vscode"], "appIds": [] }, - { "name": "JetBrains AI", "vendor": "JetBrains", "category": "AI-Capable Editor", "risk": "Low", "matchNames": ["jetbrains ai"], "appIds": [] }, - { "name": "Ollama", "vendor": "Ollama", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["ollama"], "appIds": [] }, - { "name": "LM Studio", "vendor": "LM Studio", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["lm studio", "lmstudio"], "appIds": [] }, - { "name": "GPT4All", "vendor": "Nomic AI", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["gpt4all"], "appIds": [] }, - { "name": "Jan", "vendor": "Jan", "category": "Local AI Runtime", "risk": "Low", "matchNames": ["jan.ai"], "appIds": [] }, - { "name": "Open WebUI", "vendor": "Open WebUI", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["open webui"], "appIds": [] }, - { "name": "AnythingLLM", "vendor": "Mintplex Labs", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["anythingllm"], "appIds": [] }, - { "name": "Oobabooga", "vendor": "Community", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["oobabooga"], "appIds": [] }, - { "name": "KoboldCpp", "vendor": "Community", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["koboldcpp"], "appIds": [] }, - { "name": "LocalAI", "vendor": "LocalAI", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["localai"], "appIds": [] }, - { "name": "Msty", "vendor": "Msty", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["msty"], "appIds": [] }, - { "name": "OpenClaw / Claw", "vendor": "Community", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["claw"], "appIds": [] }, - { "name": "AutoGPT", "vendor": "Significant Gravitas", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["autogpt"], "appIds": [] }, - { "name": "AgentGPT", "vendor": "Reworkd", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["agentgpt"], "appIds": [] }, - { "name": "BabyAGI", "vendor": "Community", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["babyagi"], "appIds": [] }, - { "name": "Manus", "vendor": "Monica", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["manus ai", "manus.im"], "appIds": [] }, - { "name": "LangChain", "vendor": "LangChain", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["langchain"], "appIds": [] }, - { "name": "LlamaIndex", "vendor": "LlamaIndex", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["llamaindex"], "appIds": [] }, - { "name": "CrewAI", "vendor": "CrewAI", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["crewai"], "appIds": [] }, - { "name": "n8n", "vendor": "n8n", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["n8n"], "appIds": [] }, - { "name": "Zapier", "vendor": "Zapier", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["zapier"], "appIds": [] }, - { "name": "Make", "vendor": "Make", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["make.com"], "appIds": [] }, - { "name": "Lindy", "vendor": "Lindy", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["lindy"], "appIds": [] }, - { "name": "Relevance AI", "vendor": "Relevance AI", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["relevance ai"], "appIds": [] }, - { "name": "Dust", "vendor": "Dust", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["dust.tt"], "appIds": [] }, - { "name": "Bardeen", "vendor": "Bardeen", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["bardeen"], "appIds": [] }, - { "name": "Gumloop", "vendor": "Gumloop", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["gumloop"], "appIds": [] }, - { "name": "Browse AI", "vendor": "Browse AI", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["browse ai"], "appIds": [] }, - { "name": "Midjourney", "vendor": "Midjourney", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["midjourney"], "appIds": [] }, - { "name": "Stable Diffusion", "vendor": "Stability AI", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["stable diffusion", "stability ai"], "appIds": [] }, - { "name": "DALL-E", "vendor": "OpenAI", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["dall-e", "dalle"], "appIds": [] }, - { "name": "Adobe Firefly", "vendor": "Adobe", "category": "AI Image & Design", "risk": "Low", "matchNames": ["firefly"], "appIds": [] }, - { "name": "Canva", "vendor": "Canva", "category": "AI Image & Design", "risk": "Informational", "matchNames": ["canva"], "appIds": [] }, - { "name": "Leonardo AI", "vendor": "Leonardo.Ai", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["leonardo.ai", "leonardo ai"], "appIds": [] }, - { "name": "Ideogram", "vendor": "Ideogram", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["ideogram"], "appIds": [] }, - { "name": "Krea", "vendor": "Krea", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["krea.ai"], "appIds": [] }, - { "name": "ComfyUI", "vendor": "Comfy Org", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["comfyui"], "appIds": [] }, - { "name": "Automatic1111", "vendor": "Community", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["automatic1111"], "appIds": [] }, - { "name": "InvokeAI", "vendor": "Invoke", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["invokeai"], "appIds": [] }, - { "name": "Remove.bg", "vendor": "Kaleido", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["remove.bg"], "appIds": [] }, - { "name": "Photoroom", "vendor": "Photoroom", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["photoroom"], "appIds": [] }, - { "name": "Synthesia", "vendor": "Synthesia", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["synthesia"], "appIds": [] }, - { "name": "HeyGen", "vendor": "HeyGen", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["heygen"], "appIds": [] }, - { "name": "RunwayML", "vendor": "Runway", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["runwayml", "runway ml"], "appIds": [] }, - { "name": "Descript", "vendor": "Descript", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["descript"], "appIds": [] }, - { "name": "OpusClip", "vendor": "OpusClip", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["opusclip", "opus clip"], "appIds": [] }, - { "name": "Veed", "vendor": "Veed", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["veed.io"], "appIds": [] }, - { "name": "Pika", "vendor": "Pika Labs", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["pika labs"], "appIds": [] }, - { "name": "Luma", "vendor": "Luma AI", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["luma ai", "lumalabs"], "appIds": [] }, - { "name": "Pictory", "vendor": "Pictory", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["pictory"], "appIds": [] }, - { "name": "InVideo", "vendor": "InVideo", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["invideo"], "appIds": [] }, - { "name": "CapCut", "vendor": "ByteDance", "category": "AI Video & Audio", "risk": "High", "matchNames": ["capcut"], "appIds": [] }, - { "name": "ElevenLabs", "vendor": "ElevenLabs", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["elevenlabs"], "appIds": [] }, - { "name": "Murf", "vendor": "Murf", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["murf"], "appIds": [] }, - { "name": "Play.ht", "vendor": "PlayHT", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["play.ht"], "appIds": [] }, - { "name": "Speechify", "vendor": "Speechify", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["speechify"], "appIds": [] }, - { "name": "Suno", "vendor": "Suno", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["suno"], "appIds": [] }, - { "name": "Otter.ai", "vendor": "Otter", "category": "AI Meeting Notetaker", "risk": "High", "matchNames": ["otter.ai"], "appIds": [] }, - { "name": "Fireflies.ai", "vendor": "Fireflies", "category": "AI Meeting Notetaker", "risk": "High", "matchNames": ["fireflies"], "appIds": [] }, - { "name": "Fathom", "vendor": "Fathom", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["fathom"], "appIds": [] }, - { "name": "tl;dv", "vendor": "tldv", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["tldv", "tl;dv"], "appIds": [] }, - { "name": "Read AI", "vendor": "Read", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["read.ai"], "appIds": [] }, - { "name": "Krisp", "vendor": "Krisp", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["krisp"], "appIds": [] }, - { "name": "Sembly", "vendor": "Sembly AI", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["sembly"], "appIds": [] }, - { "name": "Avoma", "vendor": "Avoma", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["avoma"], "appIds": [] }, - { "name": "MeetGeek", "vendor": "MeetGeek", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["meetgeek"], "appIds": [] }, - { "name": "Supernormal", "vendor": "Supernormal", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["supernormal"], "appIds": [] }, - { "name": "Granola", "vendor": "Granola", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["granola"], "appIds": [] }, - { "name": "Grammarly", "vendor": "Grammarly", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["grammarly"], "appIds": [] }, - { "name": "Jasper", "vendor": "Jasper", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["jasper.ai"], "appIds": [] }, - { "name": "QuillBot", "vendor": "QuillBot", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["quillbot"], "appIds": [] }, - { "name": "Wordtune", "vendor": "AI21 Labs", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["wordtune"], "appIds": [] }, - { "name": "Copy.ai", "vendor": "Copy.ai", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["copy.ai"], "appIds": [] }, - { "name": "Writesonic", "vendor": "Writesonic", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["writesonic"], "appIds": [] }, - { "name": "Rytr", "vendor": "Rytr", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["rytr"], "appIds": [] }, - { "name": "Sudowrite", "vendor": "Sudowrite", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["sudowrite"], "appIds": [] }, - { "name": "HyperWrite", "vendor": "HyperWrite", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["hyperwrite"], "appIds": [] }, - { "name": "Writer", "vendor": "Writer", "category": "AI Writing & Translation", "risk": "Low", "matchNames": ["writer.com"], "appIds": [] }, - { "name": "DeepL", "vendor": "DeepL", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["deepl"], "appIds": [] }, - { "name": "Fyxer", "vendor": "Fyxer AI", "category": "AI Email", "risk": "High", "matchNames": ["fyxer"], "appIds": [] }, - { "name": "Superhuman", "vendor": "Superhuman", "category": "AI Email", "risk": "Medium", "matchNames": ["superhuman"], "appIds": [] }, - { "name": "Shortwave", "vendor": "Shortwave", "category": "AI Email", "risk": "Medium", "matchNames": ["shortwave"], "appIds": [] }, - { "name": "Lavender", "vendor": "Lavender", "category": "AI Email", "risk": "Medium", "matchNames": ["lavender"], "appIds": [] }, - { "name": "Glean", "vendor": "Glean", "category": "AI Search & Research", "risk": "Medium", "matchNames": ["glean"], "appIds": [] }, - { "name": "Phind", "vendor": "Phind", "category": "AI Search & Research", "risk": "Medium", "matchNames": ["phind"], "appIds": [] }, - { "name": "ChatPDF", "vendor": "ChatPDF", "category": "AI Search & Research", "risk": "High", "matchNames": ["chatpdf"], "appIds": [] }, - { "name": "AskYourPDF", "vendor": "AskYourPDF", "category": "AI Search & Research", "risk": "High", "matchNames": ["askyourpdf"], "appIds": [] }, - { "name": "SciSpace", "vendor": "SciSpace", "category": "AI Search & Research", "risk": "Medium", "matchNames": ["scispace"], "appIds": [] }, - { "name": "Gamma", "vendor": "Gamma", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["gamma"], "appIds": [] }, - { "name": "Tome", "vendor": "Tome", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["tome.app", "tome ai"], "appIds": [] }, - { "name": "Beautiful.ai", "vendor": "Beautiful.ai", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["beautiful.ai"], "appIds": [] }, - { "name": "Notion", "vendor": "Notion", "category": "AI Presentation & Productivity", "risk": "Informational", "matchNames": ["notion"], "appIds": [] }, - { "name": "Coda", "vendor": "Coda", "category": "AI Presentation & Productivity", "risk": "Informational", "matchNames": ["coda"], "appIds": [] }, - { "name": "Mem", "vendor": "Mem", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["mem.ai"], "appIds": [] }, - { "name": "Monica", "vendor": "Monica", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["monica"], "appIds": [] }, - { "name": "Sider", "vendor": "Sider", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["sider.ai"], "appIds": [] }, - { "name": "Merlin", "vendor": "Merlin", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["merlin"], "appIds": [] }, - { "name": "MaxAI", "vendor": "MaxAI", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["maxai"], "appIds": [] }, - { "name": "Hugging Face", "vendor": "Hugging Face", "category": "AI Platform & API", "risk": "Low", "matchNames": ["hugging face", "huggingface"], "appIds": [] }, - { "name": "Replicate", "vendor": "Replicate", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["replicate"], "appIds": [] }, - { "name": "OpenRouter", "vendor": "OpenRouter", "category": "AI Platform & API", "risk": "High", "matchNames": ["openrouter"], "appIds": [] }, - { "name": "Together AI", "vendor": "Together AI", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["together ai", "together.ai"], "appIds": [] }, - { "name": "Groq", "vendor": "Groq", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["groq"], "appIds": [] }, - { "name": "Fireworks AI", "vendor": "Fireworks AI", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["fireworks ai"], "appIds": [] }, - { "name": "Cohere", "vendor": "Cohere", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["cohere"], "appIds": [] }, - { "name": "AI21 Labs", "vendor": "AI21 Labs", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["ai21"], "appIds": [] }, - { "name": "Databricks", "vendor": "Databricks", "category": "AI Platform & API", "risk": "Informational", "matchNames": ["databricks"], "appIds": [] }, - { "name": "Weights & Biases", "vendor": "Weights & Biases", "category": "AI Platform & API", "risk": "Low", "matchNames": ["weights & biases", "wandb"], "appIds": [] }, - { "name": "Gong", "vendor": "Gong", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["gong"], "appIds": [] }, - { "name": "Apollo.io", "vendor": "Apollo", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["apollo.io"], "appIds": [] }, - { "name": "Regie.ai", "vendor": "Regie.ai", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["regie"], "appIds": [] }, - { "name": "Intercom Fin", "vendor": "Intercom", "category": "AI Business Apps", "risk": "Low", "matchNames": ["intercom"], "appIds": [] }, - { "name": "Salesforce Einstein", "vendor": "Salesforce", "category": "AI Business Apps", "risk": "Informational", "matchNames": ["einstein"], "appIds": [] }, - { "name": "ServiceNow Now Assist", "vendor": "ServiceNow", "category": "AI Business Apps", "risk": "Informational", "matchNames": ["now assist"], "appIds": [] }, - { "name": "Atlassian Rovo", "vendor": "Atlassian", "category": "AI Business Apps", "risk": "Informational", "matchNames": ["rovo"], "appIds": [] }, - { "name": "Moveworks", "vendor": "Moveworks", "category": "AI Business Apps", "risk": "Low", "matchNames": ["moveworks"], "appIds": [] }, - { "name": "HireVue", "vendor": "HireVue", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["hirevue"], "appIds": [] }, - { "name": "Eightfold", "vendor": "Eightfold AI", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["eightfold"], "appIds": [] }, - { "name": "Harvey", "vendor": "Harvey", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["harvey.ai", "harvey ai"], "appIds": [] }, - { "name": "Spellbook", "vendor": "Spellbook", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["spellbook"], "appIds": [] }, - { "name": "DataRobot", "vendor": "DataRobot", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["datarobot"], "appIds": [] }, - { "name": "H2O.ai", "vendor": "H2O.ai", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["h2o.ai", "h2o ai"], "appIds": [] }, - { "name": "Julius AI", "vendor": "Julius", "category": "AI Business Apps", "risk": "High", "matchNames": ["julius ai"], "appIds": [] }, - { "name": "Vapi", "vendor": "Vapi", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["vapi"], "appIds": [] }, - { "name": "Retell AI", "vendor": "Retell", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["retell ai"], "appIds": [] }, - { "name": "Voiceflow", "vendor": "Voiceflow", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["voiceflow"], "appIds": [] }, - { "name": "Botpress", "vendor": "Botpress", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["botpress"], "appIds": [] }, - { "name": "Chatbase", "vendor": "Chatbase", "category": "AI Business Apps", "risk": "High", "matchNames": ["chatbase"], "appIds": [] }, - { "name": "StackAI", "vendor": "StackAI", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["stackai", "stack ai"], "appIds": [] } + { "name": "Azure OpenAI", "vendor": "Microsoft", "category": "AI Platform & API", "risk": "Low", "matchNames": ["azure openai"], "appIds": [], "description": "Microsoft-hosted OpenAI models (GPT, DALL-E, Whisper) delivered as an Azure service inside your own subscription.", "riskReason": "Runs inside the organization's Azure tenant under Microsoft's enterprise terms: prompts and completions are not used to train models, data stays in the selected region, and access is controlled through Entra ID." }, + { "name": "GitHub Copilot", "vendor": "GitHub", "category": "AI Coding", "risk": "Medium", "matchNames": ["github copilot"], "appIds": [], "description": "AI pair programmer from GitHub that suggests code and answers questions inside the editor.", "riskReason": "Business and Enterprise plans exclude code from model training, but the free and individual tiers are common on unmanaged accounts and send proprietary source code under consumer terms." }, + { "name": "OpenAI Codex", "vendor": "OpenAI", "category": "AI Coding", "risk": "High", "matchNames": ["codex"], "appIds": [], "description": "OpenAI's autonomous coding agent that clones repositories and performs multi-step programming tasks.", "riskReason": "Requires deep repository access and executes code autonomously; when used through personal OpenAI accounts, proprietary source and secrets leave the organization with no contractual protection." }, + { "name": "OpenAI Sora", "vendor": "OpenAI", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["sora"], "appIds": [], "description": "OpenAI's text-to-video generator for producing short video clips from prompts.", "riskReason": "Consumer-account driven with limited business controls; uploaded reference footage and prompts are processed under consumer terms, and generated media carries brand-misuse and deepfake potential." }, + { "name": "ChatGPT", "vendor": "OpenAI", "category": "AI Assistant", "risk": "High", "matchNames": ["chatgpt","openai"], "appIds": [], "description": "OpenAI's chat assistant, the most widely used general-purpose AI tool for drafting, analysis and coding.", "riskReason": "Free and Plus accounts train on conversations by default and are almost always personal accounts, so any customer data, credentials or source pasted in leaves the organization's control permanently. Enterprise and Team plans address this but are rarely what is detected." }, + { "name": "Claude", "vendor": "Anthropic", "category": "AI Assistant", "risk": "Medium", "matchNames": ["claude","anthropic"], "appIds": [], "description": "Anthropic's AI assistant for writing, analysis and coding, available via web, desktop and API.", "riskReason": "Anthropic does not train on business API data and offers enterprise controls, but the consumer app is typically used with personal accounts where uploaded files and chats sit outside organizational governance." }, + { "name": "NotebookLM", "vendor": "Google", "category": "AI Search & Research", "risk": "Low", "matchNames": ["notebooklm"], "appIds": [], "description": "Google's research assistant that answers questions grounded in documents you upload.", "riskReason": "Covered by Google Workspace terms when used with a work account: uploaded sources are not used to train models and stay within the Workspace data boundary." }, + { "name": "Google Gemini", "vendor": "Google", "category": "AI Assistant", "risk": "Medium", "matchNames": ["gemini"], "appIds": [], "description": "Google's AI assistant integrated with search and Workspace, with standalone web and mobile apps.", "riskReason": "Workspace-integrated use is contractually covered, but consumer Gemini accounts may use conversations for model improvement and human review, and personal-account use is what typically shows up in discovery." }, + { "name": "Google Antigravity", "vendor": "Google", "category": "AI Coding", "risk": "Medium", "matchNames": ["antigravity"], "appIds": [], "description": "Google's agent-first AI development environment where coding agents plan and execute tasks across editor, terminal and browser.", "riskReason": "Agents get broad workstation access including terminal and browser control; source code and task context are processed by Google models, typically under individual accounts rather than enterprise agreements." }, + { "name": "Vertex AI", "vendor": "Google", "category": "AI Platform & API", "risk": "Low", "matchNames": ["vertex ai"], "appIds": [], "description": "Google Cloud's managed machine-learning platform for building and deploying models, including Gemini APIs.", "riskReason": "An enterprise cloud service governed by Google Cloud terms: customer data is not used to train foundation models and access runs through the organization's GCP project and IAM." }, + { "name": "Microsoft Copilot", "vendor": "Microsoft", "category": "AI Assistant", "risk": "Low", "matchNames": ["copilot"], "appIds": [], "description": "Microsoft's AI assistant across Windows, Edge and Microsoft 365 applications.", "riskReason": "With commercial data protection under a work account, prompts and responses are not retained for training and inherit Microsoft 365 compliance boundaries, making it the default sanctioned option in most tenants." }, + { "name": "Perplexity", "vendor": "Perplexity AI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["perplexity"], "appIds": [], "description": "AI answer engine that combines live web search with model-generated summaries and citations.", "riskReason": "Business-grade privacy exists on Enterprise Pro, but typical usage is free personal accounts where queries - which often contain client and project context - are retained and may improve the service." }, + { "name": "DeepSeek", "vendor": "DeepSeek", "category": "AI Assistant", "risk": "High", "matchNames": ["deepseek"], "appIds": [], "description": "Chinese AI assistant and model family known for strong reasoning at low cost.", "riskReason": "Data is processed and stored in China under Chinese jurisdiction, with documented weak safeguards and prompt data used for training; most Western regulators and enterprises treat any submitted content as disclosed." }, + { "name": "Grok", "vendor": "xAI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["grok","x.ai"], "appIds": [], "description": "xAI's chatbot integrated with X (Twitter), with real-time access to platform content.", "riskReason": "Consumer-first tool tied to personal X accounts; conversations may be used for training and the platform's data handling and moderation posture remain immature compared to enterprise vendors." }, + { "name": "Mistral Le Chat", "vendor": "Mistral AI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["mistral"], "appIds": [], "description": "Chat assistant from French AI vendor Mistral, backed by their open and commercial models.", "riskReason": "EU-based vendor with reasonable privacy posture and paid tiers that exclude training, but detected usage is usually free personal accounts outside any organizational agreement." }, + { "name": "Meta AI", "vendor": "Meta", "category": "AI Assistant", "risk": "Medium", "matchNames": ["meta ai","llama.cpp"], "appIds": [], "description": "Meta's assistant built on Llama models, embedded in WhatsApp, Instagram and standalone apps.", "riskReason": "Consumer product with no business tier: prompts flow into Meta's advertising-driven ecosystem and may be used for model training, with no enterprise controls or contractual protections." }, + { "name": "Qwen", "vendor": "Alibaba", "category": "AI Assistant", "risk": "High", "matchNames": ["qwen"], "appIds": [], "description": "Alibaba's AI assistant and open-model family (Tongyi Qianwen).", "riskReason": "Hosted services process data in China under Chinese data-access laws; submitted business content should be treated as disclosed to an uncontrolled third party." }, + { "name": "Kimi", "vendor": "Moonshot AI", "category": "AI Assistant", "risk": "High", "matchNames": ["kimi","moonshot ai"], "appIds": [], "description": "Moonshot AI's Chinese chat assistant known for very long document context windows.", "riskReason": "Its headline feature is uploading long documents - contracts, reports, specifications - to servers in China under Chinese jurisdiction, combining maximum data exposure with minimal legal recourse." }, + { "name": "Poe", "vendor": "Quora", "category": "AI Assistant", "risk": "Medium", "matchNames": ["poe by quora","quora"], "appIds": [], "description": "Quora's multi-model chat hub giving one subscription access to many AI models and community bots.", "riskReason": "Conversations pass through Quora and on to multiple third-party model providers with varying data policies, and community-created bots can capture inputs; there is no enterprise governance layer." }, + { "name": "You.com", "vendor": "You.com", "category": "AI Assistant", "risk": "Medium", "matchNames": ["you.com"], "appIds": [], "description": "AI search and chat platform offering multiple models with web-connected answers.", "riskReason": "Personal-subscription tool routing queries through multiple model backends; business data in prompts is handled under consumer terms without organizational visibility." }, + { "name": "Pi", "vendor": "Inflection AI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["inflection"], "appIds": [], "description": "Inflection AI's conversational assistant focused on personal, empathetic dialogue.", "riskReason": "A consumer companion-style assistant with no business controls; conversations are retained by the vendor and any work content shared sits outside organizational governance." }, + { "name": "Character.AI", "vendor": "Character Technologies", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["character.ai","character ai"], "appIds": [], "description": "Platform for chatting with user-created AI personas and characters.", "riskReason": "Entertainment platform with no legitimate business use: conversations are retained and used for training, personas encourage oversharing, and its presence on work devices is a policy violation in most organizations." }, + { "name": "Candy.AI", "vendor": "Candy.AI", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["candy.ai"], "appIds": [], "description": "AI companion service for romantic and adult-oriented persona chat.", "riskReason": "Adult content service with aggressive data collection and no business justification; presence on managed devices is an HR and security concern rather than a productivity question." }, + { "name": "Janitor AI", "vendor": "JanitorAI", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["janitor ai","janitorai"], "appIds": [], "description": "Community platform for roleplay chat with user-created AI characters, largely adult-oriented.", "riskReason": "Unvetted community platform with adult content, opaque data handling and user-supplied API key patterns that leak credentials; no business use case exists." }, + { "name": "Crushon AI", "vendor": "Crushon", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["crushon"], "appIds": [], "description": "AI companion chat service focused on unfiltered romantic and adult conversations.", "riskReason": "Adult companion service with no enterprise controls or business purpose; conversations are retained by an anonymous operator and its presence signals acceptable-use policy violations." }, + { "name": "FlowGPT", "vendor": "FlowGPT", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["flowgpt"], "appIds": [], "description": "Community marketplace for sharing AI prompts and character bots across models.", "riskReason": "Community-run bots and prompt-sharing mean inputs may be visible to bot creators and the platform; content moderation is weak and there is no business tier or data protection agreement." }, + { "name": "Cursor", "vendor": "Anysphere", "category": "AI Coding", "risk": "Medium", "matchNames": ["cursor"], "appIds": [], "description": "AI-first code editor (VS Code fork) with deep codebase-aware chat and autonomous edit modes.", "riskReason": "Indexes and uploads codebase context to cloud models; Business plans offer privacy mode with no training, but individual subscriptions on personal accounts are the norm and put proprietary source outside company control." }, + { "name": "Codeium / Windsurf", "vendor": "Codeium", "category": "AI Coding", "risk": "Medium", "matchNames": ["codeium","windsurf"], "appIds": [], "description": "AI coding assistant and the Windsurf agentic editor from Codeium.", "riskReason": "Sends code context to cloud completion services; enterprise self-hosted options exist, but free individual tiers dominate detection and carry code exfiltration risk under consumer terms." }, + { "name": "Tabnine", "vendor": "Tabnine", "category": "AI Coding", "risk": "Medium", "matchNames": ["tabnine"], "appIds": [], "description": "Privacy-focused AI code completion tool supporting local and private deployments.", "riskReason": "Positions on privacy with zero-retention and private deployment options, but free personal use still sends code snippets to cloud services outside organizational agreements." }, + { "name": "Sourcegraph Cody", "vendor": "Sourcegraph", "category": "AI Coding", "risk": "Medium", "matchNames": ["sourcegraph","cody"], "appIds": [], "description": "Codebase-aware AI assistant from Sourcegraph that answers questions across large repositories.", "riskReason": "Enterprise deployments are well-governed, but free individual use grants a third party read access to whole repositories under personal accounts." }, + { "name": "Aider", "vendor": "Aider", "category": "AI Coding", "risk": "Medium", "matchNames": ["aider"], "appIds": [], "description": "Open-source terminal-based AI pair programmer that edits local git repositories.", "riskReason": "The tool itself is local, but it ships code to whichever model API key the user supplies - typically personal OpenAI or Anthropic keys with no organizational data terms." }, + { "name": "Amazon Q / CodeWhisperer", "vendor": "Amazon", "category": "AI Coding", "risk": "Medium", "matchNames": ["codewhisperer","amazon q"], "appIds": [], "description": "AWS's AI coding assistant and enterprise Q&A assistant for developers.", "riskReason": "Professional tiers under an AWS organization inherit enterprise terms, but Builder ID (personal) usage sends code to AWS outside the organization's agreements." }, + { "name": "Amazon Bedrock", "vendor": "Amazon", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["bedrock"], "appIds": [], "description": "AWS managed service for accessing foundation models (Anthropic, Meta, Amazon) via API.", "riskReason": "An enterprise cloud service with no training on customer data, but usage detected outside a governed AWS account may indicate shadow API projects moving company data through personal accounts." }, + { "name": "Amazon Kiro", "vendor": "Amazon", "category": "AI Coding", "risk": "Medium", "matchNames": ["kiro"], "appIds": [], "description": "AWS's agentic AI development environment built around spec-driven coding.", "riskReason": "Agentic access to source and specs routed through AWS accounts; safe under organizational AWS agreements but frequently adopted via personal Builder IDs first." }, + { "name": "Replit", "vendor": "Replit", "category": "AI Coding", "risk": "High", "matchNames": ["replit"], "appIds": [], "description": "Browser-based development platform with AI agent that builds and deploys applications.", "riskReason": "Code lives on Replit's cloud by default and free-tier workspaces are public, so proprietary code pasted in becomes literally public; the AI agent also deploys with broad permissions." }, + { "name": "Lovable", "vendor": "Lovable", "category": "AI Coding", "risk": "High", "matchNames": ["lovable"], "appIds": [], "description": "AI app builder that generates full-stack applications from natural-language prompts.", "riskReason": "Prompt-to-app platforms receive business logic, data models and often production credentials during building; generated apps default to public hosting and personal accounts, bypassing all development governance." }, + { "name": "Bolt.new", "vendor": "StackBlitz", "category": "AI Coding", "risk": "High", "matchNames": ["bolt.new","stackblitz"], "appIds": [], "description": "StackBlitz's AI web-app builder that generates and runs full-stack projects in the browser.", "riskReason": "Project code and any secrets pasted during development are processed in the vendor's cloud under personal accounts; output ships to public hosting with no organizational review." }, + { "name": "v0", "vendor": "Vercel", "category": "AI Coding", "risk": "High", "matchNames": ["v0.dev"], "appIds": [], "description": "Vercel's AI UI generator that produces React components and full pages from prompts.", "riskReason": "Designs, internal UI patterns and business context flow to Vercel under personal accounts, and generated projects deploy publicly with one click, outside change control." }, + { "name": "Devin", "vendor": "Cognition", "category": "AI Coding", "risk": "High", "matchNames": ["devin ai","cognition labs"], "appIds": [], "description": "Cognition's autonomous AI software engineer that plans and completes entire development tasks.", "riskReason": "Requires standing access to repositories, credentials and infrastructure to work autonomously - an extremely broad grant to an external service, usually procured by individual developers rather than through security review." }, + { "name": "Continue", "vendor": "Continue", "category": "AI Coding", "risk": "Medium", "matchNames": ["continue.dev"], "appIds": [], "description": "Open-source AI coding assistant extension that connects editors to any model provider.", "riskReason": "The extension is local and configurable for private models, but in practice routes code to cloud APIs on personal keys, inheriting whatever data terms the chosen provider applies." }, + { "name": "Cline", "vendor": "Cline", "category": "AI Coding", "risk": "Medium", "matchNames": ["cline"], "appIds": [], "description": "Open-source autonomous coding agent for VS Code with terminal and file access.", "riskReason": "Executes commands and edits files autonomously while shipping repository context to user-supplied model APIs on personal keys; power and data exposure depend entirely on the individual's configuration." }, + { "name": "Roo Code", "vendor": "Roo Code", "category": "AI Coding", "risk": "Medium", "matchNames": ["roo code"], "appIds": [], "description": "Fork of Cline offering multi-mode autonomous coding agents in VS Code.", "riskReason": "Same profile as other agentic coding extensions: broad workspace access with code context sent to personal model API keys outside organizational terms." }, + { "name": "Qodo", "vendor": "Qodo", "category": "AI Coding", "risk": "Medium", "matchNames": ["qodo"], "appIds": [], "description": "AI code-review and test-generation platform (formerly CodiumAI).", "riskReason": "Enterprise tiers provide zero-retention guarantees, but individual free usage sends diffs and tests to the vendor cloud under personal accounts." }, + { "name": "Warp", "vendor": "Warp", "category": "AI Coding", "risk": "Medium", "matchNames": ["warp.dev"], "appIds": [], "description": "AI-enhanced terminal with command generation and agentic workflows.", "riskReason": "Terminal history and command context - which routinely contain hostnames, paths and secrets - are processed by cloud AI under individual accounts unless the enterprise tier with zero-retention is used." }, + { "name": "Visual Studio Code", "vendor": "Microsoft", "category": "AI-Capable Editor", "risk": "Informational", "matchNames": ["visual studio code","vscode"], "appIds": [], "description": "Microsoft's code editor; not an AI tool itself but the primary host for AI coding extensions.", "riskReason": "Listed for visibility only: the editor is standard software, but its presence indicates where AI coding extensions like Copilot, Cline or Continue may be running." }, + { "name": "JetBrains AI", "vendor": "JetBrains", "category": "AI-Capable Editor", "risk": "Low", "matchNames": ["jetbrains ai"], "appIds": [], "description": "AI assistant integrated into JetBrains IDEs with organizational controls.", "riskReason": "Offered through JetBrains accounts with enterprise licensing, no training on customer code and admin-controllable data sharing, making governed deployment straightforward." }, + { "name": "Ollama", "vendor": "Ollama", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["ollama"], "appIds": [], "description": "Local model runtime for running open-weight LLMs on the user's own machine.", "riskReason": "Data stays on-device which removes exfiltration risk, but it bypasses all monitoring, model outputs are ungoverned, and pulled models plus exposed local APIs create an unmanaged attack surface." }, + { "name": "LM Studio", "vendor": "LM Studio", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["lm studio","lmstudio"], "appIds": [], "description": "Desktop application for downloading and running local LLMs with a chat interface.", "riskReason": "Fully local processing avoids data leakage, but unvetted community models, an optional local server that other apps can call, and zero organizational visibility keep it in the review category." }, + { "name": "GPT4All", "vendor": "Nomic AI", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["gpt4all"], "appIds": [], "description": "Nomic's desktop app for running local open-source models, including local document Q&A.", "riskReason": "Local-first with optional telemetry; the data risk is low but local document indexing and unvetted models operate entirely outside IT visibility." }, + { "name": "Jan", "vendor": "Jan", "category": "Local AI Runtime", "risk": "Low", "matchNames": ["jan.ai"], "appIds": [], "description": "Open-source, offline-first desktop assistant running local models.", "riskReason": "Designed to run fully offline with local storage and no cloud dependency, giving it one of the smallest data-exposure footprints of any AI tool - the remaining concern is only ungoverned output." }, + { "name": "Open WebUI", "vendor": "Open WebUI", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["open webui"], "appIds": [], "description": "Self-hosted web interface for local and remote LLMs, often paired with Ollama.", "riskReason": "Self-hosted and private by design, but frequently configured with remote API keys and exposed as a network service without authentication hardening, quietly becoming unmanaged AI infrastructure." }, + { "name": "AnythingLLM", "vendor": "Mintplex Labs", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["anythingllm"], "appIds": [], "description": "Desktop and server app for chatting with documents using local or cloud models.", "riskReason": "Document ingestion is the core feature: with local models exposure is minimal, but many setups connect personal cloud API keys, sending indexed company documents to third parties." }, + { "name": "Oobabooga", "vendor": "Community", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["oobabooga"], "appIds": [], "description": "Community web UI for running and experimenting with local text-generation models.", "riskReason": "Hobbyist tooling on work devices: local processing limits data risk, but unvetted models and extensions and the experimentation culture around it fall outside any governance." }, + { "name": "KoboldCpp", "vendor": "Community", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["koboldcpp"], "appIds": [], "description": "Lightweight community runtime for local models, popular for storytelling and roleplay.", "riskReason": "Local execution keeps data on-device; primarily an indicator of personal/entertainment model use on managed hardware rather than a data-loss vector." }, + { "name": "LocalAI", "vendor": "LocalAI", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["localai"], "appIds": [], "description": "Self-hosted, OpenAI-compatible API server for running models locally.", "riskReason": "Keeps inference on-premises, but stands up an unmanaged OpenAI-compatible endpoint other tools and scripts can silently target, creating shadow AI infrastructure." }, + { "name": "Msty", "vendor": "Msty", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["msty"], "appIds": [], "description": "Desktop app that unifies local and cloud models in a single chat interface.", "riskReason": "Mixes local models with cloud providers behind one UI, so users may believe they are working locally while prompts actually flow to cloud APIs on personal keys." }, + { "name": "OpenClaw / Claw", "vendor": "Community", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["claw"], "appIds": [], "description": "Community autonomous agent framework that chains model calls to complete tasks.", "riskReason": "Autonomous agents with tool access run unattended actions on the user's machine and accounts, with prompts and captured data sent to whatever model API is configured - unvetted code with broad permissions." }, + { "name": "AutoGPT", "vendor": "Significant Gravitas", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["autogpt"], "appIds": [], "description": "Early autonomous GPT agent framework that self-directs multi-step tasks.", "riskReason": "Executes self-generated actions with file, browser and API access on personal OpenAI keys; unpredictable behavior plus broad local permissions make it high risk on any managed device." }, + { "name": "AgentGPT", "vendor": "Reworkd", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["agentgpt"], "appIds": [], "description": "Browser-based autonomous agent platform from Reworkd.", "riskReason": "Goals and intermediate results are processed in the vendor's cloud on consumer accounts, and autonomous web actions run without organizational oversight." }, + { "name": "BabyAGI", "vendor": "Community", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["babyagi"], "appIds": [], "description": "Minimal open-source task-driven autonomous agent experiment.", "riskReason": "Experimental agent code run from personal API keys with no controls; its presence indicates ungoverned agent experimentation on work devices." }, + { "name": "Manus", "vendor": "Monica", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["manus ai","manus.im"], "appIds": [], "description": "Autonomous general-purpose AI agent from Monica (Butterfly Effect) that completes complex tasks in cloud VMs.", "riskReason": "Tasks, files and credentials are handed to autonomous cloud VMs operated by a Chinese-founded vendor with limited transparency; data jurisdiction and agent autonomy combine into high exposure." }, + { "name": "LangChain", "vendor": "LangChain", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["langchain"], "appIds": [], "description": "Developer framework for building LLM applications and agent pipelines.", "riskReason": "The framework itself is neutral, but its presence means employees are building AI integrations that move company data to model APIs - typically on personal keys and without security review." }, + { "name": "LlamaIndex", "vendor": "LlamaIndex", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["llamaindex"], "appIds": [], "description": "Framework for connecting private data sources to LLMs for retrieval-augmented generation.", "riskReason": "Purpose-built to index internal documents and feed them to models; unofficial projects using it move company data into embeddings and third-party APIs without governance." }, + { "name": "CrewAI", "vendor": "CrewAI", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["crewai"], "appIds": [], "description": "Framework for orchestrating teams of autonomous AI agents.", "riskReason": "Multi-agent orchestration amplifies single-agent risk: several autonomous agents share data and take actions across tools on personal API keys, with no audit trail." }, + { "name": "n8n", "vendor": "n8n", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["n8n"], "appIds": [], "description": "Workflow automation platform with extensive AI and LLM integration nodes.", "riskReason": "Self-hosted deployments can be well-governed, but cloud accounts wired to mailboxes, CRMs and LLM APIs move production data through personal automations that IT cannot see." }, + { "name": "Zapier", "vendor": "Zapier", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["zapier"], "appIds": [], "description": "Cloud automation service connecting thousands of apps, now with AI steps and agents.", "riskReason": "Every zap is a standing data pipe between company systems and third parties under a personal account; adding AI steps sends that data onward to model providers." }, + { "name": "Make", "vendor": "Make", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["make.com"], "appIds": [], "description": "Visual cloud automation platform (formerly Integromat) with AI modules.", "riskReason": "Same profile as other cloud automation: personal accounts holding OAuth grants into company mailboxes and files, now feeding content to AI modules outside governance." }, + { "name": "Lindy", "vendor": "Lindy", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["lindy"], "appIds": [], "description": "No-code platform for building personal AI agents that handle email, scheduling and workflows.", "riskReason": "Agents are granted standing OAuth access to mailboxes and calendars and act autonomously on their content; a personal Lindy account is effectively an unmanaged delegate with inbox access." }, + { "name": "Relevance AI", "vendor": "Relevance AI", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["relevance ai"], "appIds": [], "description": "Platform for building AI agent workforces for sales, support and operations tasks.", "riskReason": "Business processes and customer data are delegated to cloud agents configured by individuals; broad integrations plus autonomous outbound actions carry high exposure without contract review." }, + { "name": "Dust", "vendor": "Dust", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["dust.tt"], "appIds": [], "description": "Platform for building internal AI assistants connected to company knowledge sources.", "riskReason": "Connecting Slack, Drive and Notion to a third-party assistant platform is exactly the kind of grant that needs vendor review; adopted properly it is governable, adopted personally it is a data pipeline." }, + { "name": "Bardeen", "vendor": "Bardeen", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["bardeen"], "appIds": [], "description": "Browser extension that automates web tasks with AI, including scraping and form filling.", "riskReason": "A browser extension with page access on work sites: it can read whatever the user sees, including CRM and admin consoles, and sends context to cloud AI under personal accounts." }, + { "name": "Gumloop", "vendor": "Gumloop", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["gumloop"], "appIds": [], "description": "No-code AI workflow builder for automating data processing and web tasks.", "riskReason": "Workflows routinely ingest spreadsheets and scraped business data into cloud AI processing under individual accounts, outside data-handling agreements." }, + { "name": "Browse AI", "vendor": "Browse AI", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["browse ai"], "appIds": [], "description": "No-code web scraping and monitoring service with AI extraction.", "riskReason": "Primarily a scraping tool on personal accounts; risk centers on ungoverned collection of competitor or customer data and credentials saved for authenticated scraping." }, + { "name": "Midjourney", "vendor": "Midjourney", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["midjourney"], "appIds": [], "description": "Leading AI image generator operated through Discord and web.", "riskReason": "Generations are public by default on standard plans and prompts may include product or campaign details; operation through personal Discord accounts leaves no organizational control." }, + { "name": "Stable Diffusion", "vendor": "Stability AI", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["stable diffusion","stability ai"], "appIds": [], "description": "Open-source image generation model family from Stability AI, run locally or via services.", "riskReason": "Local use keeps data private but unmanaged; hosted services vary widely in data handling. Main concerns are ungoverned content generation and licensing of outputs." }, + { "name": "DALL-E", "vendor": "OpenAI", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["dall-e","dalle"], "appIds": [], "description": "OpenAI's image generation model, accessed through ChatGPT and the API.", "riskReason": "Image prompts on personal OpenAI accounts fall under consumer terms; risk is moderate and mostly about prompt content and brand-safe usage of outputs." }, + { "name": "Adobe Firefly", "vendor": "Adobe", "category": "AI Image & Design", "risk": "Low", "matchNames": ["firefly"], "appIds": [], "description": "Adobe's image and design generation integrated into Creative Cloud.", "riskReason": "Trained on licensed content with commercial-use indemnification and delivered through managed Creative Cloud accounts, making it one of the safer generative design options." }, + { "name": "Canva", "vendor": "Canva", "category": "AI Image & Design", "risk": "Informational", "matchNames": ["canva"], "appIds": [], "description": "Design platform with embedded AI features (Magic Studio); primarily a standard business tool.", "riskReason": "Listed for visibility because of its embedded AI features; as a mainstream design platform commonly under business accounts, it is not in itself a shadow AI concern." }, + { "name": "Leonardo AI", "vendor": "Leonardo.Ai", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["leonardo.ai","leonardo ai"], "appIds": [], "description": "AI image generation platform popular for marketing and game assets.", "riskReason": "Consumer accounts with community-visible generations by default; prompts and reference uploads sit under personal accounts outside brand and data governance." }, + { "name": "Ideogram", "vendor": "Ideogram", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["ideogram"], "appIds": [], "description": "AI image generator known for accurate text rendering in images.", "riskReason": "Free tiers make generations public and usage is personal-account based; exposure is mostly prompt content and ungoverned brand asset creation." }, + { "name": "Krea", "vendor": "Krea", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["krea.ai"], "appIds": [], "description": "Real-time AI image generation and enhancement studio.", "riskReason": "Uploaded reference images and brand assets are processed under personal consumer accounts with default sharing, outside organizational control." }, + { "name": "ComfyUI", "vendor": "Comfy Org", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["comfyui"], "appIds": [], "description": "Node-based local interface for building image and video generation pipelines.", "riskReason": "Local generation keeps data on-device; the risk profile is unvetted community workflows and custom nodes executing arbitrary code on work machines." }, + { "name": "Automatic1111", "vendor": "Community", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["automatic1111"], "appIds": [], "description": "Community web UI for running Stable Diffusion locally.", "riskReason": "Local processing with minimal data risk; concerns are community extensions running arbitrary code and ungoverned content generation on managed hardware." }, + { "name": "InvokeAI", "vendor": "Invoke", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["invokeai"], "appIds": [], "description": "Professional-oriented local Stable Diffusion interface.", "riskReason": "Local-first generation with an optional server mode; low data exposure but operates entirely outside IT visibility on work devices." }, + { "name": "Remove.bg", "vendor": "Kaleido", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["remove.bg"], "appIds": [], "description": "Web service that removes image backgrounds automatically.", "riskReason": "Every processed image is uploaded to the vendor's cloud; used casually with product shots or people photos under free accounts with retention rights." }, + { "name": "Photoroom", "vendor": "Photoroom", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["photoroom"], "appIds": [], "description": "AI photo editing service for product imagery and background editing.", "riskReason": "Product and staff images are uploaded to consumer cloud processing; moderate exposure centered on image rights and retention under personal accounts." }, + { "name": "Synthesia", "vendor": "Synthesia", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["synthesia"], "appIds": [], "description": "AI video platform generating presenter-led videos from scripts with avatars.", "riskReason": "Scripts often contain internal training or product material and are processed in the vendor cloud; enterprise plans exist but individual accounts dominate casual adoption." }, + { "name": "HeyGen", "vendor": "HeyGen", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["heygen"], "appIds": [], "description": "AI video generation platform with talking avatars and video translation.", "riskReason": "Voice cloning and avatar creation from uploaded footage of real people raises consent and impersonation concerns; scripts and media are processed under personal accounts." }, + { "name": "RunwayML", "vendor": "Runway", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["runwayml","runway ml"], "appIds": [], "description": "Creative AI suite for video generation and editing (Gen-series models).", "riskReason": "Uploaded footage and creative material are processed in the vendor cloud on consumer plans; exposure is project confidentiality rather than structured data loss." }, + { "name": "Descript", "vendor": "Descript", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["descript"], "appIds": [], "description": "AI audio/video editor with transcription, overdub voice cloning and screen recording.", "riskReason": "Meeting and call recordings uploaded for editing contain conversations that were never cleared for third-party processing, and voice cloning adds impersonation risk." }, + { "name": "OpusClip", "vendor": "OpusClip", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["opusclip","opus clip"], "appIds": [], "description": "AI service that turns long videos into short social clips.", "riskReason": "Uploaded video content is processed under consumer accounts; moderate exposure limited to the confidentiality of the footage itself." }, + { "name": "Veed", "vendor": "Veed", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["veed.io"], "appIds": [], "description": "Browser-based AI video editing and subtitling service.", "riskReason": "Video and audio uploads processed in the vendor cloud on personal accounts; typical exposure is internal or client footage leaving controlled storage." }, + { "name": "Pika", "vendor": "Pika Labs", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["pika labs"], "appIds": [], "description": "Text-to-video and image-to-video generation service.", "riskReason": "Consumer generation service; prompts and reference uploads sit under personal accounts with default community visibility on lower tiers." }, + { "name": "Luma", "vendor": "Luma AI", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["luma ai","lumalabs"], "appIds": [], "description": "AI 3D capture and video generation (Dream Machine) from Luma AI.", "riskReason": "Uploaded captures and prompts processed under consumer terms; low-to-moderate business exposure unless internal environments or products are captured." }, + { "name": "Pictory", "vendor": "Pictory", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["pictory"], "appIds": [], "description": "AI service converting scripts and articles into narrated videos.", "riskReason": "Marketing scripts and internal content are uploaded to consumer cloud processing; exposure is content confidentiality under personal accounts." }, + { "name": "InVideo", "vendor": "InVideo", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["invideo"], "appIds": [], "description": "Template-driven AI video creation platform.", "riskReason": "Script and asset uploads under personal accounts; moderate confidentiality exposure typical of consumer creative tools." }, + { "name": "CapCut", "vendor": "ByteDance", "category": "AI Video & Audio", "risk": "High", "matchNames": ["capcut"], "appIds": [], "description": "ByteDance's video editor with AI features, tied to the TikTok ecosystem.", "riskReason": "Owned by ByteDance with data processed under Chinese jurisdiction and aggressive telemetry; uploaded footage and device data sit outside any acceptable enterprise boundary." }, + { "name": "ElevenLabs", "vendor": "ElevenLabs", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["elevenlabs"], "appIds": [], "description": "Leading AI voice generation and cloning platform.", "riskReason": "High-quality voice cloning from short samples creates real impersonation and fraud risk (vishing, fake executive audio); business tiers exist but personal accounts dominate." }, + { "name": "Murf", "vendor": "Murf", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["murf"], "appIds": [], "description": "AI voiceover generation service for narration and presentations.", "riskReason": "Scripts uploaded to consumer cloud processing; moderate exposure around content confidentiality, lower cloning risk than sample-based services." }, + { "name": "Play.ht", "vendor": "PlayHT", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["play.ht"], "appIds": [], "description": "AI text-to-speech and voice cloning API and studio.", "riskReason": "Voice cloning capability plus consumer-account usage carries impersonation risk and puts scripts under personal-account terms." }, + { "name": "Speechify", "vendor": "Speechify", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["speechify"], "appIds": [], "description": "Text-to-speech app that reads documents and web pages aloud.", "riskReason": "Documents are uploaded for narration - often internal reports read on commutes - placing their content under a consumer service's retention terms." }, + { "name": "Suno", "vendor": "Suno", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["suno"], "appIds": [], "description": "AI music generation service creating songs from text prompts.", "riskReason": "Low business-data exposure; concerns are personal use on work devices and unresolved copyright status of generated music used in company content." }, + { "name": "Otter.ai", "vendor": "Otter", "category": "AI Meeting Notetaker", "risk": "High", "matchNames": ["otter.ai"], "appIds": [], "description": "AI meeting transcription service with an auto-joining meeting bot.", "riskReason": "The bot joins and records entire meetings - including other parties who never consented - and stores full transcripts of confidential discussions under a personal account; recording-consent laws make this a legal exposure, not just a data one." }, + { "name": "Fireflies.ai", "vendor": "Fireflies", "category": "AI Meeting Notetaker", "risk": "High", "matchNames": ["fireflies"], "appIds": [], "description": "AI notetaker that records, transcribes and analyzes meetings across platforms.", "riskReason": "Standing calendar and meeting access with automatic recording; complete conversation archives including client calls accumulate in a third-party cloud under individual accounts." }, + { "name": "Fathom", "vendor": "Fathom", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["fathom"], "appIds": [], "description": "Free AI meeting recorder and summarizer for Zoom, Meet and Teams.", "riskReason": "Same recording-consent and transcript-retention concerns as other notetakers, moderated by a clearer free-tier privacy stance; still individual-account driven." }, + { "name": "tl;dv", "vendor": "tldv", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["tldv","tl;dv"], "appIds": [], "description": "Meeting recording and AI summary tool for Meet, Zoom and Teams.", "riskReason": "Records meetings under personal accounts with transcripts stored in the vendor cloud; consent and client-confidentiality obligations are routinely skipped." }, + { "name": "Read AI", "vendor": "Read", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["read.ai"], "appIds": [], "description": "Meeting intelligence tool measuring engagement and generating summaries, expanding into email and messaging.", "riskReason": "Beyond recording meetings it requests mail and calendar scopes to score communications, concentrating a broad slice of workplace interaction data under a personal grant." }, + { "name": "Krisp", "vendor": "Krisp", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["krisp"], "appIds": [], "description": "AI noise cancellation and meeting transcription running on the local device.", "riskReason": "Noise processing is local which is genuinely privacy-friendly; the optional cloud transcription and summaries carry standard notetaker exposure." }, + { "name": "Sembly", "vendor": "Sembly AI", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["sembly"], "appIds": [], "description": "AI meeting assistant generating notes, tasks and insights.", "riskReason": "Meeting audio and transcripts processed in vendor cloud under individual accounts; standard notetaker consent and retention concerns." }, + { "name": "Avoma", "vendor": "Avoma", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["avoma"], "appIds": [], "description": "Meeting lifecycle assistant with recording, transcription and revenue intelligence.", "riskReason": "Customer call recordings and CRM-linked conversation data in a third-party cloud; governable on business plans, risky as individual adoption." }, + { "name": "MeetGeek", "vendor": "MeetGeek", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["meetgeek"], "appIds": [], "description": "Automatic meeting recorder with AI summaries and insights.", "riskReason": "Auto-join recording under personal accounts with cloud transcript retention; standard meeting-notetaker exposure." }, + { "name": "Supernormal", "vendor": "Supernormal", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["supernormal"], "appIds": [], "description": "AI meeting notes tool integrated with calendars.", "riskReason": "Calendar access plus automatic meeting capture under individual accounts; transcripts of internal and client meetings accumulate outside governance." }, + { "name": "Granola", "vendor": "Granola", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["granola"], "appIds": [], "description": "AI notepad that transcribes meetings locally from system audio without a bot.", "riskReason": "No visible bot means participants rarely know transcription is happening; notes and transcripts still sync to the vendor cloud under personal accounts, with consent risk hidden from other parties." }, + { "name": "Grammarly", "vendor": "Grammarly", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["grammarly"], "appIds": [], "description": "AI writing assistant checking and rewriting text across every application.", "riskReason": "As a system-wide extension it reads nearly everything typed - emails, contracts, HR notes - and processes it in the vendor cloud; Business plans add controls, but free personal accounts are pervasive." }, + { "name": "Jasper", "vendor": "Jasper", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["jasper.ai"], "appIds": [], "description": "AI marketing content generation platform.", "riskReason": "Campaign briefs and brand material are processed under vendor cloud terms; business-oriented vendor with acceptable terms, but individual accounts skip procurement review." }, + { "name": "QuillBot", "vendor": "QuillBot", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["quillbot"], "appIds": [], "description": "AI paraphrasing and summarization tool popular for rewriting text.", "riskReason": "Users paste whole documents for rewriting into a free consumer service; content retention under personal accounts is the primary exposure." }, + { "name": "Wordtune", "vendor": "AI21 Labs", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["wordtune"], "appIds": [], "description": "AI21's writing companion for rewriting and summarizing.", "riskReason": "Browser-extension text access plus consumer-account processing; pasted business text falls under personal-account terms." }, + { "name": "Copy.ai", "vendor": "Copy.ai", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["copy.ai"], "appIds": [], "description": "AI copywriting and go-to-market content platform.", "riskReason": "Marketing and sales content processed on free or personal plans; moderate confidentiality exposure, business tiers available." }, + { "name": "Writesonic", "vendor": "Writesonic", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["writesonic"], "appIds": [], "description": "AI content writing platform for articles and marketing copy.", "riskReason": "Standard consumer content-generation exposure: briefs and drafts under personal accounts in the vendor cloud." }, + { "name": "Rytr", "vendor": "Rytr", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["rytr"], "appIds": [], "description": "Budget AI writing assistant for short-form content.", "riskReason": "Free consumer writing tool; pasted content is retained under consumer terms with no organizational controls." }, + { "name": "Sudowrite", "vendor": "Sudowrite", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["sudowrite"], "appIds": [], "description": "AI creative writing tool aimed at fiction authors.", "riskReason": "Primarily personal creative use on work devices; business-data exposure is low but usage is entirely ungoverned." }, + { "name": "HyperWrite", "vendor": "HyperWrite", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["hyperwrite"], "appIds": [], "description": "AI writing assistant with autonomous browser agent features.", "riskReason": "Beyond text generation, its browser agent can act on pages the user is signed into, extending consumer-account AI into authenticated business webapps." }, + { "name": "Writer", "vendor": "Writer", "category": "AI Writing & Translation", "risk": "Low", "matchNames": ["writer.com"], "appIds": [], "description": "Enterprise generative AI platform with brand and compliance guardrails.", "riskReason": "Built for enterprise deployment: no training on customer data, SOC 2 controls and admin governance; risk mainly arises if adopted as individual accounts instead of a managed rollout." }, + { "name": "DeepL", "vendor": "DeepL", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["deepl"], "appIds": [], "description": "High-quality machine translation service with document upload.", "riskReason": "Whole documents - contracts, correspondence - are uploaded for translation; Pro guarantees deletion after translation, but free usage retains text for training under consumer terms." }, + { "name": "Fyxer", "vendor": "Fyxer AI", "category": "AI Email", "risk": "High", "matchNames": ["fyxer"], "appIds": [], "description": "AI executive assistant that drafts email replies and organizes inboxes.", "riskReason": "Requires full mailbox access and reads every message to draft replies - complete correspondence history including confidential threads flows to a young vendor, usually via an individual OAuth grant." }, + { "name": "Superhuman", "vendor": "Superhuman", "category": "AI Email", "risk": "Medium", "matchNames": ["superhuman"], "appIds": [], "description": "Premium email client with AI drafting and triage.", "riskReason": "Full mailbox access through a third-party client with AI processing; a credible vendor, but individual adoption grants inbox-wide access without organizational review." }, + { "name": "Shortwave", "vendor": "Shortwave", "category": "AI Email", "risk": "Medium", "matchNames": ["shortwave"], "appIds": [], "description": "AI-native email client built on Gmail with assistant-driven search and drafting.", "riskReason": "Inbox-wide access and AI processing of mail content under personal choice of client; same standing-mailbox-grant concern as other AI email tools." }, + { "name": "Lavender", "vendor": "Lavender", "category": "AI Email", "risk": "Medium", "matchNames": ["lavender"], "appIds": [], "description": "AI sales email coach that scores and improves outreach messages.", "riskReason": "Reads and analyzes sales correspondence via extension access; prospect data and messaging flow to the vendor under individual seats." }, + { "name": "Glean", "vendor": "Glean", "category": "AI Search & Research", "risk": "Medium", "matchNames": ["glean"], "appIds": [], "description": "Enterprise AI search across company applications and knowledge.", "riskReason": "A legitimate enterprise product whose entire function is indexing company data; properly deployed it is well-governed, but any non-IT-sanctioned connection of data sources is a serious grant." }, + { "name": "Phind", "vendor": "Phind", "category": "AI Search & Research", "risk": "Medium", "matchNames": ["phind"], "appIds": [], "description": "AI answer engine specialized for developers and technical questions.", "riskReason": "Developers paste error output and code snippets into a consumer web service; retention under personal accounts is the main exposure." }, + { "name": "ChatPDF", "vendor": "ChatPDF", "category": "AI Search & Research", "risk": "High", "matchNames": ["chatpdf"], "appIds": [], "description": "Web service for chatting with uploaded PDF documents.", "riskReason": "Its sole function is uploading documents - contracts, reports, financials - to an inexpensive consumer service with minimal transparency about retention and reuse; document exfiltration is the product." }, + { "name": "AskYourPDF", "vendor": "AskYourPDF", "category": "AI Search & Research", "risk": "High", "matchNames": ["askyourpdf"], "appIds": [], "description": "Document Q&A service for uploaded PDFs and files.", "riskReason": "Same profile as other document-chat services: business documents are uploaded wholesale to a small vendor under consumer terms with unclear retention." }, + { "name": "SciSpace", "vendor": "SciSpace", "category": "AI Search & Research", "risk": "Medium", "matchNames": ["scispace"], "appIds": [], "description": "AI research assistant for reading and explaining academic papers.", "riskReason": "Mostly public-paper analysis with moderate exposure; uploaded internal research or drafts would sit under consumer terms." }, + { "name": "Gamma", "vendor": "Gamma", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["gamma"], "appIds": [], "description": "AI presentation and document builder generating decks from prompts.", "riskReason": "Business plans, strategy outlines and client material are pasted in to generate decks, all stored in the vendor cloud under personal accounts." }, + { "name": "Tome", "vendor": "Tome", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["tome.app","tome ai"], "appIds": [], "description": "AI storytelling and presentation tool.", "riskReason": "Same as other AI deck builders: presentation content with business context processed and stored under consumer accounts." }, + { "name": "Beautiful.ai", "vendor": "Beautiful.ai", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["beautiful.ai"], "appIds": [], "description": "AI-assisted presentation design platform.", "riskReason": "Deck content in vendor cloud under individual accounts; moderate confidentiality exposure, team plans available." }, + { "name": "Notion", "vendor": "Notion", "category": "AI Presentation & Productivity", "risk": "Informational", "matchNames": ["notion"], "appIds": [], "description": "Workspace platform with embedded Notion AI; primarily a standard business tool.", "riskReason": "Listed for visibility because of embedded AI features; as a mainstream workspace product it is a procurement question rather than shadow AI, unless personal workspaces hold company content." }, + { "name": "Coda", "vendor": "Coda", "category": "AI Presentation & Productivity", "risk": "Informational", "matchNames": ["coda"], "appIds": [], "description": "Document/database workspace with embedded AI; primarily a standard business tool.", "riskReason": "Included for visibility of its AI features; risk profile is that of a normal SaaS workspace, notable only when company data lives in personal accounts." }, + { "name": "Mem", "vendor": "Mem", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["mem.ai"], "appIds": [], "description": "AI-organized note-taking app.", "riskReason": "Personal notes app that accumulates meeting notes and work context in a consumer cloud with AI processing under individual accounts." }, + { "name": "Monica", "vendor": "Monica", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["monica"], "appIds": [], "description": "All-in-one AI browser extension bundling chat, reading and writing on any page.", "riskReason": "Browser extensions with page access can read everything the user views - webmail, CRM, admin panels - and send page content to cloud AI on consumer accounts; broad access with minimal transparency." }, + { "name": "Sider", "vendor": "Sider", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["sider.ai"], "appIds": [], "description": "AI sidebar browser extension for chat, reading and writing on any page.", "riskReason": "Same profile as other AI sidebar extensions: standing read access to visited pages, content routed to multiple model backends under personal accounts." }, + { "name": "Merlin", "vendor": "Merlin", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["merlin"], "appIds": [], "description": "Browser extension providing one-click AI access across models on any website.", "riskReason": "Page-content access across all browsing plus multi-provider routing under consumer accounts; high exposure surface with weak governance." }, + { "name": "MaxAI", "vendor": "MaxAI", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["maxai"], "appIds": [], "description": "AI browser extension for summarizing, rewriting and chatting with page content.", "riskReason": "Reads and processes page content across authenticated business webapps, sending it to cloud models under a personal subscription." }, + { "name": "Hugging Face", "vendor": "Hugging Face", "category": "AI Platform & API", "risk": "Low", "matchNames": ["hugging face","huggingface"], "appIds": [], "description": "Platform hosting open models, datasets and AI apps (Spaces).", "riskReason": "Primarily a development resource with reasonable terms; main exposures are uploading company data to public Spaces or datasets, and unvetted model downloads." }, + { "name": "Replicate", "vendor": "Replicate", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["replicate"], "appIds": [], "description": "API platform for running open-source models in the cloud.", "riskReason": "Developer usage on personal API keys sends inference data to third-party-hosted community models with variable provenance and terms." }, + { "name": "OpenRouter", "vendor": "OpenRouter", "category": "AI Platform & API", "risk": "High", "matchNames": ["openrouter"], "appIds": [], "description": "Aggregator API routing requests to many model providers through one key.", "riskReason": "One key fans prompts out to dozens of providers - including free routes that explicitly log and train on inputs - so data terms are effectively whichever backend was cheapest that day." }, + { "name": "Together AI", "vendor": "Together AI", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["together ai","together.ai"], "appIds": [], "description": "Cloud platform for running and fine-tuning open-source models.", "riskReason": "Developer platform with acceptable commercial terms; individual API keys moving company data through personal accounts is the primary concern." }, + { "name": "Groq", "vendor": "Groq", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["groq"], "appIds": [], "description": "Ultra-fast inference API for open models on custom hardware.", "riskReason": "Free-tier speed attracts personal-key experimentation; prompts flow under individual accounts outside organizational data terms." }, + { "name": "Fireworks AI", "vendor": "Fireworks AI", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["fireworks ai"], "appIds": [], "description": "Inference platform for open and fine-tuned production models.", "riskReason": "Reasonable enterprise posture; risk is standard personal-API-key usage outside procurement and data agreements." }, + { "name": "Cohere", "vendor": "Cohere", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["cohere"], "appIds": [], "description": "Enterprise-focused LLM provider for search, RAG and generation.", "riskReason": "Enterprise-oriented vendor with solid data terms; detected personal-key usage still bypasses organizational agreements." }, + { "name": "AI21 Labs", "vendor": "AI21 Labs", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["ai21"], "appIds": [], "description": "LLM provider (Jamba, Jurassic) with APIs and writing products.", "riskReason": "Established vendor with business terms; individual API usage under personal accounts is the main gap." }, + { "name": "Databricks", "vendor": "Databricks", "category": "AI Platform & API", "risk": "Informational", "matchNames": ["databricks"], "appIds": [], "description": "Data and AI platform; AI features ride on the governed lakehouse deployment.", "riskReason": "An enterprise data platform procured and governed centrally; listed for visibility of its AI capabilities rather than as a shadow AI concern." }, + { "name": "Weights & Biases", "vendor": "Weights & Biases", "category": "AI Platform & API", "risk": "Low", "matchNames": ["weights & biases","wandb"], "appIds": [], "description": "ML experiment tracking and model registry platform.", "riskReason": "Developer tooling with enterprise terms; the main caution is experiment logs and datasets uploaded to personal free accounts." }, + { "name": "Gong", "vendor": "Gong", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["gong"], "appIds": [], "description": "Revenue intelligence platform recording and analyzing sales calls.", "riskReason": "Records customer calls at scale; as a sanctioned deployment it is governed, but any individual or trial usage records conversations without proper consent chains." }, + { "name": "Apollo.io", "vendor": "Apollo", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["apollo.io"], "appIds": [], "description": "Sales intelligence and engagement platform with AI outreach.", "riskReason": "Individual seats sync contacts and mailboxes for outreach; CRM-grade customer data accumulates under personal accounts, plus contributory data-sharing terms." }, + { "name": "Regie.ai", "vendor": "Regie.ai", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["regie"], "appIds": [], "description": "AI sales prospecting and sequence generation platform.", "riskReason": "Prospect data and outreach content processed in vendor cloud under sales-team or individual accounts; moderate, procurement-level exposure." }, + { "name": "Intercom Fin", "vendor": "Intercom", "category": "AI Business Apps", "risk": "Low", "matchNames": ["intercom"], "appIds": [], "description": "Intercom's AI customer support agent answering on company knowledge.", "riskReason": "Deployed as part of a governed Intercom contract with enterprise data terms; a procurement item rather than typical shadow adoption." }, + { "name": "Salesforce Einstein", "vendor": "Salesforce", "category": "AI Business Apps", "risk": "Informational", "matchNames": ["einstein"], "appIds": [], "description": "AI layer inside Salesforce (Einstein/Agentforce); part of the governed CRM.", "riskReason": "Runs inside the organization's existing Salesforce trust boundary with contractual controls; listed for visibility, not as shadow AI." }, + { "name": "ServiceNow Now Assist", "vendor": "ServiceNow", "category": "AI Business Apps", "risk": "Informational", "matchNames": ["now assist"], "appIds": [], "description": "AI capabilities embedded in the ServiceNow platform.", "riskReason": "Part of a governed enterprise ITSM deployment with platform-level data terms; informational visibility only." }, + { "name": "Atlassian Rovo", "vendor": "Atlassian", "category": "AI Business Apps", "risk": "Informational", "matchNames": ["rovo"], "appIds": [], "description": "Atlassian's AI search and agents across Jira and Confluence.", "riskReason": "Ships inside the existing Atlassian cloud agreement and permission model; a licensing and configuration question rather than shadow AI." }, + { "name": "Moveworks", "vendor": "Moveworks", "category": "AI Business Apps", "risk": "Low", "matchNames": ["moveworks"], "appIds": [], "description": "Enterprise AI assistant automating IT and HR support.", "riskReason": "Sold and deployed as a governed enterprise product with admin controls and contractual data terms; low residual risk when centrally managed." }, + { "name": "HireVue", "vendor": "HireVue", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["hirevue"], "appIds": [], "description": "AI-assisted video interviewing and candidate assessment platform.", "riskReason": "Processes candidate PII and interview recordings with algorithmic assessment - heavily regulated territory (GDPR, EEOC) that demands a governed deployment, not team-level adoption." }, + { "name": "Eightfold", "vendor": "Eightfold AI", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["eightfold"], "appIds": [], "description": "AI talent intelligence platform for recruiting and workforce planning.", "riskReason": "Employee and candidate career data processed with AI matching; regulated HR data means contractual deployment is essential." }, + { "name": "Harvey", "vendor": "Harvey", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["harvey.ai","harvey ai"], "appIds": [], "description": "AI legal assistant for research, review and drafting.", "riskReason": "Privileged legal documents and matters are processed in the vendor cloud; strong enterprise posture, but privilege and confidentiality demand firm-level agreements, not individual accounts." }, + { "name": "Spellbook", "vendor": "Spellbook", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["spellbook"], "appIds": [], "description": "AI contract drafting and review add-in for Word.", "riskReason": "Contracts under negotiation - among the most sensitive documents an organization holds - are processed by the vendor; needs contractual deployment rather than personal trials." }, + { "name": "DataRobot", "vendor": "DataRobot", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["datarobot"], "appIds": [], "description": "Automated machine learning platform for building predictive models.", "riskReason": "Training datasets uploaded to the platform often contain customer records; enterprise-grade vendor, but data-science teams adopting it without review move regulated data." }, + { "name": "H2O.ai", "vendor": "H2O.ai", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["h2o.ai","h2o ai"], "appIds": [], "description": "Open-source and enterprise AutoML platform.", "riskReason": "Similar to other ML platforms: dataset uploads to cloud services under team accounts need data-governance review; local open-source use is lower risk." }, + { "name": "Julius AI", "vendor": "Julius", "category": "AI Business Apps", "risk": "High", "matchNames": ["julius ai"], "appIds": [], "description": "AI data analyst that answers questions about uploaded spreadsheets and datasets.", "riskReason": "Users upload raw business data - sales figures, customer lists, financials - to a consumer service for analysis; wholesale dataset exfiltration under personal accounts." }, + { "name": "Vapi", "vendor": "Vapi", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["vapi"], "appIds": [], "description": "Developer platform for building AI voice agents for calls.", "riskReason": "Voice agents handle live customer calls and their recordings via developer accounts; consent, recording law and call-content retention need review before production use." }, + { "name": "Retell AI", "vendor": "Retell", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["retell ai"], "appIds": [], "description": "Platform for building AI phone agents.", "riskReason": "Same profile as other voice-agent platforms: live call audio and transcripts processed under developer accounts, with telephony consent obligations." }, + { "name": "Voiceflow", "vendor": "Voiceflow", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["voiceflow"], "appIds": [], "description": "Design and orchestration platform for chat and voice AI agents.", "riskReason": "Conversation flows and connected knowledge bases sit in the vendor cloud under team accounts; moderate exposure typical of agent-building SaaS." }, + { "name": "Botpress", "vendor": "Botpress", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["botpress"], "appIds": [], "description": "Platform for building and deploying chatbots with LLM integration.", "riskReason": "Bot knowledge bases and conversation logs in vendor or self-hosted deployments; risk depends on hosting choice and what data the bots are fed." }, + { "name": "Chatbase", "vendor": "Chatbase", "category": "AI Business Apps", "risk": "High", "matchNames": ["chatbase"], "appIds": [], "description": "Service for building custom AI chatbots trained on uploaded company content.", "riskReason": "The setup flow is uploading internal documents and site content to train a hosted bot - company knowledge wholesale into a small vendor's cloud, then exposed through a public chat widget." }, + { "name": "StackAI", "vendor": "StackAI", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["stackai","stack ai"], "appIds": [], "description": "No-code platform for building internal AI workflows and agents.", "riskReason": "Internal documents and workflow data connected to a third-party orchestration cloud under team accounts; needs vendor review before feeding it business systems." } ] diff --git a/Config/openapi.json b/Config/openapi.json index 7fbc0152a540a..0456733b43b00 100644 --- a/Config/openapi.json +++ b/Config/openapi.json @@ -23157,6 +23157,12 @@ }, "targetUrl": { "type": "string" + }, + "appId": { + "type": "string" + }, + "appSecret": { + "type": "string" } } } @@ -42056,4 +42062,4 @@ } } } -} \ No newline at end of file +} diff --git a/Config/standards.json b/Config/standards.json index 6552e8000429a..5a8d0a83eba9f 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -1587,8 +1587,8 @@ "ZTNA21807", "ZTNA21810" ], - "helpText": "Disables users from being able to consent to applications, except for those specified in the field below", - "docsDescription": "Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications.", + "helpText": "Disables users from being able to consent to applications, except for those specified in the field below. This standard conflicts with the \"Allow users to consent to applications with low security risk\" standard; only one of the two should be assigned per tenant.", + "docsDescription": "Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications. This standard conflicts with the \"Allow users to consent to applications with low security risk\" (OauthConsentLowSec) standard. Enabling both on the same tenant causes a remediation conflict, so only assign one.", "executiveText": "Requires administrative approval before employees can grant applications access to company data, preventing unauthorized data sharing and potential security breaches. This protects against malicious applications while allowing approved business tools to function normally.", "addedComponent": [ { @@ -1609,8 +1609,8 @@ "name": "standards.OauthConsentLowSec", "cat": "Entra (AAD) Standards", "tag": ["IntegratedApps"], - "helpText": "Sets the default oauth consent level so users can consent to applications that have low risks.", - "docsDescription": "Allows users to consent to applications with low assigned risk.", + "helpText": "Sets the default oauth consent level so users can consent to applications that have low risks. This standard conflicts with the \"Require admin consent for applications\" standard; only one of the two should be assigned per tenant.", + "docsDescription": "Allows users to consent to applications with low assigned risk. This standard conflicts with the \"Require admin consent for applications (Prevent OAuth phishing)\" (OauthConsent) standard. Enabling both on the same tenant causes a remediation conflict, so only assign one.", "executiveText": "Allows employees to approve low-risk applications without administrative intervention, balancing security with productivity. This provides a middle ground between complete restriction and open access, enabling business agility while maintaining protection against high-risk applications.", "label": "Allow users to consent to applications with low security risk (Prevent OAuth phishing. Lower impact, less secure)", "impact": "Medium Impact", @@ -1656,27 +1656,38 @@ "name": "standards.StaleEntraDevices", "cat": "Entra (AAD) Standards", "tag": ["Essential 8 (1501)", "NIST CSF 2.0 (ID.AM-08)", "NIST CSF 2.0 (PR.PS-03)"], - "helpText": "**Remediate is currently not available**. Cleans up Entra devices that have not connected/signed in for the specified number of days.", - "docsDescription": "Remediate is currently not available. Cleans up Entra devices that have not connected/signed in for the specified number of days. First disables and later deletes the devices. More info can be found in the [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices)", + "helpText": "Cleans up Entra devices that have not connected/signed in for the specified number of days. Remediation first disables stale enabled devices and, on a later run, deletes stale devices that are already disabled. Hybrid-joined, Intune-managed and Autopilot devices are skipped. Deleting a device permanently removes any BitLocker recovery keys stored on it.", + "docsDescription": "Cleans up Entra devices that have not connected/signed in for the specified number of days. Remediation first disables stale enabled devices once they pass the disable threshold, and later deletes devices that are already disabled once they have been inactive for the disable threshold plus the configured grace delta (deletion age = disable threshold + grace days). The disable-before-delete grace period is further guaranteed by never deleting a device in the same pass it was disabled. Hybrid-joined (on-premises synced), Intune-managed/compliant, and system-managed Autopilot devices are excluded, in line with the [Microsoft guidance](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices). **Warning:** deleting a device permanently removes any BitLocker recovery keys stored on that device object.", "executiveText": "Automatically identifies and removes inactive devices that haven't connected to company systems for a specified period, reducing security risks from abandoned or lost devices. This maintains a clean device inventory and prevents potential unauthorized access through dormant device registrations.", "addedComponent": [ { "type": "number", "name": "standards.StaleEntraDevices.deviceAgeThreshold", - "label": "Days before stale(Do not set below 30)", + "required": true, + "defaultValue": 90, + "label": "Days before stale (disables the device after this many days of inactivity, minimum 30)", "validators": { "min": { "value": 30, "message": "Minimum value is 30" } } + }, + { + "type": "number", + "name": "standards.StaleEntraDevices.deviceDeleteThreshold", + "defaultValue": 0, + "label": "Grace days after disable before deletion (0 = never delete). Devices are deleted once inactive for the disable threshold plus this many additional days.", + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" } + } } ], - "disabledFeatures": { "report": false, "warn": false, "remediate": true }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "label": "Cleanup stale Entra devices", "impact": "High Impact", "impactColour": "danger", "addedDate": "2025-01-19", "powershellEquivalent": "Remove-MgDevice, Update-MgDevice or Graph API", "recommendedBy": [], - "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] + "requiredCapabilities": [] }, { "name": "standards.UndoOauth", diff --git a/Modules/AzBobbyTables/3.5.1/AzBobbyTables.PS.dll b/Modules/AzBobbyTables/3.5.1/AzBobbyTables.PS.dll deleted file mode 100644 index cd004ea9a49b2..0000000000000 Binary files a/Modules/AzBobbyTables/3.5.1/AzBobbyTables.PS.dll and /dev/null differ diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/AzBobbyTables.Core.dll b/Modules/AzBobbyTables/3.5.1/dependencies/AzBobbyTables.Core.dll deleted file mode 100644 index bee6b1a4afd6c..0000000000000 Binary files a/Modules/AzBobbyTables/3.5.1/dependencies/AzBobbyTables.Core.dll and /dev/null differ diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Threading.dll b/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Threading.dll deleted file mode 100644 index eb30047dc7144..0000000000000 Binary files a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Threading.dll and /dev/null differ diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Validation.dll b/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Validation.dll deleted file mode 100644 index 8c4956f420e13..0000000000000 Binary files a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Validation.dll and /dev/null differ diff --git a/Modules/AzBobbyTables/3.6.0/AzBobbyTables.PS.dll b/Modules/AzBobbyTables/3.6.0/AzBobbyTables.PS.dll new file mode 100644 index 0000000000000..0886fa7c42d74 Binary files /dev/null and b/Modules/AzBobbyTables/3.6.0/AzBobbyTables.PS.dll differ diff --git a/Modules/AzBobbyTables/3.5.1/AzBobbyTables.psd1 b/Modules/AzBobbyTables/3.6.0/AzBobbyTables.psd1 similarity index 84% rename from Modules/AzBobbyTables/3.5.1/AzBobbyTables.psd1 rename to Modules/AzBobbyTables/3.6.0/AzBobbyTables.psd1 index b35aa5f9e1491..b07587f76bff4 100644 --- a/Modules/AzBobbyTables/3.5.1/AzBobbyTables.psd1 +++ b/Modules/AzBobbyTables/3.6.0/AzBobbyTables.psd1 @@ -4,7 +4,7 @@ RootModule = 'AzBobbyTables.PS.dll' # Version number of this module. -ModuleVersion = '3.5.1' +ModuleVersion = '3.6.0' # Supported PSEditions CompatiblePSEditions = @('Core') @@ -110,11 +110,16 @@ PrivateData = @{ # IconUri = '' # ReleaseNotes of this module - ReleaseNotes = '## [3.5.1] - 2026-04-22 + ReleaseNotes = '## [3.6.0] - 2026-07-01 + +### Added + +- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122)) +- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service''s Retry-After hint between attempts. Defaults to `0` (no retries). ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122)) ### Changed -- Share a single HttpClient across all TableClient/TableServiceClient instances via HttpClientTransport, enabling TCP connection pooling and reducing socket churn in high-concurrency scenarios +Bumped Microsoft.VisualStudio.Threading from 17.14.15 to 18.7.23 (#132) ' diff --git a/Modules/AzBobbyTables/3.5.1/CHANGELOG.md b/Modules/AzBobbyTables/3.6.0/CHANGELOG.md similarity index 75% rename from Modules/AzBobbyTables/3.5.1/CHANGELOG.md rename to Modules/AzBobbyTables/3.6.0/CHANGELOG.md index 4910bdc0cbfff..1871f2ceeb3b9 100644 --- a/Modules/AzBobbyTables/3.5.1/CHANGELOG.md +++ b/Modules/AzBobbyTables/3.6.0/CHANGELOG.md @@ -4,9 +4,21 @@ The format is based on and uses the types of changes according to [Keep a Change ## [Unreleased] +### Added + +- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122)) +- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service's Retry-After hint between attempts. Defaults to `0` (no retries). ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122)) + +### Changed + +Bumped Microsoft.VisualStudio.Threading from 17.14.15 to 18.7.23 (#132) + +## [3.5.0] - 2026-04-20 + ### Changed -- Share a single HttpClient across all TableClient/TableServiceClient instances via HttpClientTransport, enabling TCP connection pooling and reducing socket churn in high-concurrency scenarios +- Now shares a single HttpClient across all TableClient/TableServiceClient instances via HttpClientTransport, enabling TCP connection pooling and reducing socket churn in high-concurrency scenarios [#122](https://github.com/PalmEmanuel/AzBobbyTables/pull/122) +- Bump System.Linq.Async from 7.0.0 to 7.0.1 ## [3.4.2] - 2026-03-30 @@ -92,7 +104,8 @@ The format is based on and uses the types of changes according to [Keep a Change ## 3.1.1 - 2023-05-03 -[unreleased]: https://github.com/PalmEmanuel/AzBobbyTables/compare/v3.4.2...HEAD +[unreleased]: https://github.com/PalmEmanuel/AzBobbyTables/compare/v3.5.0...HEAD +[3.5.0]: https://github.com/PalmEmanuel/AzBobbyTables/compare/v3.4.2...v3.5.0 [3.4.2]: https://github.com/PalmEmanuel/AzBobbyTables/compare/v3.4.1...v3.4.2 [3.4.1]: https://github.com/PalmEmanuel/AzBobbyTables/compare/v3.4.0...v3.4.1 [3.4.0]: https://github.com/PalmEmanuel/AzBobbyTables/compare/v3.3.2...v3.4.0 diff --git a/Modules/AzBobbyTables/3.5.1/LICENSE b/Modules/AzBobbyTables/3.6.0/LICENSE similarity index 100% rename from Modules/AzBobbyTables/3.5.1/LICENSE rename to Modules/AzBobbyTables/3.6.0/LICENSE diff --git a/Modules/AzBobbyTables/3.6.0/PSGetModuleInfo.xml b/Modules/AzBobbyTables/3.6.0/PSGetModuleInfo.xml new file mode 100644 index 0000000000000..3ba1a2251829c --- /dev/null +++ b/Modules/AzBobbyTables/3.6.0/PSGetModuleInfo.xml @@ -0,0 +1,159 @@ + + + + Microsoft.PowerShell.Commands.PSRepositoryItemInfo + System.Management.Automation.PSCustomObject + System.Object + + + AzBobbyTables + 3.6.0 + Module + A module for handling Azure Table Storage operations by wrapping the Azure Data Tables SDK. + Emanuel Palm + PalmEmanuel + (c) Emanuel Palm. All rights reserved. +
2026-07-01T12:50:12+08:00
+ +
2026-07-01T21:42:52.9978294+08:00
+ + + + Microsoft.PowerShell.Commands.DisplayHintType + System.Enum + System.ValueType + System.Object + + DateTime + 2 + + +
+ + https://github.com/PalmEmanuel/AzBobbyTables/blob/main/LICENSE + https://github.com/PalmEmanuel/AzBobbyTables + + + + System.Object[] + System.Array + System.Object + + + azure + storage + table + cosmos + cosmosdb + data + PSModule + PSEdition_Core + + + + + System.Collections.Hashtable + System.Object + + + + Function + + + + + + + Command + + + + Add-AzDataTableEntity + Clear-AzDataTable + Get-AzDataTable + Get-AzDataTableEntity + Get-AzDataTableSupportedEntityType + Remove-AzDataTableEntity + Update-AzDataTableEntity + New-AzDataTableContext + Remove-AzDataTable + New-AzDataTable + + + + + Cmdlet + + + + Add-AzDataTableEntity + Clear-AzDataTable + Get-AzDataTable + Get-AzDataTableEntity + Get-AzDataTableSupportedEntityType + Remove-AzDataTableEntity + Update-AzDataTableEntity + New-AzDataTableContext + Remove-AzDataTable + New-AzDataTable + + + + + DscResource + + + + RoleCapability + + + + Workflow + + + + + + ## [3.6.0] - 2026-07-01_x000A__x000A_### Added_x000A__x000A_- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122))_x000A_- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service's Retry-After hint between attempts. Defaults to `0` (no retries). ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122))_x000A__x000A_### Changed_x000A__x000A_Bumped Microsoft.VisualStudio.Threading from 17.14.15 to 18.7.23 (#132) + + + + + https://www.powershellgallery.com/api/v2 + PSGallery + NuGet + + + System.Management.Automation.PSCustomObject + System.Object + + + (c) Emanuel Palm. All rights reserved. + A module for handling Azure Table Storage operations by wrapping the Azure Data Tables SDK. + False + ## [3.6.0] - 2026-07-01_x000A__x000A_### Added_x000A__x000A_- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122))_x000A_- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service's Retry-After hint between attempts. Defaults to `0` (no retries). ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122))_x000A__x000A_### Changed_x000A__x000A_Bumped Microsoft.VisualStudio.Threading from 17.14.15 to 18.7.23 (#132) + True + True + 3 + 93772 + 1969607 + 1/07/2026 12:50:12 PM +08:00 + 1/07/2026 12:50:12 PM +08:00 + 1/07/2026 1:42:19 PM +08:00 + azure storage table cosmos cosmosdb data PSModule PSEdition_Core PSCmdlet_Add-AzDataTableEntity PSCommand_Add-AzDataTableEntity PSCmdlet_Clear-AzDataTable PSCommand_Clear-AzDataTable PSCmdlet_Get-AzDataTable PSCommand_Get-AzDataTable PSCmdlet_Get-AzDataTableEntity PSCommand_Get-AzDataTableEntity PSCmdlet_Get-AzDataTableSupportedEntityType PSCommand_Get-AzDataTableSupportedEntityType PSCmdlet_Remove-AzDataTableEntity PSCommand_Remove-AzDataTableEntity PSCmdlet_Update-AzDataTableEntity PSCommand_Update-AzDataTableEntity PSCmdlet_New-AzDataTableContext PSCommand_New-AzDataTableContext PSCmdlet_Remove-AzDataTable PSCommand_Remove-AzDataTable PSCmdlet_New-AzDataTable PSCommand_New-AzDataTable PSIncludes_Cmdlet + False + 2026-07-01T13:42:19Z + 3.6.0 + Emanuel Palm + false + Module + AzBobbyTables.nuspec|dependencies\System.Memory.Data.dll|dependencies\System.ClientModel.dll|dependencies\System.Interactive.Async.dll|LICENSE|dependencies\AzBobbyTables.Core.dll|dependencies\System.Text.Encodings.Web.dll|dependencies\Microsoft.Bcl.AsyncInterfaces.dll|AzBobbyTables.psd1|dependencies\System.Linq.AsyncEnumerable.dll|dependencies\Microsoft.Win32.Registry.dll|dependencies\System.Numerics.Vectors.dll|CHANGELOG.md|dependencies\System.Linq.Async.dll|dependencies\System.Security.Principal.Windows.dll|dependencies\System.Buffers.dll|AzBobbyTables.PS.dll|dependencies\System.Threading.Tasks.Extensions.dll|dependencies\System.Security.AccessControl.dll|dependencies\Azure.Core.dll|en-US\AzBobbyTables.PS.dll-Help.xml|dependencies\System.Memory.dll|dependencies\System.Diagnostics.DiagnosticSource.dll|dependencies\Microsoft.VisualStudio.Validation.dll|dependencies\Microsoft.Bcl.Memory.dll|dependencies\Microsoft.VisualStudio.Threading.dll|dependencies\Azure.Data.Tables.dll|dependencies\System.Runtime.CompilerServices.Unsafe.dll|dependencies\System.Text.Json.dll + eead4f42-5080-4f83-8901-340c529a5a11 + 7.0 + pipe.how + + + C:\Users\Zac\Documents\PowerShell\Modules\AzBobbyTables\3.6.0 +
+
+
diff --git a/Modules/AzBobbyTables/3.6.0/dependencies/AzBobbyTables.Core.dll b/Modules/AzBobbyTables/3.6.0/dependencies/AzBobbyTables.Core.dll new file mode 100644 index 0000000000000..4cb326f0b68db Binary files /dev/null and b/Modules/AzBobbyTables/3.6.0/dependencies/AzBobbyTables.Core.dll differ diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Azure.Core.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Azure.Core.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/Azure.Core.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/Azure.Core.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Azure.Data.Tables.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Azure.Data.Tables.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/Azure.Data.Tables.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/Azure.Data.Tables.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Bcl.AsyncInterfaces.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Bcl.AsyncInterfaces.dll similarity index 64% rename from Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Bcl.AsyncInterfaces.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Bcl.AsyncInterfaces.dll index e93a3813812e2..2867daaf7d5fe 100644 Binary files a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Bcl.AsyncInterfaces.dll and b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Bcl.AsyncInterfaces.dll differ diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Bcl.Memory.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Bcl.Memory.dll similarity index 83% rename from Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Bcl.Memory.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Bcl.Memory.dll index 98594d3ab74ff..8b5a97d8c70eb 100644 Binary files a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Bcl.Memory.dll and b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Bcl.Memory.dll differ diff --git a/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Threading.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Threading.dll new file mode 100644 index 0000000000000..052dcc98c342a Binary files /dev/null and b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Threading.dll differ diff --git a/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Validation.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Validation.dll new file mode 100644 index 0000000000000..40afae8203d65 Binary files /dev/null and b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Validation.dll differ diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Win32.Registry.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Win32.Registry.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Win32.Registry.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Win32.Registry.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Buffers.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Buffers.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Buffers.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Buffers.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.ClientModel.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.ClientModel.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.ClientModel.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.ClientModel.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Diagnostics.DiagnosticSource.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Diagnostics.DiagnosticSource.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Diagnostics.DiagnosticSource.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Diagnostics.DiagnosticSource.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Interactive.Async.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Interactive.Async.dll similarity index 85% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Interactive.Async.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Interactive.Async.dll index 7fc37da76ebac..0b7806c9d072f 100644 Binary files a/Modules/AzBobbyTables/3.5.1/dependencies/System.Interactive.Async.dll and b/Modules/AzBobbyTables/3.6.0/dependencies/System.Interactive.Async.dll differ diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.Async.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Linq.Async.dll similarity index 90% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.Async.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Linq.Async.dll index 35906b95ebbb0..cfd93e39366fd 100644 Binary files a/Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.Async.dll and b/Modules/AzBobbyTables/3.6.0/dependencies/System.Linq.Async.dll differ diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.AsyncEnumerable.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Linq.AsyncEnumerable.dll similarity index 93% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.AsyncEnumerable.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Linq.AsyncEnumerable.dll index 2068b65671db5..88eb4bd5aa174 100644 Binary files a/Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.AsyncEnumerable.dll and b/Modules/AzBobbyTables/3.6.0/dependencies/System.Linq.AsyncEnumerable.dll differ diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Memory.Data.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Memory.Data.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Memory.Data.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Memory.Data.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Memory.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Memory.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Memory.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Memory.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Numerics.Vectors.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Numerics.Vectors.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Numerics.Vectors.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Numerics.Vectors.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Runtime.CompilerServices.Unsafe.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Runtime.CompilerServices.Unsafe.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Runtime.CompilerServices.Unsafe.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Runtime.CompilerServices.Unsafe.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Security.AccessControl.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Security.AccessControl.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Security.AccessControl.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Security.AccessControl.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Security.Principal.Windows.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Security.Principal.Windows.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Security.Principal.Windows.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Security.Principal.Windows.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Text.Encodings.Web.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Text.Encodings.Web.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Text.Encodings.Web.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Text.Encodings.Web.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Text.Json.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Text.Json.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Text.Json.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Text.Json.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Threading.Tasks.Extensions.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Threading.Tasks.Extensions.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Threading.Tasks.Extensions.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Threading.Tasks.Extensions.dll diff --git a/Modules/AzBobbyTables/3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml b/Modules/AzBobbyTables/3.6.0/en-US/AzBobbyTables.PS.dll-Help.xml similarity index 84% rename from Modules/AzBobbyTables/3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml rename to Modules/AzBobbyTables/3.6.0/en-US/AzBobbyTables.PS.dll-Help.xml index 162d20d700f77..66a2240908b39 100644 --- a/Modules/AzBobbyTables/3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml +++ b/Modules/AzBobbyTables/3.6.0/en-US/AzBobbyTables.PS.dll-Help.xml @@ -61,6 +61,18 @@ False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + Add-AzDataTableEntity @@ -99,6 +111,18 @@ None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + OperationType @@ -168,6 +192,18 @@ False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + OperationType @@ -271,6 +307,18 @@ PS C:\> Add-AzDataTableEntity -Entity $Users -Context $Context -OperationType None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -286,6 +334,18 @@ PS C:\> Add-AzDataTableEntity -Entity $Users -Context $Context -OperationType None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -374,6 +434,18 @@ PS C:\> Clear-AzDataTable $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -402,6 +474,18 @@ PS C:\> Clear-AzDataTable $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -485,6 +569,18 @@ PS C:\> Clear-AzDataTable $Context False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + Get-AzDataTableEntity @@ -524,6 +620,18 @@ PS C:\> Clear-AzDataTable $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + Property @@ -612,6 +720,18 @@ PS C:\> Clear-AzDataTable $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + Property @@ -792,6 +912,18 @@ PS C:\> $UserEntities = Get-AzDataTableEntity -Property 'FirstName','Age' -Co None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -807,6 +939,18 @@ PS C:\> $UserEntities = Get-AzDataTableEntity -Property 'FirstName','Age' -Co None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -886,7 +1030,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -937,7 +1081,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -964,7 +1108,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -1003,7 +1147,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -1054,7 +1198,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -1141,7 +1285,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -1302,6 +1446,18 @@ PS C:\> New-AzDataTable -Context $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -1317,6 +1473,18 @@ PS C:\> New-AzDataTable -Context $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -1405,6 +1573,18 @@ PS C:\> Remove-AzDataTable -Context $Context False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -1444,6 +1624,18 @@ PS C:\> Remove-AzDataTable -Context $Context False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -1566,6 +1758,18 @@ PS C:\> # OK - The -Force switch overrides ETag validation False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + OperationType @@ -1622,6 +1826,18 @@ PS C:\> # OK - The -Force switch overrides ETag validation False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + OperationType diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 index f4cc20ab450b1..94ada9d6ef442 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 @@ -20,34 +20,39 @@ function Push-BECRun { $startDate = (Get-Date).AddDays(-7).ToUniversalTime() $endDate = (Get-Date) Write-Information 'Getting audit logs' - $auditLog = (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-AdminAuditLogConfig').UnifiedAuditLogIngestionEnabled - $7dayslog = if ($auditLog -eq $false) { - $ExtractResult = 'AuditLog is disabled. Cannot perform full analysis' - } else { - $sessionid = Get-Random -Minimum 10000 -Maximum 99999 - $operations = @( - 'Remove-MailboxPermission', - 'Add-MailboxPermission', - 'UpdateCalendarDelegation', - 'AddFolderPermissions', - 'MailboxLogin', - 'UserLoggedIn' - ) - $startDate = (Get-Date).AddDays(-7) - $endDate = (Get-Date) - $SearchParam = @{ - SessionCommand = 'ReturnLargeSet' - Operations = $operations - sessionid = $sessionid - startDate = $startDate - endDate = $endDate + try { + $auditLog = (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-AdminAuditLogConfig').UnifiedAuditLogIngestionEnabled + $7DaysLog = if ($auditLog -eq $false) { + $ExtractResult = 'AuditLog is disabled. Cannot perform full analysis' + } else { + $sessionid = Get-Random -Minimum 10000 -Maximum 99999 + $operations = @( + 'Remove-MailboxPermission', + 'Add-MailboxPermission', + 'UpdateCalendarDelegation', + 'AddFolderPermissions' + ) + $startDate = (Get-Date).AddDays(-7) + $endDate = (Get-Date) + $SearchParam = @{ + SessionCommand = 'ReturnLargeSet' + Operations = $operations + sessionid = $sessionid + startDate = $startDate + endDate = $endDate + } + do { + $logsTenant = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-unifiedAuditLog' -cmdParams $SearchParam -Anchor $Username + Write-Information "Retrieved $($logsTenant.count) logs" + $logsTenant + } while ($LogsTenant.count % 5000 -eq 0 -and $LogsTenant.count -ne 0) + $ExtractResult = 'Successfully extracted logs from auditlog' } - do { - New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-unifiedAuditLog' -cmdParams $SearchParam -Anchor $Username - Write-Information "Retrieved $($logsTenant.count) logs" - $logsTenant - } while ($LogsTenant.count % 5000 -eq 0 -and $LogsTenant.count -ne 0) - $ExtractResult = 'Successfully extracted logs from auditlog' + } catch { + $7DaysLog = @() + $CippAuditError = Get-CippException -Exception $_ + $ExtractResult = "Could not retrieve audit logs: $($CippAuditError.NormalizedError)" + Write-LogMessage -API 'BECRun' -message "Failed to retrieve audit logs for $($UserName): $($CippAuditError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippAuditError } Write-Information 'Getting last sign-in' try { @@ -76,7 +81,7 @@ function Push-BECRun { } try { - $PermissionsLog = ($7dayslog | Where-Object -Property Operations -In 'Remove-MailboxPermission', 'Add-MailboxPermission', 'UpdateCalendarDelegation', 'AddFolderPermissions' ).AuditData | ConvertFrom-Json -ErrorAction Stop | ForEach-Object { + $PermissionsLog = ($7DaysLog | Where-Object -Property Operations -In 'Remove-MailboxPermission', 'Add-MailboxPermission', 'UpdateCalendarDelegation', 'AddFolderPermissions' ).AuditData | ConvertFrom-Json -ErrorAction Stop | ForEach-Object { $perms = if ($_.Parameters) { $_.Parameters | ForEach-Object { if ($_.Name -eq 'AccessRights') { $_.Value } } } else @@ -93,16 +98,66 @@ function Push-BECRun { $PermissionsLog = @() } + Write-Information 'Getting inbox rule changes' + try { + $RuleChangesLog = if ($auditLog -eq $false) { @() } else { + # ponytail: separate user-scoped search - UpdateInboxRules is too high-volume for the tenant-wide query above + $RuleSearchParam = @{ + SessionCommand = 'ReturnLargeSet' + Operations = @('New-InboxRule', 'Set-InboxRule', 'Remove-InboxRule', 'UpdateInboxRules') + sessionid = (Get-Random -Minimum 10000 -Maximum 99999) + startDate = $startDate + endDate = $endDate + UserIds = $UserName + } + (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-UnifiedAuditLog' -cmdParams $RuleSearchParam -Anchor $UserName).AuditData | ConvertFrom-Json -ErrorAction Stop | + Where-Object { $_.UserId -eq $UserName -or $_.MailboxOwnerUPN -eq $UserName -or $_.ObjectId -like "*$UserName*" } | ForEach-Object { + $RuleName = ($_.Parameters | Where-Object { $_.Name -eq 'Name' }).Value ?? $_.ObjectId + [pscustomobject]@{ + Operation = $_.Operation + UserKey = $_.UserId + RuleName = $RuleName + Parameters = ($_.Parameters | Where-Object { $_ -and $_.Name -notin 'Identity', 'Name' } | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join '; ' + Date = $_.CreationTime + } + } + } + } catch { + $RuleChangesLog = @() + $CippRuleError = Get-CippException -Exception $_ + Write-LogMessage -API 'BECRun' -message "Failed to retrieve inbox rule changes for $($UserName): $($CippRuleError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippRuleError + } + Write-Information 'Getting rules' try { $RulesLog = New-ExoRequest -cmdlet 'Get-InboxRule' -tenantid $TenantFilter -cmdParams @{ Mailbox = $Username; IncludeHidden = $true } -Anchor $Username | Where-Object { $_.Name -ne 'Junk E-Mail Rule' -and $_.Name -notlike 'Microsoft.Exchange.OOF.*' } } catch { - Write-Host 'Failed to get rules: ' + $_.Exception.Message + $CippRulesError = Get-CippException -Exception $_ + Write-LogMessage -API 'BECRun' -message "Failed to retrieve inbox rules for $($UserName): $($CippRulesError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippRulesError $RulesLog = @() } + # inbox rules carry no timestamps, so 'recent' = name-matches a 7-day audit event; Outlook-client changes (UpdateInboxRules) carry no rule name and stay unflagged + $RecentRuleNames = @($RuleChangesLog | Where-Object { $_.Operation -in 'New-InboxRule', 'Set-InboxRule' } | ForEach-Object { ($_.RuleName -split '\\')[-1] }) + $RulesLog = @($RulesLog | Where-Object { $_ } | Select-Object *, @{ Name = 'RecentlyChanged'; Expression = { $_.Name -in $RecentRuleNames } }) + + Write-Information 'Getting sent message trace' + try { + $MessageTraceParams = @{ + SenderAddress = $UserName + StartDate = $startDate.ToString('s') + EndDate = $endDate.ToString('s') + } + $SentMessages = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MessageTraceV2' -cmdParams $MessageTraceParams -Anchor $UserName | + Select-Object MessageTraceId, Status, Subject, RecipientAddress, @{ Name = 'Received'; Expression = { $_.Received.ToString('u') } }, FromIP) + } catch { + $SentMessages = @() + $CippTraceError = Get-CippException -Exception $_ + Write-LogMessage -API 'BECRun' -message "Failed to retrieve message trace for $($UserName): $($CippTraceError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippTraceError + } + Write-Information 'Getting last 50 logons' try { $Last50Logons = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=userDisplayName ne 'On-Premises Directory Synchronization Service Account'&`$top=50&`$orderby=createdDateTime desc" -tenantid $TenantFilter -noPagination $true | Select-Object @{ Name = 'CreatedDateTime'; Expression = { $(($_.createdDateTime | Out-String) -replace '\r\n') } }, @@ -155,6 +210,8 @@ function Push-BECRun { LastSuspectUserLogon = @($LastSignIn) SuspectUserDevices = @($Devices) NewRules = @($RulesLog) + InboxRuleChanges = @($RuleChangesLog) + SentMessages = @($SentMessages) MailboxPermissionChanges = @($PermissionsLog) NewUsers = @($NewUsers) MFADevices = @($MFADevices | Where-Object { $_.'@odata.type' -ne '#microsoft.graph.passwordAuthenticationMethod' }) @@ -175,7 +232,7 @@ function Push-BECRun { } catch { $errMessage = Get-NormalizedError -message $_.Exception.Message $CippError = Get-CippException -Exception $_ - $results = [pscustomobject]@{'Results' = "$errMessage"; Exception = $CippError } + $results = [pscustomobject]@{'Results' = "$errMessage"; Exception = $CippError; ExtractedAt = (Get-Date) } Write-LogMessage -API 'BECRun' -message "Error Running BEC for $($UserName): $errMessage" -tenant $TenantFilter -sev 'Error' -LogData $CIPPError $Entity = @{ UserId = $SuspectUser diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 index 074278d77cbb3..f0eac8d4d0020 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 @@ -65,6 +65,14 @@ function Push-CIPPDBCacheData { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Compliance license check failed: $($_.Exception.Message)" -sev Warning -LogData $ErrorMessage } + $DefenderCapable = $false + try { + $DefenderCapable = Test-CIPPStandardLicense -StandardName Compliance'DefenderLicenseCheck' -TenantFilter $TenantFilter -RequiredCapabilities @('MDE_SMB', 'WIN_DEF_ATP', 'DEFENDER_ENDPOINT_P1') -SkipLog + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Compliance license check failed: $($_.Exception.Message)" -sev Warning -LogData $ErrorMessage + } + $SharePointCapable = $false try { $SharePointCapable = Test-CIPPStandardLicense -StandardName 'SharePointLicenseCheck' -TenantFilter $TenantFilter -Preset SharePoint -SkipLog @@ -190,6 +198,18 @@ function Push-CIPPDBCacheData { } else { Write-Host "Skipping Compliance data collection for $TenantFilter - no required license" } + + if ($DefenderCapable) { + $Tasks.Add(@{ + FunctionName = 'ExecCIPPDBCache' + CollectionType = 'Defender' + TenantFilter = $TenantFilter + QueueId = $QueueId + QueueName = "DB Cache Defender - $TenantFilter" + }) + } else { + Write-Host "Skipping Defender data collection for $TenantFilter - no required license" + } if ($SharePointCapable) { $Tasks.Add(@{ @@ -199,6 +219,15 @@ function Push-CIPPDBCacheData { QueueId = $QueueId QueueName = "DB Cache SharePoint - $TenantFilter" }) + # SharePointSharingLinks runs as its own activity — it's heavy (scans every drive) and + # spawns a child orchestrator (one activity per site) that needs its own time budget. + $Tasks.Add(@{ + FunctionName = 'ExecCIPPDBCache' + Name = 'SharePointSharingLinks' + TenantFilter = $TenantFilter + QueueId = $QueueId + QueueName = "DB Cache SharePointSharingLinks - $TenantFilter" + }) } else { Write-Host "Skipping SharePoint data collection for $TenantFilter - no required license" } diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 index 8a358101773e4..b37e214051722 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 @@ -50,48 +50,17 @@ function Push-ExecScheduledCommand { # Task should be 'Pending' (queued by orchestrator) or 'Running' (retry/recovery) # We accept both to handle edge cases - # Check for rerun protection - prevent duplicate executions within the recurrence interval - # Do this BEFORE updating state to 'Running' to avoid getting stuck - if ($task.Recurrence -and $task.Recurrence -ne '0' -and !$IsMultiTenantExecution) { - # Calculate interval in seconds from recurrence string - $IntervalSeconds = switch -Regex ($task.Recurrence) { - '^(\d+)$' { [int64]$matches[1] * 86400 } # Plain number = days - '(\d+)m$' { [int64]$matches[1] * 60 } - '(\d+)h$' { [int64]$matches[1] * 3600 } - '(\d+)d$' { [int64]$matches[1] * 86400 } - default { 0 } - } - - if ($IntervalSeconds -gt 0) { - # Round down to nearest 15-minute interval (900 seconds) since that's when orchestrator runs - # This prevents rerun blocking issues due to slight timing variations - $FifteenMinutes = 900 - $AdjustedInterval = [Math]::Floor($IntervalSeconds / $FifteenMinutes) * $FifteenMinutes - - # Ensure we have at least one 15-minute interval - if ($AdjustedInterval -lt $FifteenMinutes) { - $AdjustedInterval = $FifteenMinutes - } - # Use task RowKey as API identifier for rerun cache - $RerunParams = @{ - TenantFilter = $Tenant - Type = 'ScheduledTask' - API = $task.RowKey - Interval = $AdjustedInterval - BaseTime = [int64]$task.ScheduledTime - Headers = $Headers - } - - $IsRerun = Test-CIPPRerun @RerunParams - if ($IsRerun) { - Write-Information "Scheduled task $($task.Name) for tenant $Tenant was recently executed. Skipping to prevent duplicate execution." - Remove-Variable -Name ScheduledTaskId -Scope Script -ErrorAction SilentlyContinue - return - } - } - } - - # Also check for one-time task rerun protection based on ExecutedTime + # NOTE: Recurring scheduled tasks intentionally have NO rerun-cache protection here. + # Duplicate execution is already prevented by the orchestrator's own state machine: + # - the ETag claim flips the task to 'Pending' atomically (no concurrent dispatch), + # - 'Pending'/'Running' tasks are not re-picked until they go stale (1h/4h), + # - ScheduledTime is advanced on completion so the task isn't eligible again until due. + # A separate rerun cache (Test-CIPPRerun) was a second, independent clock that drifted out + # of sync with ScheduledTime whenever a run didn't finish advancing the schedule, which both + # deadlocked tasks and blocked the orchestrator's stuck-task recovery. ScheduledTime is the + # single source of truth. + + # One-time task rerun protection based on ExecutedTime (the task's own field, not a cache) if ((!$task.Recurrence -or $task.Recurrence -eq '0') -and $task.ExecutedTime -and !$IsMultiTenantExecution) { $currentUnixTime = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds $timeSinceExecution = $currentUnixTime - [int64]$task.ExecutedTime diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 index be40e229a3307..f222f537dd589 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 @@ -17,51 +17,7 @@ function Push-IntuneReportExportSubmit { } try { - $Select = switch ($ReportName) { - 'AppInvRawData' { - @( - 'ApplicationKey', 'ApplicationName', 'ApplicationPublisher', 'ApplicationVersion', - 'DeviceId', 'DeviceName', 'OSDescription', 'OSVersion', 'Platform', - 'UserId', 'UserName', 'EmailAddress' - ) - } - 'AppInstallStatusAggregate' { - @( - 'ApplicationId', 'DisplayName', 'Publisher', 'Platform', 'AppVersion', 'AppPlatform', - 'InstalledDeviceCount', 'FailedDeviceCount', 'FailedUserCount', - 'PendingInstallDeviceCount', 'NotInstalledDeviceCount', 'FailedDevicePercentage' - ) - } - default { throw "Unknown Intune report '$ReportName'" } - } - - $Body = @{ - reportName = $ReportName - format = 'json' - localizationType = 'replaceLocalizableValues' - select = $Select - } | ConvertTo-Json -Depth 5 - - $Job = New-GraphPOSTRequest ` - -uri 'https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs' ` - -tenantid $TenantFilter ` - -body $Body - - if (-not $Job.id) { throw "Intune returned no job id for $ReportName" } - - $JobsTable = Get-CIPPTable -tablename 'IntuneReportJobs' - $Existing = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" - if ($Existing) { - Remove-AzDataTableEntity @JobsTable -Entity $Existing -Force -ErrorAction SilentlyContinue - } - - Add-CIPPAzDataTableEntity @JobsTable -Entity @{ - PartitionKey = $TenantFilter - RowKey = $ReportName - JobId = $Job.id - ReportName = $ReportName - SubmittedAt = ([DateTime]::UtcNow).ToString('o') - } -Force + $Job = New-CIPPIntuneReportExportJob -TenantFilter $TenantFilter -ReportName $ReportName Write-LogMessage -API 'IntuneReportExport' -tenant $TenantFilter -message "Submitted $ReportName export job $($Job.id)" -sev Info return @{ Status = 'Submitted'; JobId = $Job.id; ReportName = $ReportName; TenantFilter = $TenantFilter } diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 new file mode 100644 index 0000000000000..42bd5b3e68e4c --- /dev/null +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 @@ -0,0 +1,190 @@ +function Push-DBCacheSharePointSiteSharingLinks { + <# + .SYNOPSIS + Scans a single SharePoint/OneDrive site for sharing links and returns the rows. + + .DESCRIPTION + Processes one site (fanned out by Set-CIPPDBCacheSharePointSharingLinks). Enumerates the + site's drives, delta-scans each drive for items carrying the "shared" facet, fetches the + direct (non-inherited) sharing permissions of those items and returns one row per sharing + link (any scope) or direct grant to an external user. Delta pages are streamed and shared + items are processed in bounded buffers so a single very large library cannot exhaust the + worker's memory. The rows are returned to the orchestrator; Push-StoreSharePointSharingLinks + aggregates every site and writes the cache once. + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.TenantFilter + $SiteId = $Item.SiteId + $SiteName = $Item.SiteName + $SiteUrl = $Item.SiteUrl + $IsPersonalSite = [bool]$Item.IsPersonalSite + + # Verified domains passed from the parent; used to tell internal from external recipients. + $InternalDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Domain in @($Item.InternalDomains)) { if ($Domain) { [void]$InternalDomains.Add([string]$Domain) } } + + # Returns $true when an identity (link recipient or direct grant) is external to the tenant. + function Test-CIPPExternalIdentity { + param($Identity, $InternalDomains) + $LoginName = [string]($Identity.siteUser.loginName ?? $Identity.user.loginName ?? '') + if ($LoginName -match '#ext#' -or $LoginName -match 'urn%3aspo%3aguest' -or $LoginName -match 'urn:spo:guest') { return $true } + $Email = [string]($Identity.user.email ?? $Identity.user.userPrincipalName ?? $Identity.siteUser.email ?? '') + if ($Email -match '#EXT#') { return $true } + if ($Email -and $Email.Contains('@') -and $InternalDomains.Count -gt 0) { + return -not $InternalDomains.Contains($Email.Split('@')[-1]) + } + return $false + } + + # Friendly display value for an identity, preferring email over display name. + function Get-CIPPIdentityLabel { + param($Identity) + $Identity.user.email ?? $Identity.user.userPrincipalName ?? $Identity.siteUser.email ?? $Identity.user.displayName ?? $Identity.siteUser.displayName ?? $Identity.group.email ?? $Identity.group.displayName ?? $Identity.siteGroup.displayName + } + + $Rows = [System.Collections.Generic.List[object]]::new() + + # Fetch permissions for a buffer of shared items and append their sharing-link rows. + function Add-CIPPSharingRows { + param($Buffer, $Drive, $Site, $InternalDomains, $TenantFilter, $RowsOut) + + if (@($Buffer).Count -eq 0) { return } + + $ItemByRequestId = @{} + $RequestId = 0 + $PermissionRequests = foreach ($SharedItem in $Buffer) { + $ItemByRequestId["$RequestId"] = $SharedItem + @{ + id = "$RequestId" + method = 'GET' + url = "drives/$($Drive.id)/items/$($SharedItem.id)/permissions" + } + $RequestId++ + } + + $PermissionResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($PermissionRequests) -asapp $true + foreach ($Response in $PermissionResponses) { + if ($Response.status -and $Response.status -ne 200) { continue } + $DriveItem = $ItemByRequestId["$($Response.id)"] + + foreach ($Permission in @($Response.body.value)) { + # Only permissions set on the item itself; inherited ones are reported on their parent. + if ($Permission.inheritedFrom) { continue } + + if ($Permission.link) { + $Recipients = @($Permission.grantedToIdentitiesV2 ?? $Permission.grantedToIdentities) + $LinkScope = $Permission.link.scope ?? 'users' + $Classification = switch ($LinkScope) { + 'anonymous' { 'Anonymous' } + 'organization' { 'Internal' } + 'existingAccess' { 'Internal' } + default { + $HasExternal = $false + foreach ($Recipient in $Recipients) { + if (Test-CIPPExternalIdentity -Identity $Recipient -InternalDomains $InternalDomains) { $HasExternal = $true; break } + } + if ($HasExternal) { 'External' } else { 'Internal' } + } + } + $LinkType = $Permission.link.type ?? 'link' + $LinkUrl = $Permission.link.webUrl + } else { + # Direct grant (no sharing link): only report grants to external users. + $Recipients = @($Permission.grantedToV2 ?? $Permission.grantedTo) + if ($Permission.roles -contains 'owner') { continue } + $HasExternal = $false + foreach ($Recipient in $Recipients) { + if (Test-CIPPExternalIdentity -Identity $Recipient -InternalDomains $InternalDomains) { $HasExternal = $true; break } + } + if (-not $HasExternal) { continue } + $Classification = 'External' + $LinkScope = 'direct' + $LinkType = 'directGrant' + $LinkUrl = $null + } + + $SharedWith = @($Recipients | ForEach-Object { Get-CIPPIdentityLabel -Identity $_ } | Where-Object { $_ } | Sort-Object -Unique) + + $RowsOut.Add([PSCustomObject]@{ + id = "$($Drive.id)_$($DriveItem.id)_$($Permission.id)" + siteId = $Site.SiteId + siteName = $Site.SiteName + siteUrl = $Site.SiteUrl + workload = if ($Site.IsPersonalSite) { 'OneDrive' } else { 'SharePoint' } + driveId = $Drive.id + driveName = $Drive.name + itemId = $DriveItem.id + fileName = $DriveItem.name + itemUrl = $DriveItem.webUrl + itemType = if ($DriveItem.folder) { 'Folder' } else { 'File' } + size = $DriveItem.size + lastModifiedDateTime = $DriveItem.lastModifiedDateTime + permissionId = $Permission.id + linkType = $LinkType + linkScope = $LinkScope + classification = $Classification + roles = @($Permission.roles) + sharedWith = $SharedWith + linkUrl = $LinkUrl + hasPassword = $Permission.hasPassword ?? $false + expirationDateTime = $Permission.expirationDateTime + }) + } + } + } + + try { + # 1) Drives (document libraries) for this one site. + $Drives = @() + try { + $Drives = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/drives?`$select=id,name,driveType,webUrl" -tenantid $TenantFilter -asapp $true) + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: could not list drives for '$SiteUrl': $($_.Exception.Message)" -sev Warning + return @() + } + + $SiteContext = [PSCustomObject]@{ + SiteId = $SiteId + SiteName = $SiteName + SiteUrl = $SiteUrl + IsPersonalSite = $IsPersonalSite + } + $PermissionBufferSize = 200 + + # 2) Delta-scan each drive, streaming pages so a huge library never loads at once. + # Shared items are buffered and flushed to permission lookups in bounded chunks. + foreach ($Drive in $Drives) { + if (-not $Drive.id) { continue } + $Buffer = [System.Collections.Generic.List[object]]::new() + try { + New-GraphGetRequest -uri "https://graph.microsoft.com/beta/drives/$($Drive.id)/root/delta?`$select=id,name,webUrl,folder,shared,size,lastModifiedDateTime&`$top=999" -tenantid $TenantFilter -asapp $true -Stream | + Where-Object { $_.shared -and -not $_.deleted } | + ForEach-Object { + $Buffer.Add($_) + if ($Buffer.Count -ge $PermissionBufferSize) { + Add-CIPPSharingRows -Buffer $Buffer -Drive $Drive -Site $SiteContext -InternalDomains $InternalDomains -TenantFilter $TenantFilter -RowsOut $Rows + $Buffer.Clear() + } + } + # Flush the remainder for this drive. + Add-CIPPSharingRows -Buffer $Buffer -Drive $Drive -Site $SiteContext -InternalDomains $InternalDomains -TenantFilter $TenantFilter -RowsOut $Rows + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning drive '$($Drive.name)' on '$SiteUrl': $($_.Exception.Message)" -sev Warning + } finally { + $Buffer = $null + [System.GC]::Collect() + } + } + + return @($Rows) + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning site '$SiteUrl': $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + return @($Rows) + } +} diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 new file mode 100644 index 0000000000000..40532b7f4b4e1 --- /dev/null +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 @@ -0,0 +1,36 @@ +function Push-StoreSharePointSharingLinks { + <# + .SYNOPSIS + Post-execution function that aggregates per-site sharing links and writes the cache. + + .DESCRIPTION + Collects the row sets returned by every Push-DBCacheSharePointSiteSharingLinks activity and + writes them to the CIPP reporting database as a single SharePointSharingLinks dataset (full + replace with count). Writing once from this serial step avoids the {Type}-Count race that + parallel appenders would cause. + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.Parameters.TenantFilter + + try { + $AllRows = [System.Collections.Generic.List[object]]::new() + foreach ($SiteResult in $Item.Results) { + foreach ($Row in @($SiteResult)) { + if ($Row -and $Row.id) { $AllRows.Add($Row) } + } + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -Data @($AllRows) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($AllRows.Count) SharePoint/OneDrive sharing links across $(@($Item.Results).Count) sites" -sev Info + return + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to store SharePoint sharing links: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + throw + } +} diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 index 6d8cba6a8cb2c..b3c91c96ed345 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 @@ -11,6 +11,27 @@ function Push-CIPPStandard { $Tenant = $Item.Tenant $Standard = $Item.Standard + + # FUTURE USE - ZAC + # Grouped Conditional Access batch: deploy all of a tenant's CA templates in one sequential + # pass. Per-template rerun checks and standard-info context are handled inside the batch + # function, so we bypass the generic per-item dispatch below. + # if ($Item.BatchTemplates) { + # $TemplateCount = @($Item.BatchTemplates).Count + # Write-Information "Received Conditional Access batch for $Tenant with $TemplateCount template(s)." + # Set-CippUserAgentContext -Source 'standard' -TemplateId $Item.TemplateId + # $QueuedTime = if ($Item.QueuedTime) { [int64]$Item.QueuedTime } else { 0 } + # try { + # Invoke-CIPPCATemplateBatch -Tenant $Tenant -Templates $Item.BatchTemplates -QueuedTime $QueuedTime + # Write-Information "Conditional Access batch completed for tenant $Tenant" + # } catch { + # Write-LogMessage -API 'Standards' -tenant $Tenant -message "Error running Conditional Access batch for tenant $Tenant - $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + # Write-Warning "Error running Conditional Access batch for tenant $Tenant - $($_.Exception.Message)" + # throw $_.Exception.Message + # } + # return + # } + $FunctionName = 'Invoke-CIPPStandard{0}' -f $Standard Write-Information "We'll be running $FunctionName" diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 index 24062cd9847d5..e15912a163472 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 @@ -20,6 +20,32 @@ function Push-CIPPStandardsApplyBatch { return } + # FUTURE USE - ZAC + # Group all ConditionalAccessTemplate standards per tenant into a single batch item so + # they deploy sequentially (one activity per tenant) instead of fanning out one activity + # per template. This removes the 429 storm against the ~1 req/s CA write endpoint and the + # duplicate named location / c1-c99 / 1040 races. Non-CA standards pass through unchanged. + # $CAStandards = @($AllStandards | Where-Object { $_.Standard -eq 'ConditionalAccessTemplate' }) + # if ($CAStandards.Count -gt 0) { + # $OtherStandards = @($AllStandards | Where-Object { $_.Standard -ne 'ConditionalAccessTemplate' }) + # $GroupedCA = foreach ($TenantGroup in ($CAStandards | Group-Object -Property Tenant)) { + # [pscustomobject]@{ + # Tenant = $TenantGroup.Name + # Standard = 'ConditionalAccessTemplate' + # FunctionName = 'CIPPStandard' + # QueuedTime = ($TenantGroup.Group | Select-Object -First 1).QueuedTime + # BatchTemplates = @($TenantGroup.Group | ForEach-Object { + # [pscustomobject]@{ + # Settings = $_.Settings + # TemplateId = $_.TemplateId + # } + # }) + # } + # } + # $AllStandards = @($OtherStandards) + @($GroupedCA) + # Write-Information "Grouped $($CAStandards.Count) Conditional Access template standards into $(@($GroupedCA).Count) per-tenant batch item(s)." + # } + Write-Information "Aggregated $($AllStandards.Count) standards from all tenants: $($AllStandards | ConvertTo-Json -Depth 5 -Compress)" # Start orchestrator to apply standards diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 index d018d1d0aa9d5..fff94115fdb04 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 @@ -37,7 +37,19 @@ function Push-CIPPTestsList { # Emit one task per suite — suite names must match the ValidateSet in Invoke-CIPPTestCollection. # Function discovery happens inside Invoke-CIPPTestCollection via Get-Command (path-independent). - $Suites = @('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom') + $Suites = @('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom', 'E8') + + # Optional caller-supplied suite filter (e.g. a Custom-only run). When present, restrict + # the emitted suites to the requested subset so we don't spin up every suite unnecessarily. + if ($Item.Suites) { + $Requested = @($Item.Suites) + $Suites = @($Suites | Where-Object { $_ -in $Requested }) + if ($Suites.Count -eq 0) { + Write-Information "No suites matched the requested filter ($($Requested -join ', ')) for tenant $TenantFilter. Skipping." + return @() + } + Write-Information "Suite filter applied for $TenantFilter — running: $($Suites -join ', ')" + } $Tasks = foreach ($Suite in $Suites) { [PSCustomObject]@{ diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 index 79ae2f3a8b41c..0d0fb4281d13d 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 @@ -16,9 +16,16 @@ function Get-CIPPAlertCheckExtension { $LastRunTable = Get-CippTable -tablename 'AlertLastRun' $LastRunKey = "$TenantFilter-Get-CIPPAlertCheckExtension" - # Get the last run timestamp for this tenant to only fetch new alerts + # Capture the start of this run. The watermark is advanced to this value + # after processing so the next run only fetches alerts from this point + # onward, including any that arrive while this run is still processing. + $RunStart = (Get-Date).ToUniversalTime() + + # Get the last run timestamp for this tenant to only fetch new alerts. $LastRun = Get-CIPPAzDataTableEntity @LastRunTable -Filter "PartitionKey eq 'AlertLastRun' and RowKey eq '$LastRunKey'" | Select-Object -First 1 - $Since = if ($LastRun.Timestamp) { + $Since = if ($LastRun.LastRunTime) { + [datetime]::Parse($LastRun.LastRunTime, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind) + } elseif ($LastRun.Timestamp) { $LastRun.Timestamp.UtcDateTime } else { (Get-Date).AddDays(-1).ToUniversalTime() @@ -45,6 +52,16 @@ function Get-CIPPAlertCheckExtension { if ($AlertData) { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + + # Advance the watermark so the next run only picks up alerts newer than + # this run. Without this, $Since always fell back to the default window + # and previously sent alerts were re-sent on every run. + $LastRunEntity = @{ + PartitionKey = 'AlertLastRun' + RowKey = $LastRunKey + LastRunTime = $RunStart.ToString('o') + } + $null = Add-CIPPAzDataTableEntity @LastRunTable -Entity $LastRunEntity -Force } catch { $ErrorMessage = Get-CippException -Exception $_ Write-AlertMessage -message "Check Extension alert failed: $($ErrorMessage.NormalizedError)" -tenant $TenantFilter -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 index cb50fae892f54..ec58292bbae10 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 @@ -3,7 +3,7 @@ function Get-CIPPAlertHuntressRogueApps { .SYNOPSIS Check for rogue apps in a Tenant .DESCRIPTION - This function checks for rogue apps in the tenant by comparing the service principals in the tenant with a list of known rogue apps provided by Huntress. + This function checks for rogue apps in the tenant by comparing the service principals in the tenant with a list of known rogue apps provided by Huntress and a CIPP collections of appids. .FUNCTIONALITY Entrypoint .LINK @@ -19,8 +19,23 @@ function Get-CIPPAlertHuntressRogueApps { try { $RogueApps = Invoke-RestMethod -Uri 'https://huntresslabs.github.io/rogueapps/rogueapps.json' - $RogueAppFilter = $RogueApps.appId -join "','" - $ServicePrincipals = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$filter=appId in ('$RogueAppFilter')" -tenantid $TenantFilter + $CippRogueApps = (Get-Content -Path (Join-Path $env:CIPPRootPath 'Config\schemaDefinitions.json') | ConvertFrom-Json).applications.appId + $HuntressRogueApps = $RogueApps.appId + $RogueAppIds = @($CippRogueApps) + @($HuntressRogueApps) | Where-Object { $_ } | Select-Object -Unique + $Requests = for ($i = 0; $i -lt $RogueAppIds.Count; $i += 15) { + $Chunk = $RogueAppIds[$i..([Math]::Min($i + 14, $RogueAppIds.Count - 1))] + @{ + id = [string]$i + method = 'GET' + url = "servicePrincipals?`$filter=appId in ('$($Chunk -join "','")')" + } + } + $Requests = @($Requests) + + $ServicePrincipals = if ($Requests.Count -gt 0) { + $Responses = New-GraphBulkRequest -Requests $Requests -tenantid $TenantFilter + foreach ($Response in $Responses) { $Response.body.value } + } # If IgnoreDisabledApps is true, filter out disabled service principals if ($InputValue -eq $true) { $ServicePrincipals = $ServicePrincipals | Where-Object { $_.accountEnabled -eq $true } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 index 9a304a61ae372..1623d335a2968 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 @@ -20,6 +20,8 @@ function Get-CIPPAlertInactiveLicensedUsers { if ($InputValue -is [hashtable] -or $InputValue -is [pscustomobject]) { $excludeDisabled = [bool]$InputValue.ExcludeDisabled + # Allow the alert configuration to opt in to reporting users who have never signed in (e.g. accounts with sign-in blocked) + if ([bool]$InputValue.IncludeNeverSignedIn) { $IncludeNeverSignedIn = $true } if ($null -ne $InputValue.DaysSinceLastLogin -and $InputValue.DaysSinceLastLogin -ne '') { $parsedDays = 0 if ([int]::TryParse($InputValue.DaysSinceLastLogin.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { diff --git a/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 b/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 index 3a5ea194fe185..e5f94558e34e6 100644 --- a/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 @@ -34,16 +34,29 @@ function Add-CIPPGroupMember { [string]$APIName = 'Add Group Member' ) try { - if ($Member -like '*#EXT#*') { $Member = [System.Web.HttpUtility]::UrlEncode($Member) } $ODataBindString = 'https://graph.microsoft.com/v1.0/directoryObjects/{0}' - $Requests = foreach ($m in $Member) { + $Requests = @( + foreach ($m in $Member) { + if ($m -like '*#EXT#*') { $m = [System.Web.HttpUtility]::UrlEncode($m) } + @{ + id = "users-$m" + url = "users/$($m)?`$select=id,userPrincipalName" + method = 'GET' + } + } @{ - id = $m - url = "users/$($m)?`$select=id,userPrincipalName" + id = 'group' + url = "groups/$($GroupId)?`$select=id,displayName" method = 'GET' } - } - $Users = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter + ) + $BulkResults = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter + $Users = @($BulkResults | Where-Object { $_.id -like 'users-*' }) + # Group display name for logging; falls back to the id if the lookup failed + # (e.g. the group was addressed by mail rather than GUID). + $GroupName = ($BulkResults | Where-Object { $_.id -eq 'group' }).body.displayName ?? $GroupId + $SuccessfulUsers = [System.Collections.Generic.List[string]]::new() + $FailedUsers = [System.Collections.Generic.List[string]]::new() if ($GroupType -eq 'Distribution list' -or $GroupType -eq 'Mail-Enabled Security') { $ExoBulkRequests = [System.Collections.Generic.List[object]]::new() @@ -58,7 +71,7 @@ function Add-CIPPGroupMember { } }) $ExoLogs.Add(@{ - message = "Added member $($User.body.userPrincipalName) to $($GroupId) group" + message = "Added member $($User.body.userPrincipalName) to group $($GroupName)" target = $User.body.userPrincipalName }) } @@ -76,6 +89,7 @@ function Add-CIPPGroupMember { $ExoError = $LastError | Where-Object { $ExoLog.target -in $_.target -and $_.error } if (!$LastError -or ($LastError.error -and $LastError.target -notcontains $ExoLog.target)) { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $ExoLog.message -Sev 'Info' + $SuccessfulUsers.Add($ExoLog.target) } } } @@ -91,25 +105,36 @@ function Add-CIPPGroupMember { } } $AddResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($AddRequests) - $SuccessfulUsers = [system.collections.generic.list[string]]::new() foreach ($Result in $AddResults) { + $UserPrincipalName = ($Users | Where-Object { $_.body.id -eq $Result.id }).body.userPrincipalName if ($Result.status -lt 200 -or $Result.status -gt 299) { - $FailedUsername = $Users | Where-Object { $_.body.id -eq $Result.id } | Select-Object -ExpandProperty body | Select-Object -ExpandProperty userPrincipalName - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add member $($FailedUsername): $($Result.body.error.message)" -Sev 'Error' + # Select-Object -First 1: Get-NormalizedError can return multiple strings + # when a message matches more than one of its translation patterns. + $ErrorText = Get-NormalizedError -message ($Result.body.error.message ?? "Request failed with status $($Result.status)") | Select-Object -First 1 + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add member $UserPrincipalName to group $($GroupName): $ErrorText" -Sev 'Error' + $FailedUsers.Add("$UserPrincipalName ($ErrorText)") } else { - $UserPrincipalName = $Users | Where-Object { $_.body.id -eq $Result.id } | Select-Object -ExpandProperty body | Select-Object -ExpandProperty userPrincipalName $SuccessfulUsers.Add($UserPrincipalName) } } } - $UserList = ($SuccessfulUsers -join ', ') - $Results = "Successfully added user $UserList to $($GroupId)." + $Messages = [System.Collections.Generic.List[string]]::new() + if ($SuccessfulUsers.Count -gt 0) { + $Messages.Add("Successfully added user $($SuccessfulUsers -join ', ') to group $($GroupName).") + } + if ($FailedUsers.Count -gt 0) { + $Messages.Add("Failed to add $($FailedUsers -join '; ').") + } + $Results = $Messages -join ' ' + if ($SuccessfulUsers.Count -eq 0 -and $FailedUsers.Count -gt 0) { + throw $Results + } Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'Info' return $Results } catch { $ErrorMessage = Get-CippException -Exception $_ $UserList = if ($Users) { ($Users.body.userPrincipalName -join ', ') } else { ($Member -join ', ') } - $Results = "Failed to add user $UserList to $($GroupId) - $($ErrorMessage.NormalizedError)" + $Results = "Failed to add user $UserList to group $($GroupName ?? $GroupId) - $($ErrorMessage.NormalizedError)" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'error' -LogData $ErrorMessage throw $Results } diff --git a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 index 7837363b430dc..f408e2e59bb96 100644 --- a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 @@ -234,6 +234,7 @@ function Add-CIPPScheduledTask { Results = 'Planned' AlertComment = [string]$task.AlertComment CustomSubject = [string]$task.CustomSubject + PsaTicketStrategy = [string]($task.PsaTicketStrategy.value ?? $task.PsaTicketStrategy) } diff --git a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 index 77518b24d11d7..62b40dc56f3ba 100644 --- a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 @@ -74,7 +74,9 @@ function Add-CIPPW32ScriptApplication { # Build detection rules — detection script takes priority, then file detection, then marker file fallback if ($Properties.detectionScript) { # PowerShell script detection: script should write to STDOUT and exit 0 when detected - $DetectionScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Properties.detectionScript)) + # Resolve %tenantid%/%tenantfilter%/etc. for consistency with the install/uninstall scripts below + $ReplacedDetectionScript = Get-CIPPTextReplacement -Text $Properties.detectionScript -TenantFilter $TenantFilter + $DetectionScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($ReplacedDetectionScript)) $DetectionRules = @( @{ '@odata.type' = '#microsoft.graph.win32LobAppPowerShellScriptDetection' diff --git a/Modules/CIPPCore/Public/AuditLogs/Add-CippAuditLogCoverageManualEntry.ps1 b/Modules/CIPPCore/Public/AuditLogs/Add-CippAuditLogCoverageManualEntry.ps1 new file mode 100644 index 0000000000000..cf79d871408c6 --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/Add-CippAuditLogCoverageManualEntry.ps1 @@ -0,0 +1,74 @@ +function Add-CippAuditLogCoverageManualEntry { + <# + .SYNOPSIS + Bridge a manually-created audit log search into the V2 AuditLogCoverage ledger so the + pipeline downloads and processes it automatically (Option B). + .DESCRIPTION + Manual searches live in the AuditLogSearches table, which the (now V2-only) pipeline no + longer scans. When alert processing is requested for a manual search, this writes a ledger + row keyed 'MANUAL-' in State 'Created' so Start-AuditLogIngestionV2 / + Push-AuditLogDownloadV2 poll, download and process it like any other search - it inherits + retries, the orphan sweep, SearchStatus tracking and the coverage UI. + + The RowKey prefix keeps these out of the window planner: Get-CippAuditLogPlannedWindows only + considers 14-digit RowKeys and Get-CippAuditLogReconciliationWindows only 'RECON-*', so a + 'MANUAL-*' row is never treated as a window, gap or reconciliation block. Type 'Manual' lets + the UI exclude them from the window heatmap/charts. + + Idempotent (UpsertMerge): re-queuing the same search resets State to 'Created' to reprocess. + .PARAMETER TenantFilter + Tenant default domain (becomes the ledger PartitionKey). + .PARAMETER SearchId + The Graph audit-log search id. + .PARAMETER StartTime + Search start (datetime / DateTimeOffset / ISO string). Stored as WindowStart. + .PARAMETER EndTime + Search end. Stored as WindowEnd. + .PARAMETER SearchStatus + Graph search status at creation (e.g. notStarted); refreshed on each poll. + .PARAMETER TenantId + Tenant customerId. Resolved from TenantFilter if not supplied. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$SearchId, + $StartTime, + $EndTime, + [string]$SearchStatus, + [string]$TenantId + ) + + try { + if (-not $TenantId) { + try { $TenantId = Get-Tenants -TenantFilter $TenantFilter | Select-Object -First 1 -ExpandProperty customerId } catch {} + } + + $Now = (Get-Date).ToUniversalTime() + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $Entity = @{ + PartitionKey = [string]$TenantFilter + RowKey = 'MANUAL-' + [string]$SearchId + TenantId = [string]$TenantId + Type = 'Manual' + State = 'Created' + SearchId = [string]$SearchId + SearchStatus = [string]$SearchStatus + Attempts = 0 + RetryCount = 0 + ThrottleCount = 0 + CreatedUtc = $Now + LastPolledUtc = $Now + LastError = '' + } + if ($StartTime) { try { $Entity.WindowStart = ([datetimeoffset]$StartTime).UtcDateTime } catch {} } + if ($EndTime) { try { $Entity.WindowEnd = ([datetimeoffset]$EndTime).UtcDateTime } catch {} } + + Add-CIPPAzDataTableEntity @Ledger -Entity $Entity -OperationType UpsertMerge + Write-Information "AuditLogV2: bridged manual search $SearchId for $TenantFilter into coverage ledger (MANUAL-$SearchId)" + } catch { + Write-Information ('Add-CippAuditLogCoverageManualEntry error for {0} / {1}: {2}' -f $TenantFilter, $SearchId, $_.Exception.Message) + } +} diff --git a/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogNextAttempt.ps1 b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogNextAttempt.ps1 new file mode 100644 index 0000000000000..d7c6c49840733 --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogNextAttempt.ps1 @@ -0,0 +1,27 @@ +function Get-CippAuditLogNextAttempt { + <# + .SYNOPSIS + Compute the next-attempt UTC time for a retried audit-log coverage row (exponential backoff). + .DESCRIPTION + Used by the V2 audit-log pipeline to schedule retries of failed search creations and + downloads in the AuditLogCoverage ledger. Exponential backoff with jitter, capped. Because + the timers run every 15 minutes, small delays effectively mean "retry next run"; larger ones + defer a persistently failing window before it is eventually dead-lettered by the caller. + .PARAMETER Attempts + The attempt count that has just been consumed (1 = first failure). + .OUTPUTS + [datetime] (UTC) when the row becomes eligible to retry. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [int]$Attempts, + [int]$BaseMinutes = 5, + [int]$CapMinutes = 240 + ) + $exp = [Math]::Max(0, $Attempts - 1) + $delay = [Math]::Min($BaseMinutes * [Math]::Pow(2, $exp), $CapMinutes) + $jitter = Get-Random -Minimum 0.8 -Maximum 1.2 + return (Get-Date).ToUniversalTime().AddMinutes($delay * $jitter) +} diff --git a/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 new file mode 100644 index 0000000000000..a33fffb19acdb --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 @@ -0,0 +1,95 @@ +function Get-CippAuditLogPlannedWindows { + <# + .SYNOPSIS + Compute the 35-minute audit-log search windows a tenant is missing (gaps + the newest settled). + .DESCRIPTION + Pure helper for the V2 audit-log pipeline. Windows are 35 minutes long on a 30-minute stride, + so consecutive windows overlap by 5 minutes (covers boundary stragglers; alerting dedups by + record id). Window ENDS sit on the 30-minute grid minus the settle (i.e. :25 / :55), which is + exactly `floor_to_30min(now) - settle`. With the planner timer firing at :00/:15/:30/:45 and a + 5-minute settle, a fresh window becomes creatable exactly at a :00/:30 tick - no tick delay - + and the :15/:45 ticks naturally have no new window (they do retries + download/process). + + Backfill of older gaps is bounded by -HorizonHours and capped at -MaxPerRun per call (oldest + first). A brand-new tenant is seeded with only the newest settled window. + .PARAMETER ExistingRows + The tenant's current AuditLogCoverage rows. Reconciliation rows (RowKey 'RECON-*') are ignored + here; only regular 14-digit window keys are considered. + .PARAMETER Now + Reference time (UTC). Defaults to now. + .OUTPUTS + Array of [pscustomobject]@{ RowKey; WindowStart; WindowEnd } sorted oldest-first. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [object[]]$ExistingRows, + [datetime]$Now = (Get-Date).ToUniversalTime(), + [int]$SettleMinutes = 5, + [int]$WindowMinutes = 35, + [int]$StrideMinutes = 30, + [int]$HorizonHours = 24, + [int]$MaxPerRun = 6 + ) + + $Now = $Now.ToUniversalTime() + + # Newest window end: floor to the 30-min grid, minus the settle (lands on :25 / :55). + $FloorMinute = $Now.Minute - ($Now.Minute % $StrideMinutes) + $Floor = [datetime]::new($Now.Year, $Now.Month, $Now.Day, $Now.Hour, $FloorMinute, 0, [System.DateTimeKind]::Utc) + $NewestEnd = $Floor.AddMinutes(-$SettleMinutes) + + $HorizonStart = $Now.AddHours(-$HorizonHours) + + # Existing regular window keys (ignore reconciliation rows). + $ExistingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $ExistingStarts = [System.Collections.Generic.List[datetime]]::new() + foreach ($Row in $ExistingRows) { + if ($Row.RowKey -notmatch '^\d{14}$') { continue } + [void]$ExistingKeys.Add([string]$Row.RowKey) + if ($null -ne $Row.WindowStart) { + try { $ExistingStarts.Add(([datetimeoffset]$Row.WindowStart).UtcDateTime) } catch {} + } + } + + # Brand-new tenant: seed only the newest settled window. + if ($ExistingStarts.Count -eq 0) { + $Start = $NewestEnd.AddMinutes(-$WindowMinutes) + if ($Start -lt $HorizonStart) { return @() } + return , ([pscustomobject]@{ + RowKey = $Start.ToString('yyyyMMddHHmmss') + WindowStart = $Start + WindowEnd = $NewestEnd + }) + } + + # Established tenant: backfill missing windows from the lower bound up to NewestEnd (oldest first). + $EarliestExisting = ($ExistingStarts | Measure-Object -Minimum).Minimum + $LowerEnd = if ($EarliestExisting -gt $HorizonStart) { $EarliestExisting.AddMinutes($WindowMinutes) } else { $HorizonStart.AddMinutes($WindowMinutes) } + + $Owed = [System.Collections.Generic.List[object]]::new() + $End = $NewestEnd + while ($End -ge $LowerEnd) { + $Start = $End.AddMinutes(-$WindowMinutes) + $Key = $Start.ToString('yyyyMMddHHmmss') + if (-not $ExistingKeys.Contains($Key)) { + $Owed.Add([pscustomobject]@{ RowKey = $Key; WindowStart = $Start; WindowEnd = $End }) + } + $End = $End.AddMinutes(-$StrideMinutes) + } + + # $Owed is newest-first from the loop; reorder to oldest-first ([0]=oldest, [-1]=newest). + $Owed.Reverse() + if ($Owed.Count -le $MaxPerRun) { + return @($Owed) + } + + # Backlog exceeds the per-run cap: always include the NEWEST window so the live period can + # be created promptly (current-first, see Push-AuditLogSearchCreationV2), plus the oldest + # (MaxPerRun-1) so historical gaps still drain - oldest first - before they age out of the + # horizon. Without seeding the newest here it would never be Planned during a backlog. + $Newest = $Owed[$Owed.Count - 1] + $Backfill = @($Owed[0..($MaxPerRun - 2)]) + return @($Backfill + $Newest) +} diff --git a/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogReconciliationWindows.ps1 b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogReconciliationWindows.ps1 new file mode 100644 index 0000000000000..6ffa7f1c4c026 --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogReconciliationWindows.ps1 @@ -0,0 +1,68 @@ +function Get-CippAuditLogReconciliationWindows { + <# + .SYNOPSIS + Compute the 12-hour reconciliation audit-log windows a tenant is missing. + .DESCRIPTION + The fast 35-minute path searches each period soon after it closes, so late-landing / backfilled + audit events (Microsoft can publish them hours later) can be missed. This helper produces wide + catch-all windows aligned to 00:00-12:00 and 12:00-00:00 UTC, each created 3 hours after the + block closes (a generous settle so backfilled data has landed). They flow through the normal + download/process path; alerting dedups by record id, so overlap with the fast path is harmless. + .PARAMETER ExistingRows + The tenant's current AuditLogCoverage rows. Only reconciliation rows (RowKey 'RECON-*') are + considered when finding gaps. + .PARAMETER Now + Reference time (UTC). Defaults to now. + .OUTPUTS + Array of [pscustomobject]@{ RowKey; WindowStart; WindowEnd } sorted oldest-first. RowKey is + 'RECON-'. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [object[]]$ExistingRows, + [datetime]$Now = (Get-Date).ToUniversalTime(), + [int]$SettleHours = 3, + [int]$HorizonHours = 24, + [int]$MaxPerRun = 6 + ) + + $Now = $Now.ToUniversalTime() + + # Newest 12h block end (00:00 / 12:00 UTC) whose close is at least SettleHours in the past. + $T = $Now.AddHours(-$SettleHours) + $BoundaryHour = if ($T.Hour -lt 12) { 0 } else { 12 } + $NewestEnd = [datetime]::new($T.Year, $T.Month, $T.Day, $BoundaryHour, 0, 0, [System.DateTimeKind]::Utc) + $HorizonStart = $Now.AddHours(-$HorizonHours) + + $ExistingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Row in $ExistingRows) { + if ($Row.RowKey -like 'RECON-*') { [void]$ExistingKeys.Add([string]$Row.RowKey) } + } + + # No reconciliation history: seed only the newest settled block (avoid a first-run backfill spike; + # the fast 35-min path already covers recent history). Established tenants backfill gaps below. + if ($ExistingKeys.Count -eq 0) { + $Start = $NewestEnd.AddHours(-12) + if ($Start -lt $HorizonStart) { return @() } + return , ([pscustomobject]@{ RowKey = 'RECON-' + $Start.ToString('yyyyMMddHHmmss'); WindowStart = $Start; WindowEnd = $NewestEnd }) + } + + $Owed = [System.Collections.Generic.List[object]]::new() + $End = $NewestEnd + while ($End -ge $HorizonStart) { + $Start = $End.AddHours(-12) + $Key = 'RECON-' + $Start.ToString('yyyyMMddHHmmss') + if (-not $ExistingKeys.Contains($Key)) { + $Owed.Add([pscustomobject]@{ RowKey = $Key; WindowStart = $Start; WindowEnd = $End }) + } + $End = $End.AddHours(-12) + } + + $Owed.Reverse() + if ($Owed.Count -gt $MaxPerRun) { + return @($Owed[0..($MaxPerRun - 1)]) + } + return @($Owed) +} diff --git a/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 index befd7cd164966..94e2cd35cfbf9 100644 --- a/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 +++ b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 @@ -269,6 +269,13 @@ function New-CippAuditLogSearch { } $Table = Get-CIPPTable -TableName 'AuditLogSearches' Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force | Out-Null + + # When alert processing is requested, bridge the search into the V2 AuditLogCoverage + # ledger so the pipeline downloads + processes it automatically (the V2 pipeline does + # not scan the AuditLogSearches table). + if ($ProcessLogs.IsPresent) { + Add-CippAuditLogCoverageManualEntry -TenantFilter $TenantFilter -SearchId $Query.id -StartTime $StartTime -EndTime $EndTime -SearchStatus $Query.status + } } return $Query diff --git a/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearchV2.ps1 b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearchV2.ps1 new file mode 100644 index 0000000000000..babdc345f9328 --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearchV2.ps1 @@ -0,0 +1,86 @@ +function New-CippAuditLogSearchV2 { + <# + .SYNOPSIS + Create a Microsoft Graph audit-log search for the V2 pipeline and return a classified result. + .DESCRIPTION + Thin wrapper over New-GraphPOSTRequest (which now honours 429 backoff). Unlike the V1 + New-CippAuditLogSearch, this writes to NO table - the AuditLogCoverage ledger is updated by + the caller. Failures are classified so the caller can decide whether to retry (transient) or + stop (auditing disabled). + .PARAMETER TenantFilter + Tenant default domain or customerId. + .PARAMETER StartTime + Window start (inclusive). + .PARAMETER EndTime + Window end (exclusive). + .PARAMETER RecordTypeFilters + Record types to capture. Defaults to the four the V1 pipeline used. + .OUTPUTS + [pscustomobject]@{ Id; Status; Outcome; Message } Outcome in 'Created','AuditingDisabled','Transient'. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][datetime]$StartTime, + [Parameter(Mandatory = $true)][datetime]$EndTime, + [string[]]$RecordTypeFilters = @('exchangeAdmin', 'azureActiveDirectory', 'azureActiveDirectoryAccountLogon', 'azureActiveDirectoryStsLogon'), + [int]$MaxAttempts = 3, + [string]$DisplayName = ('CIPP Audit Search V2 - ' + (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) + ) + + $Body = @{ + displayName = $DisplayName + filterStartDateTime = $StartTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') + filterEndDateTime = $EndTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') + recordTypeFilters = @($RecordTypeFilters) + } | ConvertTo-Json -Compress + + if (-not $PSCmdlet.ShouldProcess($TenantFilter, 'Create audit log search')) { + return [pscustomobject]@{ Id = $null; Status = 'WhatIf'; Outcome = 'Transient'; Message = 'WhatIf'; Throttled = $false } + } + + for ($Attempt = 1; $Attempt -le $MaxAttempts; $Attempt++) { + try { + # maxRetries 1 = no retry inside the Graph helper; this function owns retry/backoff. + $Query = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/security/auditLog/queries' -body $Body -tenantid $TenantFilter -AsApp $true -maxRetries 1 + return [pscustomobject]@{ Id = $Query.id; Status = $Query.status; Outcome = 'Created'; Message = $null; Throttled = $false } + } catch { + $Raw = $_.Exception.Data['RawErrorBody'] + if (-not $Raw) { $Raw = $_.ErrorDetails.Message } + if (-not $Raw) { $Raw = $_.Exception.Message } + $Parsed = $null + if ($Raw) { try { $Parsed = ([string]$Raw) | ConvertFrom-Json -ErrorAction Stop } catch {} } + + # AuditingDisabledTenant can be top-level Status or nested as JSON inside error.message. + $AuditStatus = $Parsed.Status + if (-not $AuditStatus) { + $Inner = $Parsed.error.message ?? $Parsed.message + if ($Inner -is [string]) { try { $AuditStatus = ($Inner | ConvertFrom-Json -ErrorAction Stop).Status } catch {} } + } + if ($AuditStatus -eq 'AuditingDisabledTenant') { + return [pscustomobject]@{ Id = $null; Status = 'AuditingDisabledTenant'; Outcome = 'AuditingDisabled'; Message = 'Unified auditing is disabled for this tenant.'; Throttled = $false } + } + + $Code = $Parsed.error.code ?? $Parsed.code + $Msg = $Parsed.error.message ?? $Parsed.message ?? $_.Exception.Message + $StatusCode = $null + try { $StatusCode = [int]$_.Exception.Response.StatusCode } catch {} + + # 429 = the tenant's ~10 concurrent-search cap is full. Retrying in-process won't clear it, + # so return immediately and let the planner defer this + remaining windows to next cycle. + if (($Code -eq 'TooManyRequests') -or ($StatusCode -eq 429)) { + return [pscustomobject]@{ Id = $null; Status = ([string]($Code ?? 'TooManyRequests')); Outcome = 'Transient'; Message = [string]$Msg; Throttled = $true } + } + + # Other transient (UnknownError, 5xx, gateway, timeout): usually a momentary EXO-backend + # blip that clears on a quick re-submit. Retry in-process with >1s jitter before giving up. + if ($Attempt -lt $MaxAttempts) { + Start-Sleep -Seconds (Get-Random -Minimum 1.5 -Maximum 4.0) + continue + } + return [pscustomobject]@{ Id = $null; Status = ([string]($Code ?? 'Error')); Outcome = 'Transient'; Message = [string]$Msg; Throttled = $false } + } + } +} diff --git a/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 index 4b89c560b7594..dbc1b4759e68e 100644 --- a/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 @@ -17,13 +17,25 @@ function Get-CIPPRolePermissions { $Filter = "RowKey eq '$RoleName'" $Role = Get-CIPPAzDataTableEntity @Table -Filter $Filter if ($Role) { - $Permissions = $Role.Permissions | ConvertFrom-Json + $Permissions = ($Role.Permissions | ConvertFrom-Json).PSObject.Properties.Value + # Stored permissions can reference endpoints removed or renamed in later CIPP + # versions; drop those so stale entries don't inflate the role's permission set + # (e.g. failing the Test-CippApiClientRoleGrant subset check). Skip filtering if + # the valid-permission universe can't be resolved, rather than emptying the role. + try { + $ValidPermissions = Get-CippHttpPermissions + if (@($ValidPermissions).Count -gt 0) { + $Permissions = @($Permissions | Where-Object { $ValidPermissions -contains $_ }) + } + } catch { + Write-Warning "Unable to resolve valid permissions to filter role '$RoleName': $($_.Exception.Message)" + } $AllowedTenants = if ($Role.AllowedTenants) { $Role.AllowedTenants | ConvertFrom-Json } else { @() } $BlockedTenants = if ($Role.BlockedTenants) { $Role.BlockedTenants | ConvertFrom-Json } else { @() } $BlockedEndpoints = if ($Role.BlockedEndpoints) { $Role.BlockedEndpoints | ConvertFrom-Json } else { @() } [PSCustomObject]@{ Role = $Role.RowKey - Permissions = $Permissions.PSObject.Properties.Value + Permissions = @($Permissions) AllowedTenants = @($AllowedTenants) BlockedTenants = @($BlockedTenants) BlockedEndpoints = @($BlockedEndpoints) diff --git a/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 index 0fd04bd57e070..7d7a245274222 100644 --- a/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 @@ -22,30 +22,10 @@ function Get-CippAllowedPermissions { ) # Get all available permissions and base roles configuration - - $Version = if ($env:CIPPNG -eq 'true') { - $env:APP_VERSION - } else { - (Get-Content -Path (Join-Path $env:CIPPRootPath 'version_latest.txt')).Trim() - } $BaseRoles = Get-Content -Path (Join-Path $env:CIPPRootPath 'Config\cipp-roles.json') | ConvertFrom-Json $DefaultRoles = @('superadmin', 'admin', 'editor', 'readonly', 'anonymous', 'authenticated') - $AllPermissionCacheTable = Get-CIPPTable -tablename 'cachehttppermissions' - $AllPermissionsRow = Get-CIPPAzDataTableEntity @AllPermissionCacheTable -Filter "PartitionKey eq 'HttpFunctions' and RowKey eq 'HttpFunctions' and Version eq '$($Version)'" - - if (-not $AllPermissionsRow.Permissions) { - $AllPermissions = Get-CIPPHttpFunctions -ByRole | Select-Object -ExpandProperty Permission - $Entity = @{ - PartitionKey = 'HttpFunctions' - RowKey = 'HttpFunctions' - Version = [string]$Version - Permissions = [string]($AllPermissions | ConvertTo-Json -Compress) - } - Add-CIPPAzDataTableEntity @AllPermissionCacheTable -Entity $Entity -Force - } else { - $AllPermissions = $AllPermissionsRow.Permissions | ConvertFrom-Json - } + $AllPermissions = Get-CippHttpPermissions $AllowedPermissions = [System.Collections.Generic.List[string]]::new() diff --git a/Modules/CIPPCore/Public/Authentication/Get-CippHttpPermissions.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CippHttpPermissions.ps1 new file mode 100644 index 0000000000000..599ce78189de4 --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Get-CippHttpPermissions.ps1 @@ -0,0 +1,50 @@ +function Get-CippHttpPermissions { + <# + .SYNOPSIS + Returns the set of API permissions that exist on the current HTTP functions. + + .DESCRIPTION + Resolves the full permission universe for the running CIPP version from the + cachehttppermissions table, computing and caching it via Get-CIPPHttpFunctions + on a cache miss. Results are memoized in-process per version so hot paths + (Test-CIPPAccess, Get-CippAllowedPermissions) avoid repeated table reads. + + .OUTPUTS + [string[]] of valid permission names, e.g. 'Exchange.Mailbox.ReadWrite'. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + $Version = if ($env:CIPPNG -eq 'true') { + $env:APP_VERSION + } else { + (Get-Content -Path (Join-Path $env:CIPPRootPath 'version_latest.txt')).Trim() + } + + if ($script:CippHttpPermissions -and $script:CippHttpPermissionsVersion -eq $Version) { + return $script:CippHttpPermissions + } + + $AllPermissionCacheTable = Get-CIPPTable -tablename 'cachehttppermissions' + $AllPermissionsRow = Get-CIPPAzDataTableEntity @AllPermissionCacheTable -Filter "PartitionKey eq 'HttpFunctions' and RowKey eq 'HttpFunctions' and Version eq '$($Version)'" + + if (-not $AllPermissionsRow.Permissions) { + $AllPermissions = Get-CIPPHttpFunctions -ByRole | Select-Object -ExpandProperty Permission + $Entity = @{ + PartitionKey = 'HttpFunctions' + RowKey = 'HttpFunctions' + Version = [string]$Version + Permissions = [string]($AllPermissions | ConvertTo-Json -Compress) + } + Add-CIPPAzDataTableEntity @AllPermissionCacheTable -Entity $Entity -Force + } else { + $AllPermissions = $AllPermissionsRow.Permissions | ConvertFrom-Json + } + + $script:CippHttpPermissions = @($AllPermissions) + $script:CippHttpPermissionsVersion = $Version + return $script:CippHttpPermissions +} diff --git a/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 b/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 index 127af216dcc51..bdcdc9728e870 100644 --- a/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 @@ -23,7 +23,7 @@ function Initialize-CIPPAuth { # -- Entry logging -- $EasyAuthEnabled = [Craft.Services.AppLifecycleBridge]::IsEasyAuthConfigured() $IsDevStorage = ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true') -or ($env:NonLocalHostAzurite -eq 'true') - $KVName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $KVName = Get-CippKeyVaultName Write-Information "[Auth-Init] Starting — EasyAuth=$EasyAuthEnabled, DevStorage=$IsDevStorage, KVName='$KVName', DeploymentId='$env:WEBSITE_DEPLOYMENT_ID'" diff --git a/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOEasyAuth.ps1 b/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOEasyAuth.ps1 index 8ced685da8420..c87f28a6fc09d 100644 --- a/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOEasyAuth.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOEasyAuth.ps1 @@ -71,8 +71,7 @@ function Set-CIPPSSOEasyAuth { # Set AUTH_SECRET as a KV reference when requested (initial setup) # Skip for implicit auth (no client secret needed — e.g. central migration app) if ($UseKvReferences -and -not $ImplicitAuth) { - $KV = $env:WEBSITE_DEPLOYMENT_ID - $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } + $VaultName = Get-CippKeyVaultName if ($VaultName) { $MergedSettings['AUTH_SECRET'] = "@Microsoft.KeyVault(VaultName=$VaultName;SecretName=SSOAppSecret)" } diff --git a/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOStoredCredentials.ps1 b/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOStoredCredentials.ps1 index 8ee4bd1456c1b..560fff8522933 100644 --- a/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOStoredCredentials.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOStoredCredentials.ps1 @@ -32,9 +32,8 @@ function Set-CIPPSSOStoredCredentials { return } - $KV = $env:WEBSITE_DEPLOYMENT_ID - $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } - if (-not $VaultName) { throw 'Cannot determine Key Vault name from WEBSITE_DEPLOYMENT_ID' } + $VaultName = Get-CippKeyVaultName + if (-not $VaultName) { throw 'Cannot determine Key Vault name (WEBSITE_SITE_NAME / WEBSITE_DEPLOYMENT_ID not set)' } if ($AppId) { $ExistingAppIdSecret = $null diff --git a/Modules/CIPPCore/Public/Authentication/Update-CIPPSSORedirectUri.ps1 b/Modules/CIPPCore/Public/Authentication/Update-CIPPSSORedirectUri.ps1 index 03d113d2a2145..e5c7bd830b6f4 100644 --- a/Modules/CIPPCore/Public/Authentication/Update-CIPPSSORedirectUri.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Update-CIPPSSORedirectUri.ps1 @@ -32,8 +32,7 @@ function Update-CIPPSSORedirectUri { $SSOMultiTenant = $Secret.SSOMultiTenant -eq 'True' } catch { } } else { - $KV = $env:WEBSITE_DEPLOYMENT_ID - $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } + $VaultName = Get-CippKeyVaultName if ($VaultName) { try { $SSOAppId = Get-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOAppId' -AsPlainText -ErrorAction Stop @@ -95,30 +94,33 @@ function Update-CIPPSSORedirectUri { return } - # Build patch body - $PatchBody = @{} - + # Patch redirect URIs and signInAudience as separate requests. A tenant app-management + # policy can reject an audience change (e.g. downgrading a multi-tenant app to + # single-tenant fails with "SigninAudienceRestrictions with restricted mode can be + # configured only on multi-tenants apps"). Sending them together would let that + # rejection also drop the redirect URI additions, which are needed for sign-in. if ($MissingUris.Count -gt 0) { $UpdatedUris = [System.Collections.Generic.List[string]]::new() $ExistingUris | ForEach-Object { $UpdatedUris.Add($_) } $MissingUris | ForEach-Object { $UpdatedUris.Add($_) } - $PatchBody.web = @{ redirectUris = $UpdatedUris } - } - - if ($AudienceMismatch) { - $PatchBody.signInAudience = $ExpectedAudience - Write-Information "[SSO-Redirect] Correcting signInAudience: $($AppResponse.signInAudience) -> $ExpectedAudience" - } - - $Body = $PatchBody | ConvertTo-Json -Depth 5 - New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppResponse.id)" -body $Body -type PATCH -NoAuthCheck $true -AsApp $true - - if ($MissingUris.Count -gt 0) { + $UriBody = @{ web = @{ redirectUris = $UpdatedUris } } | ConvertTo-Json -Depth 5 + New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppResponse.id)" -body $UriBody -type PATCH -NoAuthCheck $true -AsApp $true Write-Information "[SSO-Redirect] Added redirect URIs: $($MissingUris -join ', ')" Write-LogMessage -API 'SSO-Redirect' -message "Added redirect URIs: $($MissingUris -join ', ')" -sev Info } + if ($AudienceMismatch) { - Write-LogMessage -API 'SSO-Redirect' -message "Updated signInAudience to $ExpectedAudience (multiTenant=$SSOMultiTenant)" -sev Info + Write-Information "[SSO-Redirect] Correcting signInAudience: $($AppResponse.signInAudience) -> $ExpectedAudience" + try { + $AudienceBody = @{ signInAudience = $ExpectedAudience } | ConvertTo-Json -Compress + New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppResponse.id)" -body $AudienceBody -type PATCH -NoAuthCheck $true -AsApp $true + Write-LogMessage -API 'SSO-Redirect' -message "Updated signInAudience to $ExpectedAudience (multiTenant=$SSOMultiTenant)" -sev Info + } catch { + # Non-fatal: a tenant app-management policy is blocking the audience change. + # EasyAuth issuer validation already enforces the effective tenant scope, so the + # app registration can stay as-is. Log at Info so warmup doesn't spam warnings. + Write-Information "[SSO-Redirect] signInAudience change to $ExpectedAudience was rejected by tenant policy (leaving app reg as $($AppResponse.signInAudience)): $($_.Exception.Message)" + } } } catch { Write-LogMessage -API 'SSO-Redirect' -message "Failed to update SSO app registration: $_" -LogData (Get-CippException -Exception $_) -sev Warning diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1 index a123691d9d8cd..7c96361eb7bf6 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1 @@ -80,7 +80,7 @@ function Compare-CIPPIntuneAssignments { $ExpectedGroupIds = @( $ExpectedCustomGroup.Split(',').Trim() | ForEach-Object { $name = $_ - $AllGroupsCache | Where-Object { $_.displayName -like $name } | Select-Object -ExpandProperty id + $AllGroupsCache | Where-Object { $_.displayName -like ($name -replace '\[', '`[' -replace '\]', '`]') } | Select-Object -ExpandProperty id } | Where-Object { $_ } ) $MissingIds = @($ExpectedGroupIds | Where-Object { $_ -notin $ExistingIncludeGroupIds }) @@ -97,7 +97,7 @@ function Compare-CIPPIntuneAssignments { $ExpectedExcludeIds = @( $ExpectedExcludeGroup.Split(',').Trim() | ForEach-Object { $name = $_ - $AllGroupsCache | Where-Object { $_.displayName -like $name } | Select-Object -ExpandProperty id + $AllGroupsCache | Where-Object { $_.displayName -like ($name -replace '\[', '`[' -replace '\]', '`]') } | Select-Object -ExpandProperty id } | Where-Object { $_ } ) $MissingExcludeIds = @($ExpectedExcludeIds | Where-Object { $_ -notin $ExistingExcludeGroupIds }) diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index fc9902d80823f..c52306940cbbd 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -35,7 +35,8 @@ function Compare-CIPPIntuneObject { 'isSynced' 'locationInfo', 'templateId', - 'source' + 'source', + 'package' ) $excludeProps = $defaultExcludeProperties + $ExcludeProperties @@ -173,11 +174,12 @@ function Compare-CIPPIntuneObject { if (ShouldCompareAsUnorderedSet -PropertyPath $PropertyPath) { # For unordered sets, compare contents regardless of order if ($Object1.Count -ne $Object2.Count) { - # Different lengths - report the difference + # Different lengths - report the actual values so a technician + # can see exactly what differs and decide on the action. $result.Add([PSCustomObject]@{ Property = $PropertyPath - ExpectedValue = "Array with $($Object1.Count) items" - ReceivedValue = "Array with $($Object2.Count) items" + ExpectedValue = ($Object1 -join ', ') + ReceivedValue = ($Object2 -join ', ') }) } else { # Same length - check if all items exist in both arrays @@ -448,7 +450,7 @@ function Compare-CIPPIntuneObject { } $values.Add($displayValue) } - $childValue = $values -join ', ' + $childValue = ($values | Sort-Object) -join ', ' $results.Add([PSCustomObject]@{ Key = "GroupChild-$($child.settingDefinitionId)" @@ -464,7 +466,7 @@ function Compare-CIPPIntuneObject { foreach ($simpleValue in $child.simpleSettingCollectionValue) { $values.Add($simpleValue.value) } - $childValue = $values -join ', ' + $childValue = ($values | Sort-Object) -join ', ' $results.Add([PSCustomObject]@{ Key = "GroupChild-$($child.settingDefinitionId)" @@ -765,7 +767,9 @@ function Compare-CIPPIntuneObject { $key } - if ($refRawValue -ne $diffRawValue -or $null -eq $refRawValue -or $null -eq $diffRawValue) { + # Flag when values differ or the setting exists on only one side; a setting present on both sides with equal (even null) values is compliant + $presenceMismatch = ($null -eq $refItem) -xor ($null -eq $diffItem) + if ($refRawValue -ne $diffRawValue -or $presenceMismatch) { $result.Add([PSCustomObject]@{ Property = $label ExpectedValue = $refValue diff --git a/Modules/CIPPCore/Public/Compare-CIPPSensitiveInfoType.ps1 b/Modules/CIPPCore/Public/Compare-CIPPSensitiveInfoType.ps1 new file mode 100644 index 0000000000000..426bec77f51ef --- /dev/null +++ b/Modules/CIPPCore/Public/Compare-CIPPSensitiveInfoType.ps1 @@ -0,0 +1,154 @@ +function ConvertTo-CIPPSitComparable { + <# + .SYNOPSIS + Reduce a SIT rule pack XML to a semantic, comparable structure (ignoring volatile ids/versions). + .DESCRIPTION + A rule pack XML carries GUIDs (RulePack/Publisher/Entity/Regex ids) and a version that change + between deploys and are assigned by Microsoft, so a raw XML compare always looks like drift. This + extracts only the meaningful content per entity - name, description, recommended confidence, + patterns proximity, and each pattern's confidence level with its resolved regex/keyword content - + keyed by entity name. Parsing is namespace-agnostic (local-name()) so the 2011 and 2018 schemas + both work. + .OUTPUTS + Hashtable of entityName -> ordered hashtable { confidence, proximity, description, patterns }. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param([Parameter(Mandatory)][AllowNull()][string]$Xml) + + $result = @{} + if ([string]::IsNullOrWhiteSpace($Xml)) { return $result } + try { [xml]$doc = $Xml } catch { return $result } + + # Resolve referenced detection elements: regex id -> pattern text, keyword id -> sorted terms. + $regexMap = @{} + foreach ($r in $doc.SelectNodes("//*[local-name()='Regex']")) { + if ($r.id) { $regexMap[[string]$r.id] = ([string]$r.InnerText).Trim() } + } + $keywordMap = @{} + foreach ($k in $doc.SelectNodes("//*[local-name()='Keyword']")) { + $terms = @($k.SelectNodes(".//*[local-name()='Term']") | ForEach-Object { ([string]$_.InnerText).Trim() }) | Sort-Object + if ($k.id) { $keywordMap[[string]$k.id] = ($terms -join '|') } + } + + # entity id -> localized name/description + $resMap = @{} + foreach ($res in $doc.SelectNodes("//*[local-name()='Resource']")) { + if (-not $res.idRef) { continue } + $nameNode = $res.SelectSingleNode("*[local-name()='Name']") + $descNode = $res.SelectSingleNode("*[local-name()='Description']") + $resMap[[string]$res.idRef] = @{ + Name = if ($nameNode) { ([string]$nameNode.InnerText).Trim() } else { '' } + Description = if ($descNode) { ([string]$descNode.InnerText).Trim() } else { '' } + } + } + + foreach ($ent in $doc.SelectNodes("//*[local-name()='Entity']")) { + $eid = [string]$ent.id + $name = if ($resMap.ContainsKey($eid) -and $resMap[$eid].Name) { $resMap[$eid].Name } else { $eid } + + $patterns = @(foreach ($p in $ent.SelectNodes("*[local-name()='Pattern']")) { + $matches = @($p.SelectNodes(".//*[@idRef]") | ForEach-Object { + $ref = [string]$_.idRef + if ($regexMap.ContainsKey($ref)) { "regex:$($regexMap[$ref])" } + elseif ($keywordMap.ContainsKey($ref)) { "keyword:$($keywordMap[$ref])" } + else { "ref:$ref" } + }) | Sort-Object + [ordered]@{ level = [string]$p.confidenceLevel; matches = @($matches) } + }) + + $result[$name] = [ordered]@{ + confidence = [string]$ent.recommendedConfidence + proximity = [string]$ent.patternsProximity + description = if ($resMap.ContainsKey($eid)) { $resMap[$eid].Description } else { '' } + patterns = @($patterns) + } + } + return $result +} + +function Compare-CIPPSensitiveInfoType { + <# + .SYNOPSIS + Compare a stored SIT template against the live custom SIT in a tenant and report drift. + .DESCRIPTION + Resolves the template's intended rule pack XML (advanced FileDataBase64, or synthesized from a + simple Pattern), fetches the live SIT's rule pack XML, reduces both to a semantic structure via + ConvertTo-CIPPSitComparable, and diffs each templated entity (matched by name). Returns the state + and the specific differing fields with expected (template) vs current (tenant) values. + .OUTPUTS + PSCustomObject: Name, State ('Missing' | 'BuiltIn' | 'Invalid' | 'InSync' | 'Drift'), Differences. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $TenantFilter, + [Parameter(Mandatory)] $Template + ) + + $Name = $Template.Name ?? $Template.name + + # Resolve the template's intended rule pack XML. + $WantXml = $null + if ($Template.FileDataBase64) { + try { $Bytes = [System.Convert]::FromBase64String($Template.FileDataBase64) } catch { $Bytes = $null } + if ($Bytes) { + $WantXml = [System.Text.Encoding]::Unicode.GetString($Bytes) + if ($WantXml -notmatch ' + [CmdletBinding()] + param( + [Parameter(Mandatory)] $InputObject + ) + + $Result = @{ '@odata.type' = '#Exchange.GenericHashTable' } + + if ($InputObject -is [System.Collections.IDictionary]) { + foreach ($Key in @($InputObject.Keys)) { + if ($Key -ne '@odata.type') { $Result[$Key] = $InputObject[$Key] } + } + } elseif ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { + foreach ($Entry in $InputObject) { + if ($null -eq $Entry) { continue } + if ($Entry -isnot [string] -and $Entry.PSObject.Properties['Key']) { + $Result[$Entry.Key] = $Entry.Value + } elseif ($Entry -is [System.Collections.IList] -and $Entry.Count -ge 2) { + $Result["$($Entry[0])"] = $Entry[1] + } + } + } else { + foreach ($Prop in $InputObject.PSObject.Properties) { + if ($Prop.Name -ne '@odata.type') { $Result[$Prop.Name] = $Prop.Value } + } + } + + return $Result +} diff --git a/Modules/CIPPCore/Public/ConvertTo-CIPPSensitivityLabelParams.ps1 b/Modules/CIPPCore/Public/ConvertTo-CIPPSensitivityLabelParams.ps1 index 7c3894d872e4a..6cea14dc0e2d7 100644 --- a/Modules/CIPPCore/Public/ConvertTo-CIPPSensitivityLabelParams.ps1 +++ b/Modules/CIPPCore/Public/ConvertTo-CIPPSensitivityLabelParams.ps1 @@ -15,6 +15,11 @@ function ConvertTo-CIPPSensitivityLabelParams { arrays (which are not valid input in their read form). A flat object (manual JSON authored against the deploy schema) has no 'LabelActions' and passes through unchanged. + Applied -AdvancedSettings values (e.g. a custom label color) are only readable through the same + read-only 'Settings' array, so before dropping it the writable advanced settings are lifted into + an 'AdvancedSettings' dictionary that New-/Set-Label accept. An explicit 'AdvancedSettings' value + already on the template wins over captured values. + Deploy-time validation/allowlisting still happens in Set-CIPPSensitivityLabel via Get-CIPPSensitivityLabelField; this function only reshapes. .PARAMETER Label @@ -42,6 +47,39 @@ function ConvertTo-CIPPSensitivityLabelParams { return [pscustomobject]$Flat } + # Writable advanced settings that Get-Label only reports inside the read-only Settings array + # ([key, value] pairs). The rest of Settings is system metadata (displayname, contenttype, + # tooltip, ...) that must not be echoed back to New-/Set-Label. Extend this list as more + # -AdvancedSettings keys gain first-class support. + $WritableAdvancedSettings = @('color') + $CapturedAdvanced = @{} + foreach ($Entry in @($Label.Settings)) { + if ($null -eq $Entry) { continue } + $Key = $null + $Value = $null + if ($Entry -isnot [string] -and $Entry.PSObject.Properties['Key']) { + $Key = $Entry.Key + $Value = $Entry.Value + } elseif ("$Entry" -match '^\[\s*(.+?)\s*,\s*(.*?)\s*\]$') { + # Get-Label serializes each entry as the string '[key, value]' + $Key = $Matches[1] + $Value = $Matches[2] + } + if ($Key -and $Key.ToLower() -in $WritableAdvancedSettings -and -not [string]::IsNullOrWhiteSpace("$Value")) { + $CapturedAdvanced[$Key.ToLower()] = "$Value" + } + } + if ($CapturedAdvanced.Count -gt 0) { + # Explicit AdvancedSettings on the template win over values captured from Settings. + $Explicit = $Flat['AdvancedSettings'] + if ($Explicit -is [System.Collections.IDictionary]) { + foreach ($ExplicitKey in @($Explicit.Keys)) { $CapturedAdvanced[$ExplicitKey] = $Explicit[$ExplicitKey] } + } elseif ($null -ne $Explicit) { + foreach ($ExplicitProp in $Explicit.PSObject.Properties) { $CapturedAdvanced[$ExplicitProp.Name] = $ExplicitProp.Value } + } + $Flat['AdvancedSettings'] = $CapturedAdvanced + } + foreach ($Raw in @($Label.LabelActions)) { if ($null -eq $Raw) { continue } $Action = if ($Raw -is [string]) { $Raw | ConvertFrom-Json } else { $Raw } diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 new file mode 100644 index 0000000000000..9b47577ec4c87 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 @@ -0,0 +1,82 @@ +function Start-AuditLogIngestionV2 { + <# + .SYNOPSIS + V2 audit-log ingestion timer. Drives both download and processing, decoupled so that pending + logs are processed even when there is nothing new to download. + .DESCRIPTION + Runs offset 15 minutes from the creation timer and fans out two kinds of work: + + 1. Download tenants - AuditLogCoverage rows in state 'Created' (a search was created and is + awaiting download) and due (not in backoff). Each gets a per-tenant orchestrator: + Batch = AuditLogDownloadV2 (download succeeded searches -> CacheWebhooks) + PostExecution = AuditLogProcessV2 (enqueue processing if any cache rows are pending) + + 2. Process-only tenants - tenants that have rows sitting in CacheWebhooks (downloaded but + not yet processed, e.g. left behind by a worker crash mid-processing) but no pending + download. These get a processing orchestrator fanned out DIRECTLY, skipping the no-op + download orchestration. + + This makes processing self-healing: a crashed/partial processing run is retried on the next + cycle off the cache contents, not gated behind a fresh download. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param() + try { + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $Now = (Get-Date).ToUniversalTime() + + # --- Download tenants: searches awaiting download (State = Created, due) --- + $Created = @(Get-CIPPAzDataTableEntity @Ledger -Filter "State eq 'Created'") + $DueCreated = $Created | Where-Object { -not $_.NextAttemptUtc -or ([datetimeoffset]$_.NextAttemptUtc).UtcDateTime -le $Now } + $DownloadTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Name in @($DueCreated | Group-Object PartitionKey | ForEach-Object { $_.Name })) { + if ($Name) { [void]$DownloadTenants.Add([string]$Name) } + } + + # --- Process-only tenants: rows pending in the webhook cache (downloaded, not yet processed) --- + $CacheTable = Get-CippTable -TableName 'CacheWebhooks' + $CacheRows = @(Get-CIPPAzDataTableEntity @CacheTable -Property @('PartitionKey', 'RowKey')) + $CacheTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Name in @($CacheRows | Group-Object PartitionKey | ForEach-Object { $_.Name })) { + if ($Name) { [void]$CacheTenants.Add([string]$Name) } + } + + if ($DownloadTenants.Count -eq 0 -and $CacheTenants.Count -eq 0) { + Write-Information 'AuditLogV2: nothing to download or process' + return + } + + # 1) Download tenants -> download + post-exec processing in one orchestration. + foreach ($TenantFilter in $DownloadTenants) { + if ($PSCmdlet.ShouldProcess($TenantFilter, 'Download + process audit logs')) { + Start-CIPPOrchestrator -InputObject ([PSCustomObject]@{ + OrchestratorName = "AuditLogIngestV2-$TenantFilter" + Batch = @([PSCustomObject]@{ FunctionName = 'AuditLogDownloadV2'; TenantFilter = $TenantFilter }) + PostExecution = @{ FunctionName = 'AuditLogProcessV2'; Parameters = @{ TenantFilter = $TenantFilter } } + SkipLog = $true + }) + } + } + + # 2) Process-only tenants (pending cache, no pending download) -> process directly. + $ProcessOnlyCount = 0 + foreach ($TenantFilter in $CacheTenants) { + if ($DownloadTenants.Contains($TenantFilter)) { continue } + $ProcessOnlyCount++ + if ($PSCmdlet.ShouldProcess($TenantFilter, 'Process pending audit logs')) { + Start-CIPPOrchestrator -InputObject ([PSCustomObject]@{ + OrchestratorName = "AuditLogProcessV2-$TenantFilter" + QueueFunction = [PSCustomObject]@{ FunctionName = 'AuditLogProcessingBatchV2'; Parameters = @{ TenantFilter = $TenantFilter } } + SkipLog = $true + }) + } + } + + Write-Information "AuditLogV2: ingestion fan-out - $($DownloadTenants.Count) download tenant(s), $ProcessOnlyCount process-only tenant(s)" + } catch { + Write-LogMessage -API 'AuditLogV2' -message 'Error in audit log ingestion orchestrator (V2)' -sev Error -LogData (Get-CippException -Exception $_) + Write-Information ('AuditLogV2 ingestion error {0} line {1} - {2}' -f $_.InvocationInfo.ScriptName, $_.InvocationInfo.ScriptLineNumber, $_.Exception.Message) + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 new file mode 100644 index 0000000000000..4e8ad995b155b --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 @@ -0,0 +1,38 @@ +function Start-AuditLogPlannerV2 { + <# + .SYNOPSIS + Single timer entrypoint for the V2 audit-log pipeline. Runs every 15 minutes and drives both + stages: plan + create searches, then download + process. + .DESCRIPTION + Replaces the separate Start-AuditLogSearchCreationV2 and Start-AuditLogIngestionV2 timers with + one planner so the whole pipeline ticks together: + + Stage 1 (create) - Start-AuditLogSearchCreationV2: seeds owed 35-min windows (5-min settle, + ends on the :25/:55 grid so a fresh window is creatable exactly at :00/:30 with no tick + delay) plus 12-hour reconciliation windows, then creates the oldest <= 6 due windows per + tenant with auto-retry disabled and manual 429 back-off. + + Stage 2 (ingest) - Start-AuditLogIngestionV2: downloads searches created in PRIOR ticks that + are now ready, processes them, and re-processes any tenant with leftover cache rows. + + The two stages operate on different windows (stage 1 queues new searches; stage 2 consumes + searches from earlier ticks once Graph has finished them), so running them in one tick simply + pipelines the work. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param() + try { + Start-AuditLogSearchCreationV2 + } catch { + Write-LogMessage -API 'AuditLogV2' -message 'Planner: search creation stage failed' -sev Error -LogData (Get-CippException -Exception $_) + Write-Information ('AuditLogV2 planner (create) error: {0}' -f $_.Exception.Message) + } + try { + Start-AuditLogIngestionV2 + } catch { + Write-LogMessage -API 'AuditLogV2' -message 'Planner: ingestion stage failed' -sev Error -LogData (Get-CippException -Exception $_) + Write-Information ('AuditLogV2 planner (ingest) error: {0}' -f $_.Exception.Message) + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 new file mode 100644 index 0000000000000..7e89abe335f1c --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 @@ -0,0 +1,123 @@ +function Start-AuditLogSearchCreationV2 { + <# + .SYNOPSIS + V2 audit-log search creation timer. Plans non-overlapping 60-minute windows per tenant in the + AuditLogCoverage ledger and fans out creation only to tenants that owe a window or have a + retry due. + .DESCRIPTION + Replaces Start-AuditLogSearchCreation. Tenant selection is unchanged (WebhookRules Webhookv2, + minus excluded, minus auditing-disabled). The key differences: + * Windows are clock-aligned, 60 minutes, NON-overlapping (tracked in AuditLogCoverage). + * Failed creations are recorded as Planned/Retry ledger rows, so they are retried (and gaps + backfilled) instead of being silently dropped. + * "First check what tenants need searches created" - the timer scans the ledger once and + only fans out per-tenant activities for tenants that owe a window or have a due retry. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param() + try { + # --- Tenant selection (same source as V1) --- + $ConfigTable = Get-CippTable -TableName 'WebhookRules' + $ConfigEntries = Get-CIPPAzDataTableEntity @ConfigTable -Filter "PartitionKey eq 'Webhookv2'" | ForEach-Object { + $ConfigEntry = $_ + if (!$ConfigEntry.excludedTenants) { + $ConfigEntry | Add-Member -MemberType NoteProperty -Name 'excludedTenants' -Value @() -Force + } else { + $ConfigEntry.excludedTenants = $ConfigEntry.excludedTenants | ConvertFrom-Json + } + $ConfigEntry.Tenants = $ConfigEntry.Tenants | ConvertFrom-Json + $ConfigEntry | Add-Member -MemberType NoteProperty -Name 'ExpandedTenants' -Value (Expand-CIPPTenantGroups -TenantFilter ($ConfigEntry.Tenants)).value -Force + $ConfigEntry + } + if (($ConfigEntries | Measure-Object).Count -eq 0) { + Write-Information 'AuditLogV2: no webhook rules defined; nothing to create' + return + } + + $TenantList = Get-Tenants -IncludeErrors + + # Auditing-disabled skip set (reuse existing table + expiry semantics) + $AuditDisabledTable = Get-CIPPTable -TableName 'AuditLogDisabledTenants' + $NowUnix = [int64]([datetimeoffset]::UtcNow.ToUnixTimeSeconds()) + $AuditDisabledTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($DisabledRow in @(Get-CIPPAzDataTableEntity @AuditDisabledTable -Filter "PartitionKey eq 'AuditDisabledTenant'")) { + [int64]$ExpiresAtUnix = 0 + if ([int64]::TryParse([string]$DisabledRow.ExpiresAtUnix, [ref]$ExpiresAtUnix) -and $ExpiresAtUnix -gt $NowUnix) { + [void]$AuditDisabledTenants.Add([string]$DisabledRow.RowKey) + } + } + + $InScope = foreach ($Tenant in $TenantList) { + if ($AuditDisabledTenants.Contains($Tenant.defaultDomainName) -or $AuditDisabledTenants.Contains([string]$Tenant.customerId)) { continue } + $Match = $false + foreach ($ConfigEntry in $ConfigEntries) { + if ($ConfigEntry.excludedTenants.value -contains $Tenant.defaultDomainName) { continue } + if ($ConfigEntry.ExpandedTenants -contains $Tenant.defaultDomainName -or $ConfigEntry.ExpandedTenants -contains 'AllTenants') { $Match = $true; break } + } + if ($Match) { $Tenant } + } + $InScope = @($InScope) + if ($InScope.Count -eq 0) { + Write-Information 'AuditLogV2: no in-scope tenants' + return + } + + # --- Scan ledger once, group by tenant --- + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + # Cover the reconciliation horizon (48h) plus slack so the fan-out check sees existing recon rows. + $HorizonIso = (Get-Date).AddHours(-50).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $AllRows = Get-CIPPAzDataTableEntity @Ledger -Filter "Timestamp ge datetime'$HorizonIso'" + $ByTenant = @{} + foreach ($Row in $AllRows) { + if (-not $ByTenant.ContainsKey($Row.PartitionKey)) { $ByTenant[$Row.PartitionKey] = [System.Collections.Generic.List[object]]::new() } + $ByTenant[$Row.PartitionKey].Add($Row) + } + + $Now = (Get-Date).ToUniversalTime() + $Batch = foreach ($Tenant in $InScope) { + $Rows = if ($ByTenant.ContainsKey($Tenant.defaultDomainName)) { $ByTenant[$Tenant.defaultDomainName] } else { @() } + $Owed = Get-CippAuditLogPlannedWindows -ExistingRows $Rows -Now $Now + $OwedRecon = Get-CippAuditLogReconciliationWindows -ExistingRows $Rows -Now $Now + $DuePlanned = @($Rows | Where-Object { $_.State -eq 'Planned' -and (-not $_.NextAttemptUtc -or ([datetimeoffset]$_.NextAttemptUtc).UtcDateTime -le $Now) }) + if (($Owed.Count -gt 0) -or ($OwedRecon.Count -gt 0) -or ($DuePlanned.Count -gt 0)) { + [PSCustomObject]@{ + FunctionName = 'AuditLogSearchCreationV2' + TenantFilter = $Tenant.defaultDomainName + TenantId = [string]$Tenant.customerId + } + } + } + $Batch = @($Batch) + + if ($Batch.Count -gt 0) { + Write-Information "AuditLogV2: $($Batch.Count) tenant(s) need search creation" + if ($PSCmdlet.ShouldProcess('Start-AuditLogSearchCreationV2', 'Create audit log searches')) { + $InputObject = [PSCustomObject]@{ + OrchestratorName = 'AuditLogSearchCreationV2' + Batch = @($Batch) + SkipLog = $true + } + Start-CIPPOrchestrator -InputObject $InputObject + } + } else { + Write-Information 'AuditLogV2: no tenants need searches this run' + } + + # --- Best-effort retention: drop ledger rows older than 7 days (all states; active windows are < 26h old) --- + try { + $CutoffIso = (Get-Date).AddDays(-7).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $Stale = @(Get-CIPPAzDataTableEntity @Ledger -Filter "Timestamp le datetime'$CutoffIso'" -Property PartitionKey, RowKey) + if ($Stale.Count -gt 0) { + Remove-AzDataTableEntity @Ledger -Entity $Stale -Force + Write-Information "AuditLogV2: cleaned $($Stale.Count) stale ledger row(s)" + } + } catch { + Write-Information "AuditLogV2: ledger cleanup skipped - $($_.Exception.Message)" + } + } catch { + Write-LogMessage -API 'AuditLogV2' -message 'Error creating audit log searches (V2)' -sev Error -LogData (Get-CippException -Exception $_) + Write-Information ('AuditLogV2 create error {0} line {1} - {2}' -f $_.InvocationInfo.ScriptName, $_.InvocationInfo.ScriptLineNumber, $_.Exception.Message) + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPDBTestsRun.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPDBTestsRun.ps1 index 4e08a43c16c19..944cd07190f38 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPDBTestsRun.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPDBTestsRun.ps1 @@ -15,7 +15,12 @@ function Start-CIPPDBTestsRun { [string]$TenantFilter = 'allTenants', [Parameter(Mandatory = $false)] - [switch]$Force + [switch]$Force, + + # Optional subset of suites to run (e.g. 'Custom'). Omit to run every suite. + # Suite names must match the ValidateSet in Invoke-CIPPTestCollection. + [Parameter(Mandatory = $false)] + [string[]]$Suites ) Write-Information "Starting tests run for tenant: $TenantFilter" @@ -64,11 +69,16 @@ function Start-CIPPDBTestsRun { # The tenants below were already filtered by data presence above, so we pass # SkipDbCheck=$true to avoid a redundant CountsOnly round-trip per tenant. $Batch = foreach ($Tenant in $AllTenantsList) { - @{ + $ListItem = @{ FunctionName = 'CIPPTestsList' TenantFilter = $Tenant SkipDbCheck = $true } + # Propagate an optional suite filter so Phase 1 emits only the requested suites. + if ($Suites) { + $ListItem.Suites = @($Suites) + } + $ListItem } Write-Information "Built batch of $($Batch.Count) tenant test list activities" diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 index 4484f851a9fe8..342cb828a1b17 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 @@ -48,6 +48,17 @@ function Start-CIPPStatsTimer { $driftStandardsCount = Get-CIPPStatsDriftStandardsCount $mobileEnrollment = Get-CIPPStatsMobileEnrollment + # Feature flags + $FeatureFlags = @{} + Get-CIPPFeatureFlag | Select-Object -Property Id, Enabled | ForEach-Object { + $FeatureFlags[$_.Id] = $_.Enabled + } + + # SSO migration status + $MigrationTable = Get-CIPPTable -tablename 'SSOMigration' + $MigrationConfig = Get-CIPPAzDataTableEntity @MigrationTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'MigrationConfig'" -ErrorAction SilentlyContinue + $MigrationStatus = $MigrationConfig.Status + $SendingObject = [PSCustomObject]@{ rgid = $env:WEBSITE_SITE_NAME SetupComplete = $SetupComplete @@ -77,6 +88,10 @@ function Start-CIPPStatsTimer { PWPush = $RawExt.PWPush.Enabled CFZTNA = $RawExt.CFZTNA.Enabled GitHub = $RawExt.GitHub.Enabled + BestPracticeAnalyser = $FeatureFlags.BestPracticeAnalyser + SuperAdminNG = $FeatureFlags.SuperAdminNG + MCPServer = $FeatureFlags.MCPServer + SSOMigrationStatus = $MigrationStatus } | ConvertTo-Json try { diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 index 037203bb99520..77a45fc69813e 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 @@ -23,7 +23,7 @@ function Start-UpdateTokensTimer { Write-LogMessage -API 'Update Tokens' -message 'Could not update refresh token. Will try again in 7 days.' -sev 'CRITICAL' } } else { - $KV = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $KV = Get-CippKeyVaultName if ($Refreshtoken) { Set-CippKeyVaultSecret -VaultName $KV -Name 'RefreshToken' -SecretValue (ConvertTo-SecureString -String $Refreshtoken -AsPlainText -Force) } else { @@ -42,6 +42,7 @@ function Start-UpdateTokensTimer { $AppRegistration = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$AppId')?`$select=id,passwordCredentials,servicePrincipalLockConfiguration" -NoAuthCheck $true -AsApp $true -ErrorAction Stop # sort by latest expiration date and get the first one $LastPasswordCredential = $AppRegistration.passwordCredentials | Sort-Object -Property endDateTime -Descending | Select-Object -First 1 + $PasswordCredentials = $AppRegistration.passwordCredentials | Sort-Object -Property endDateTime -Descending try { $AppPolicyStatus = Update-AppManagementPolicy @@ -60,24 +61,39 @@ function Start-UpdateTokensTimer { } if ($AppSecret) { - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $Table = Get-CIPPTable -tablename 'DevSecrets' - $Secret = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" - $Secret.ApplicationSecret = $AppSecret.secretText - Add-AzDataTableEntity @Table -Entity $Secret -Force - } else { - Set-CippKeyVaultSecret -VaultName $KV -Name 'ApplicationSecret' -SecretValue (ConvertTo-SecureString -String $AppSecret.secretText -AsPlainText -Force) + try { + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $Table = Get-CIPPTable -tablename 'DevSecrets' + $Secret = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" + $Secret.ApplicationSecret = $AppSecret.secretText + Add-AzDataTableEntity @Table -Entity $Secret -Force + } else { + Set-CippKeyVaultSecret -VaultName $KV -Name 'ApplicationSecret' -SecretValue (ConvertTo-SecureString -String $AppSecret.secretText -AsPlainText -Force) + } + Write-LogMessage -API 'Update Tokens' -message "New application secret generated for $AppId. Expiration date: $($AppSecret.endDateTime)." -sev 'INFO' + } catch { + # Storing the new secret failed. It exists on the app registration but not where CIPP reads + # it, and as the newest credential it would suppress regeneration on the next run - leaving + # the stored secret permanently stale. Roll the new secret back off the app registration so + # state stays consistent and regeneration is retried on the next run. + Write-LogMessage -API 'Update Tokens' -message "Failed to store new application secret for $AppId. Rolling back the generated secret, see Log Data for details. Will try again in 7 days." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) + try { + New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)/removePassword" -Body "{`"keyId`":`"$($AppSecret.keyId)`"}" -NoAuthCheck $true -AsApp $true -ErrorAction Stop + Write-Information "Rolled back unstored application secret with keyId $($AppSecret.keyId)." + } catch { + Write-LogMessage -API 'Update Tokens' -message "Failed to roll back unstored application secret with keyId $($AppSecret.keyId) for $AppId, see Log Data for details." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) + } + $AppSecret = $null } - Write-LogMessage -API 'Update Tokens' -message "New application secret generated for $AppId. Expiration date: $($AppSecret.endDateTime)." -sev 'INFO' } # Clean up expired application secrets - $ExpiredSecrets = $PasswordCredentials.passwordCredentials | Where-Object { $_.endDateTime -lt (Get-Date).ToUniversalTime() } + $ExpiredSecrets = $PasswordCredentials | Where-Object { $_.endDateTime -lt (Get-Date).ToUniversalTime() } if ($ExpiredSecrets.Count -gt 0) { Write-Information "Found $($ExpiredSecrets.Count) expired application secrets for $AppId. Removing them." foreach ($Secret in $ExpiredSecrets) { try { - New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/applications/$($PasswordCredentials.id)/removePassword" -Body "{`"keyId`":`"$($Secret.keyId)`"}" -NoAuthCheck $true -AsApp $true -ErrorAction Stop + New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)/removePassword" -Body "{`"keyId`":`"$($Secret.keyId)`"}" -NoAuthCheck $true -AsApp $true -ErrorAction Stop Write-Information "Removed expired application secret with keyId $($Secret.keyId)." } catch { Write-LogMessage -API 'Update Tokens' -message "Error removing expired application secret with keyId $($Secret.keyId), see Log Data for details." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) @@ -106,6 +122,18 @@ function Start-UpdateTokensTimer { Write-LogMessage -API 'Update Tokens' -message 'Error updating application secret, will try again in 7 days' -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) } + # Create or renew the SAM certificate when missing or within 30 days of expiry + try { + $CertResult = Update-CIPPSAMCertificate -ErrorAction Stop + if ($CertResult.Renewed) { + Write-Information "SAM certificate renewed. Thumbprint: $($CertResult.Thumbprint), expires: $($CertResult.NotAfter), storage mode: $($CertResult.StorageMode)" + } + } catch { + Write-Warning "Error updating SAM certificate $($_.Exception.Message)." + Write-Information ($_.InvocationInfo.PositionMessage) + Write-LogMessage -API 'Update Tokens' -message 'Error updating SAM certificate, will try again in 7 days' -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) + } + # Get new refresh token for each direct added tenant $TenantList = Get-Tenants -IncludeAll | Where-Object { $_.Excluded -eq $false -and $_.delegatedPrivilegeStatus -eq 'directTenant' } if ($TenantList.Count -eq 0) { diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 index 435d3a3d4a017..89710f3e8bf37 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 @@ -20,8 +20,6 @@ function Start-UserSyncTimer { $ApiName = 'UserSync' try { - Write-LogMessage -API $ApiName -tenant 'none' -message 'Starting user sync from partner tenant.' -sev Info - # Load the role-to-group mappings $AccessGroupsTable = Get-CippTable -TableName AccessRoleGroups $AccessGroups = @(Get-CIPPAzDataTableEntity @AccessGroupsTable -Filter "PartitionKey eq 'AccessRoleGroups'") @@ -69,12 +67,6 @@ function Start-UserSyncTimer { } } - if ($UserRoleMap.Count -eq 0 -and $RoleGroupIds.Count -gt 0) { - Write-LogMessage -API $ApiName -tenant 'none' -message 'No users found in any role groups.' -sev Info - } elseif ($RoleGroupIds.Count -eq 0) { - Write-LogMessage -API $ApiName -tenant 'none' -message 'No Entra groups mapped to roles — will clean up any stale auto-provisioned users.' -sev Info - } - # Load existing allowedUsers table $UsersTable = Get-CippTable -tablename 'allowedUsers' $ExistingUsers = @(Get-CIPPAzDataTableEntity @UsersTable | Where-Object { -not $_.RowKey.StartsWith('_') }) @@ -91,7 +83,6 @@ function Start-UserSyncTimer { } $Now = (Get-Date).ToUniversalTime().ToString('o') - $UpsertCount = 0 $RemoveCount = 0 $EntitiesToUpsert = [System.Collections.Generic.List[object]]::new() $EntitiesToRemove = [System.Collections.Generic.List[object]]::new() @@ -134,7 +125,6 @@ function Start-UserSyncTimer { } $EntitiesToUpsert.Add($Entity) - $UpsertCount++ } # Reconcile existing users that are NOT in any mapped role group @@ -205,8 +195,22 @@ function Start-UserSyncTimer { } } - # Apply upserts first (write canonical rows), then removals (drop duplicates/stale rows) + # Apply upserts first (write canonical rows), then removals (drop duplicates/stale rows). + # Only count an upsert as a change when the role data actually differs from the + # existing canonical row — LastSync alone changing every run isn't a real change. + $ChangedCount = 0 foreach ($Entity in $EntitiesToUpsert) { + $Canonical = $null + if ($ExistingLookup.ContainsKey($Entity.RowKey)) { + $Canonical = $ExistingLookup[$Entity.RowKey] | Where-Object { $_.RowKey -ceq $Entity.RowKey } | Select-Object -First 1 + } + if (-not $Canonical -or + $Canonical.Roles -ne $Entity.Roles -or + $Canonical.AutoRoles -ne $Entity.AutoRoles -or + $Canonical.ManualRoles -ne $Entity.ManualRoles -or + $Canonical.Source -ne $Entity.Source) { + $ChangedCount++ + } Add-CIPPAzDataTableEntity @UsersTable -Entity $Entity -Force } foreach ($Entity in $EntitiesToRemove) { @@ -217,7 +221,10 @@ function Start-UserSyncTimer { # Invalidate CRAFT's in-memory user cache so changes apply try { [Craft.Services.AuthBridge]::InvalidateUsers() } catch {} - Write-LogMessage -API $ApiName -tenant 'none' -message "User sync completed: $UpsertCount users synced, $RemoveCount duplicate/stale rows removed." -sev Info + # Only log when something actually changed — no noise on steady-state runs. + if ($ChangedCount -gt 0 -or $RemoveCount -gt 0) { + Write-LogMessage -API $ApiName -tenant 'none' -message "User sync completed: $ChangedCount users added/updated, $RemoveCount duplicate/stale rows removed." -sev Info + } } catch { $ErrorData = Get-CippException -Exception $_ diff --git a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 index 0c59d0696f0d8..240e77e0aec6d 100644 --- a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 +++ b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 @@ -228,7 +228,10 @@ function Get-CIPPTenantAlignment { $TagValue = if ($Tag.value) { $Tag.value } else { $Tag } $TagTemplate = if ($TagValue -and $TemplatesByPackage.ContainsKey($TagValue)) { $TemplatesByPackage[$TagValue] } else { @() } $TagTemplate | ForEach-Object { - $TagStandardId = "standards.IntuneTemplate.$($_.GUID)" + # RowKey, not the GUID column: the standards engine deploys tag members with + # TemplateList.value = RowKey and writes the compare row under that id, so the + # expected id must match it. The GUID column can be missing or stale. + $TagStandardId = "standards.IntuneTemplate.$($_.RowKey)" [PSCustomObject]@{ StandardId = $TagStandardId ReportingEnabled = $IntuneReportingEnabled @@ -260,7 +263,9 @@ function Get-CIPPTenantAlignment { $TagValue = if ($Tag.value) { $Tag.value } else { $Tag } $TagTemplate = if ($CATemplatesByPackage.ContainsKey($TagValue)) { $CATemplatesByPackage[$TagValue] } else { @() } $TagTemplate | ForEach-Object { - $TagStandardId = "standards.ConditionalAccessTemplate.$($_.GUID)" + # RowKey, not the GUID column - must match the id the standards engine + # deploys with and writes the compare row under (see Intune block above) + $TagStandardId = "standards.ConditionalAccessTemplate.$($_.RowKey)" [PSCustomObject]@{ StandardId = $TagStandardId ReportingEnabled = $CAReportingEnabled @@ -270,6 +275,21 @@ function Get-CIPPTenantAlignment { } } } + # Handle Reusable Settings templates — TemplateList is multi-select, one key per template id + elseif ($StandardKey -eq 'ReusableSettingsTemplate' -and $StandardConfig) { + foreach ($RSTemplate in @($StandardConfig)) { + $RSActions = if ($RSTemplate.action) { $RSTemplate.action } else { @() } + $RSReportingEnabled = ($RSActions | Where-Object { $_.value -and ($_.value.ToLower() -eq 'report' -or $_.value.ToLower() -eq 'remediate') }).Count -gt 0 + foreach ($RSTemplateId in @($RSTemplate.TemplateList.value)) { + if ($RSTemplateId) { + [PSCustomObject]@{ + StandardId = "standards.ReusableSettingsTemplate.$RSTemplateId" + ReportingEnabled = $RSReportingEnabled + } + } + } + } + } # Handle QuarantineTemplate — each policy is keyed by hex-encoded display name elseif ($StandardKey -eq 'QuarantineTemplate' -and $StandardConfig -is [array]) { foreach ($QTemplate in $StandardConfig) { diff --git a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 index cc70d3e0a5b5e..348d103e911a1 100644 --- a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 @@ -21,12 +21,45 @@ function Get-CIPPAuthentication { } Write-Host "Got secrets from dev storage. ApplicationID: $env:ApplicationID" } else { - $keyvaultname = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $keyvaultname = Get-CippKeyVaultName $Variables | ForEach-Object { Set-Item -Path env:$_ -Value (Get-CippKeyVaultSecret -VaultName $keyvaultname -Name $_ -AsPlainText -ErrorAction Stop) -Force } } + # Set before certificate handling: Update-CIPPSAMCertificate goes through + # Get-GraphToken, which re-enters this function when SetFromProfile is unset $env:SetFromProfile = $true + + # Preload the SAM certificate PFX alongside the other credentials, provisioning it + # when it does not exist yet. Non-fatal: auth must succeed even when certificate + # handling fails; the weekly token update retries provisioning. + try { + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + if ($Secret.SAMCertificate) { + $env:SAMCertificate = $Secret.SAMCertificate + } + } else { + try { + $SAMCertificate = Get-CippKeyVaultSecret -VaultName $keyvaultname -Name 'SAMCertificate' -AsPlainText -ErrorAction Stop + if ($SAMCertificate) { + $env:SAMCertificate = $SAMCertificate + } + } catch { + Write-Information "SAM certificate not found in storage: $($_.Exception.Message)" + } + } + + if (-not $env:SAMCertificate) { + # First run on this instance: provision the certificate now. + # Set-CIPPSAMCertificate refreshes $env:SAMCertificate on success. + Write-Information 'No SAM certificate found, provisioning one now' + $CertResult = Update-CIPPSAMCertificate -ErrorAction Stop + Write-LogMessage -message "Provisioned SAM certificate during authentication load. Thumbprint: $($CertResult.Thumbprint), storage mode: $($CertResult.StorageMode)" -Sev 'Info' -API 'CIPP Authentication' + } + } catch { + Write-LogMessage -message 'Could not preload or provision the SAM certificate. It will be retried by the weekly token update.' -Sev 'Warning' -API 'CIPP Authentication' -LogData (Get-CippException -Exception $_) + } + Write-LogMessage -message 'Reloaded authentication data from KeyVault' -Sev 'debug' -API 'CIPP Authentication' return $true diff --git a/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 b/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 index 01def39a8b870..da15f05e62a8b 100644 --- a/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 @@ -7,9 +7,11 @@ function Get-CIPPAzDataTableEntity { $First, $Skip, $Sort, - $Count + $Count, + [int]$MaxRetries = 3 ) + $PSBoundParameters['MaxRetries'] = $MaxRetries $Results = Get-AzDataTableEntity @PSBoundParameters $mergedResults = @{} $rootEntities = @{} # Keyed by "$PartitionKey|$RowKey" diff --git a/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 new file mode 100644 index 0000000000000..117127cfdac2a --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 @@ -0,0 +1,178 @@ +function Get-CIPPCVEReport { + <# + .SYNOPSIS + Generates a CVE report from the CIPP Reporting database + + .DESCRIPTION + Retrieves Defender CVE data for a tenant from the reporting database + Optimized for high-performance cross-referencing and memory efficiency. + + .PARAMETER TenantFilter + The tenant to generate the report for, or 'AllTenants' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + try { + # Retrieve Exceptions from Exception database + $CveExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' + $AllExceptions = Get-CIPPAzDataTableEntity @CveExceptionsTable + $ExceptionsByCve = @{} + + # Retrieve CVEs from database + $RawCveData = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'DefenderCVEs' | Where-Object { $_.RowKey -ne 'DefenderCVEs-Count' } + $AllCachedCves = $RawCveData.Data | ConvertFrom-Json + + # Filter results by Tenant + $RawCveItems = [System.Collections.Generic.List[object]]::new() + + if ($TenantFilter -eq 'AllTenants') { + # Validate against active tenants to ensure we don't return orphaned data + $TenantList = Get-Tenants -IncludeErrors + foreach ($Item in $AllCachedCves) { + if ($TenantList.defaultDomainName -contains $Item.customerId) { + [void]$RawCveItems.Add($Item) + } + } + } else { + $TenantList = Get-Tenants | Where-Object defaultDomainName -eq $TenantFilter + foreach ($Item in $AllCachedCves) { + if ($Item.customerId -eq $TenantFilter) { + [void]$RawCveItems.Add($Item) + } + } + } + + if ($RawCveItems.Count -eq 0) { + return @() + } + + # Build filtered exception items + foreach ($Ex in $AllExceptions) { + if ($TenantList.defaultDomainName -contains $Ex.customerId -or $Ex.customerId -eq 'ALL'){ + if (-not $ExceptionsByCve.ContainsKey($Ex.cveId)) { + $ExceptionsByCve[$Ex.cveId] = [System.Collections.Generic.List[object]]::new() + } + + [void]$ExceptionsByCve[$Ex.cveId].Add([PSCustomObject]@{ + cveId = $Ex.cveId + customerId = $Ex.customerId + exceptionType = $Ex.exceptionType + exceptionSource = $Ex.exceptionSource + exceptionComment = $Ex.exceptionComment + exceptionCreatedBy = $Ex.exceptionCreatedBy + exceptionDate = $Ex.exceptionReadableDate + exceptionExpiry = $Ex.exceptionExpiry + }) + } + } + + # Process raw CVE items + $CveMasterTable = @{} + + foreach ($Item in $RawCveItems) { + $CveId = $Item.PartitionKey + + if (-not $CveMasterTable.ContainsKey($CveId)) { + $CveMasterTable[$CveId] = @{ + cveId = $CveId + vulnerabilitySeverityLevel = $Item.vulnerabilitySeverityLevel + exploitabilityLevel = $Item.exploitabilityLevel + softwareName = $Item.softwareName + softwareVendor = $Item.softwareVendor + softwareVersion = $Item.softwareVersion + lastUpdated = $Item.lastUpdated + TotalDeviceCount = 0 + AffectedTenantsList = [System.Collections.Generic.List[object]]::new() + AffectedDevicesList = [System.Collections.Generic.List[object]]::new() + DiskPathList = [System.Collections.Generic.List[object]]::new() + RegistryPathList = [System.Collections.Generic.List[object]]::new() + ExceptionMatchCount = 0 + TotalTenantGroupCount = 0 + ExceptionSources = [System.Collections.Generic.HashSet[string]]::new() + } + } + + $CveGroup = $CveMasterTable[$CveId] + $CveGroup.TotalTenantGroupCount++ + + [void]$CveGroup.AffectedTenantsList.Add(@{ customerId = $Item.customerId }) + + # Unpack the device JSON details from the row + if ($Item.deviceDetailsJson) { + $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique + foreach ($Dev in $Devices) { + [void]$CveGroup.AffectedDevicesList.Add(@{ deviceName = $Dev.deviceName }) + if($Dev.registryPaths){[void]$CveGroup.RegistryPathList.Add(@{ deviceName = $Dev.deviceName + registryPaths = $Dev.registryPaths })} + if($Dev.diskPaths){[void]$CveGroup.DiskPathList.Add(@{ deviceName = $Dev.deviceName + diskPaths = $Dev.diskPaths })} + $CveGroup.TotalDeviceCount ++ + } + } + } + + # Combine filtered results + $SortedCves = [System.Collections.Generic.List[PSCustomObject]]::new() + + foreach ($CveKey in $CveMasterTable.Keys) { + $Target = $CveMasterTable[$CveKey] + $ExceptionStatus = 'None' + $HasException = $false + $Exceptions = @{} + $ExceptionType = '' + $ExceptionComment = '' + $ExceptionCreatedBy = '' + $ExceptionDate = '' + $ExceptionExpiry = '' + + if ($ExceptionsByCve.ContainsKey($CveKey)){ + $Exceptions = @($ExceptionsByCve[$CveKey]) + $HasException = $true + $ExceptionStatus = if ($Exceptions.customerId -contains "ALL") { "All" } else { "Partial" } + $ExceptionType = @{ customerId = $Exceptions.customerId + exceptionType = $Exceptions.exceptionType } + $ExceptionComment = @{ customerId = $Exceptions.customerId + exceptionComment = $Exceptions.exceptionComment } + $ExceptionCreatedBy = @{ customerId = $Exceptions.customerId + exceptionCreatedBy = $Exceptions.exceptionCreatedBy } + $ExceptionDate = @{ customerId = $Exceptions.customerId + exceptionDate = $Exceptions.exceptionDate } + $ExceptionExpiry = @{ customerId = $Exceptions.customerId + exceptionExpiry = $Exceptions.exceptionExpiry } + } + + [void]$SortedCves.Add([PSCustomObject]@{ + cveId = $Target.cveId + vulnerabilitySeverityLevel = $Target.vulnerabilitySeverityLevel + exploitabilityLevel = $Target.exploitabilityLevel + softwareName = $Target.softwareName + softwareVendor = $Target.softwareVendor + softwareVersion = $Target.softwareVersion + deviceCount = $Target.TotalDeviceCount + tenantCount = $Target.TotalTenantGroupCount + registryPaths = $Target.RegistryPathList + diskPaths = $Target.DiskPathList + exceptionStatus = $ExceptionStatus + hasException = $HasException + affectedTenants = $Target.AffectedTenantsList + affectedDevices = $Target.AffectedDevicesList + exceptionType = $ExceptionType + exceptionComment = $ExceptionComment + exceptionCreatedBy = $ExceptionCreatedBy + exceptionDate = $ExceptionDate + exceptionExpiry = $ExceptionExpiry + cacheTimeStamp = $Target.lastUpdated + }) + } + + return $SortedCves | Sort-Object -Property cveId + + } catch { + Write-LogMessage -API 'CVEReport' -tenant $TenantFilter -message "Failed to generate CVE report: $($_.Exception.Message)" -sev Error + throw + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index f877a2a4af800..0a5fc8b318b1a 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -49,13 +49,18 @@ function Get-CIPPDrift { $IntuneTemplatesByGuid = @{} $AllIntuneTemplates = foreach ($RawTemplate in $RawIntuneTemplates) { try { - $JSONData = $RawTemplate.JSON | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue - $data = $JSONData.RAWJson | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue + $JSONData = $RawTemplate.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue + $data = $JSONData.RAWJson | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue $data | Add-Member -NotePropertyName 'displayName' -NotePropertyValue $JSONData.Displayname -Force $data | Add-Member -NotePropertyName 'description' -NotePropertyValue $JSONData.Description -Force $data | Add-Member -NotePropertyName 'Type' -NotePropertyValue $JSONData.Type -Force $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $RawTemplate.RowKey -Force $IntuneTemplatesByGuid[$RawTemplate.RowKey] = $data + # Built-in templates are seeded with RowKey = '.IntuneTemplate.json'; also index + # by the bare guid so display-name lookups that extract the guid from a standard key hit + if ($RawTemplate.RowKey -match '^([0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})\.' -and -not $IntuneTemplatesByGuid.ContainsKey($Matches[1])) { + $IntuneTemplatesByGuid[$Matches[1]] = $data + } $data } catch { # Skip invalid templates @@ -80,12 +85,32 @@ function Get-CIPPDrift { $data = $RawTemplate.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $RawTemplate.RowKey -Force $CATemplatesByGuid[$RawTemplate.RowKey] = $data + # Built-in templates are seeded with RowKey = '.CATemplate.json'; also index + # by the bare guid so display-name lookups that extract the guid from a standard key hit + if ($RawTemplate.RowKey -match '^([0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})\.' -and -not $CATemplatesByGuid.ContainsKey($Matches[1])) { + $CATemplatesByGuid[$Matches[1]] = $data + } $data } catch { # Skip invalid templates } } + # Load reusable settings templates for display name lookup + $RawReusableSettingTemplates = Get-CIPPAzDataTableEntity @IntuneTable -Filter "PartitionKey eq 'IntuneReusableSettingTemplate'" + $ReusableSettingTemplatesByGuid = @{} + foreach ($RawTemplate in $RawReusableSettingTemplates) { + try { + $data = $RawTemplate.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue + $ReusableSettingTemplatesByGuid[$RawTemplate.RowKey] = [PSCustomObject]@{ + displayName = $data.DisplayName ?? $data.displayName ?? $RawTemplate.RowKey + description = $data.Description ?? $data.description + } + } catch { + # Skip invalid templates + } + } + try { $AlignmentData = Get-CIPPTenantAlignment -TenantFilter $TenantFilter -TemplateId $TemplateId | Where-Object -Property standardType -EQ 'drift' @@ -108,6 +133,13 @@ function Get-CIPPDrift { } $Results = [System.Collections.Generic.List[object]]::new() + # Tracks every StandardName that is still valid (live deviations + standards present in the + # template, whether assigned directly or via a package) so stale tenantDrift rows can be pruned. + $ValidDriftKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + # Policy rows are only pruned when the matching Graph collection succeeded, so a transient + # failure or missing license never wipes accepted/customer-specific statuses. + $IntunePoliciesCollected = $false + $CAPoliciesCollected = $false foreach ($Alignment in $AlignmentData) { # Initialize deviation collections $StandardsDeviations = [System.Collections.Generic.List[object]]::new() @@ -145,6 +177,15 @@ function Get-CIPPDrift { $standardDescription = $Template.description } } + # Handle Reusable Settings templates + if ($ComparisonItem.StandardName -like 'standards.ReusableSettingsTemplate.*') { + $CompareGuid = $ComparisonItem.StandardName.Substring('standards.ReusableSettingsTemplate.'.Length) + if ($CompareGuid -and $ReusableSettingTemplatesByGuid.ContainsKey($CompareGuid)) { + $Template = $ReusableSettingTemplatesByGuid[$CompareGuid] + $displayName = "Reusable Setting - $($Template.displayName)" + $standardDescription = $Template.description + } + } # Handle QuarantineTemplate — suffix is hex-encoded display name, decode it if ($ComparisonItem.StandardName -like 'standards.QuarantineTemplate.*') { $HexEncodedName = $ComparisonItem.StandardName.Substring('standards.QuarantineTemplate.'.Length) @@ -158,6 +199,7 @@ function Get-CIPPDrift { } $reason = if ($ExistingDriftStates.ContainsKey($ComparisonItem.StandardName)) { $ExistingDriftStates[$ComparisonItem.StandardName].Reason } $User = if ($ExistingDriftStates.ContainsKey($ComparisonItem.StandardName)) { $ExistingDriftStates[$ComparisonItem.StandardName].User } + $IsLicenseMissing = $ComparisonItem.ComplianceStatus -eq 'License Missing' $StandardsDeviations.Add([PSCustomObject]@{ standardName = $ComparisonItem.StandardName standardDisplayName = $displayName @@ -167,7 +209,8 @@ function Get-CIPPDrift { Status = $Status Reason = $reason lastChangedByUser = $User - LicenseAvailable = $ComparisonItem.LicenseAvailable + ComplianceStatus = $ComparisonItem.ComplianceStatus + LicenseAvailable = if ($IsLicenseMissing) { $false } else { $ComparisonItem.LicenseAvailable } CurrentValue = $ComparisonItem.CurrentValue ExpectedValue = $ComparisonItem.ExpectedValue }) @@ -241,6 +284,7 @@ function Get-CIPPDrift { } } } + $IntunePoliciesCollected = $true } catch { Write-Warning "Failed to get Intune policies: $($_.Exception.Message)" } @@ -257,6 +301,7 @@ function Get-CIPPDrift { ) $CAGraphRequest = New-GraphBulkRequest -Requests $CARequests -tenantid $TenantFilter -asapp $true $TenantCAPolicies = ($CAGraphRequest | Where-Object { $_.id -eq 'policies' }).body.value + $CAPoliciesCollected = $true } catch { Write-Warning "Failed to get Conditional Access policies: $($_.Exception.Message)" $TenantCAPolicies = @() @@ -290,9 +335,9 @@ function Get-CIPPDrift { $CATemplateIds.Add($Template.TemplateList.value) } if ($Template.'TemplateList-Tags') { - $TagValue = if ($Template.'TemplateList-Tags'.value) { $Template.'TemplateList-Tags'.value } else { $null } - if ($TagValue) { - $ResolvedCATagTemplates = if ($CATemplatesByPackage.ContainsKey($TagValue)) { $CATemplatesByPackage[$TagValue] } else { @() } + foreach ($Tag in $Template.'TemplateList-Tags') { + $TagValue = if ($Tag.value) { $Tag.value } else { $Tag } + $ResolvedCATagTemplates = if ($TagValue -and $CATemplatesByPackage.ContainsKey($TagValue)) { $CATemplatesByPackage[$TagValue] } else { @() } foreach ($ResolvedTemplate in $ResolvedCATagTemplates) { if ($ResolvedTemplate.RowKey -and $ResolvedTemplate.RowKey -notin $CATemplateIds) { $CATemplateIds.Add($ResolvedTemplate.RowKey) @@ -406,6 +451,17 @@ function Get-CIPPDrift { $AllDeviations.AddRange($StandardsDeviations) $AllDeviations.AddRange($PolicyDeviations) + # Record which drift keys are still valid: live deviations and every standard in the + # template (compliant ones keep their row so an accepted status survives re-drift). + foreach ($Deviation in $AllDeviations) { + $KeyName = [string]$Deviation.standardName + if (-not [string]::IsNullOrWhiteSpace($KeyName)) { $null = $ValidDriftKeys.Add($KeyName) } + } + foreach ($ComparisonItem in $Alignment.ComparisonDetails) { + $KeyName = [string]$ComparisonItem.StandardName + if (-not [string]::IsNullOrWhiteSpace($KeyName)) { $null = $ValidDriftKeys.Add($KeyName) } + } + # Persist newly detected deviations to the tenantDrift table so the summary page can count them $NewDriftEntities = [System.Collections.Generic.List[object]]::new() foreach ($Deviation in $AllDeviations) { @@ -438,14 +494,19 @@ function Get-CIPPDrift { } } + # License-missing standards are excluded from the deviation buckets so the counts match + # the alignment score, which tracks them separately; they surface via licenseMissingDeviations. + $LicenseMissingDeviations = $AllDeviations | Where-Object { $_.ComplianceStatus -eq 'License Missing' } + $CountableDeviations = $AllDeviations | Where-Object { $_.ComplianceStatus -ne 'License Missing' } + # Filter deviations by status for counting - $NewDeviations = $AllDeviations | Where-Object { $_.Status -eq 'New' } - $AcceptedDeviations = $AllDeviations | Where-Object { $_.Status -eq 'Accepted' } - $DeniedDeviations = $AllDeviations | Where-Object { $_.Status -like 'Denied*' } - $CustomerSpecificDeviations = $AllDeviations | Where-Object { $_.Status -eq 'CustomerSpecific' } + $NewDeviations = $CountableDeviations | Where-Object { $_.Status -eq 'New' } + $AcceptedDeviations = $CountableDeviations | Where-Object { $_.Status -eq 'Accepted' } + $DeniedDeviations = $CountableDeviations | Where-Object { $_.Status -like 'Denied*' } + $CustomerSpecificDeviations = $CountableDeviations | Where-Object { $_.Status -eq 'CustomerSpecific' } # Current deviations are New + Denied (not accepted or customer specific) - $CurrentDeviations = $AllDeviations | Where-Object { $_.Status -in @('New', 'Denied') } + $CurrentDeviations = $CountableDeviations | Where-Object { $_.Status -in @('New', 'Denied') } $Result = [PSCustomObject]@{ tenantFilter = $TenantFilter @@ -457,11 +518,13 @@ function Get-CIPPDrift { deniedDeviationsCount = $DeniedDeviations.Count customerSpecificDeviationsCount = $CustomerSpecificDeviations.Count newDeviationsCount = $NewDeviations.Count + licenseMissingDeviationsCount = $LicenseMissingDeviations.Count alignedCount = $Alignment.CompliantStandards - $AcceptedDeviations.Count - $CustomerSpecificDeviations.Count currentDeviations = @($CurrentDeviations) acceptedDeviations = @($AcceptedDeviations) customerSpecificDeviations = @($CustomerSpecificDeviations) deniedDeviations = @($DeniedDeviations) + licenseMissingDeviations = @($LicenseMissingDeviations) allDeviations = @($AllDeviations) latestDataCollection = $Alignment.LatestDataCollection driftSettings = $AlignmentData @@ -470,6 +533,32 @@ function Get-CIPPDrift { $Results.Add($Result) } + # Prune stale tenantDrift rows so the alignment score only counts real deviations. + # A row goes stale when its policy was deleted/recreated in the tenant, or its template was + # removed from the drift standard (directly or via a package). Policy rows require a + # successful Graph collection this run before they are eligible for deletion. + $StaleDriftEntities = foreach ($Entity in $DriftEntities) { + $EntityName = [string]$Entity.StandardName + if ([string]::IsNullOrWhiteSpace($EntityName) -or $ValidDriftKeys.Contains($EntityName)) { continue } + if ($EntityName -like 'IntuneTemplates.*') { + if ($IntunePoliciesCollected) { $Entity } + } elseif ($EntityName -like 'ConditionalAccessTemplates.*') { + if ($CAPoliciesCollected) { $Entity } + } else { + $Entity + } + } + if ($StaleDriftEntities) { + try { + foreach ($StaleEntity in $StaleDriftEntities) { + Remove-AzDataTableEntity @DriftTable -Entity $StaleEntity + } + Write-Information "Removed $(@($StaleDriftEntities).Count) stale drift deviation entries for $TenantFilter" + } catch { + Write-Warning "Failed to remove stale drift deviation entries for $($TenantFilter): $($_.Exception.Message)" + } + } + return @($Results) } catch { diff --git a/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 index a53717c8bb595..d6f53a245e65e 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 @@ -65,7 +65,7 @@ function Get-CIPPIntuneApplicationReport { } '#microsoft.graph.exclusionGroupAssignmentTarget' { $groupName = ($Groups | Where-Object { $_.id -eq $target.groupId }).displayName - if ($groupName) { $AppExclude.Add($groupName) } + if ($groupName) { $AppExclude.Add("$groupName$intentSuffix") } } } } diff --git a/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 index 9d9143b631512..dfa8a52eec933 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 @@ -429,6 +429,55 @@ function Get-CIPPIntunePolicy { return $policies } } + 'Intents' { + $PlatformType = 'deviceManagement' + $TemplateTypeURL = 'intents' + + if ($DisplayName) { + $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter + $policy = $policies | Where-Object -Property displayName -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 + if ($policy) { + $settings = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($policy.id)')/settings" -tenantid $tenantFilter + $policyDetails = [PSCustomObject]@{ + displayName = $policy.displayName + description = $policy.description + templateId = $policy.templateId + settings = @($settings) + } + $policyJson = ConvertTo-Json -InputObject $policyDetails -Depth 100 -Compress + $policy | Add-Member -MemberType NoteProperty -Name 'cippconfiguration' -Value $policyJson -Force + } + return $policy + } elseif ($PolicyId) { + $policy = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$PolicyId')" -tenantid $tenantFilter + if ($policy) { + $settings = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$PolicyId')/settings" -tenantid $tenantFilter + $policyDetails = [PSCustomObject]@{ + displayName = $policy.displayName + description = $policy.description + templateId = $policy.templateId + settings = @($settings) + } + $policyJson = ConvertTo-Json -InputObject $policyDetails -Depth 100 -Compress + $policy | Add-Member -MemberType NoteProperty -Name 'cippconfiguration' -Value $policyJson -Force + } + return $policy + } else { + $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter + foreach ($policy in $policies) { + $settings = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($policy.id)')/settings" -tenantid $tenantFilter + $policyDetails = [PSCustomObject]@{ + displayName = $policy.displayName + description = $policy.description + templateId = $policy.templateId + settings = @($settings) + } + $policyJson = ConvertTo-Json -InputObject $policyDetails -Depth 100 -Compress + $policy | Add-Member -MemberType NoteProperty -Name 'cippconfiguration' -Value $policyJson -Force + } + return $policies + } + } default { return $null } diff --git a/Modules/CIPPCore/Public/Get-CIPPMSPAppInstallCommand.ps1 b/Modules/CIPPCore/Public/Get-CIPPMSPAppInstallCommand.ps1 new file mode 100644 index 0000000000000..86da343f15a58 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPMSPAppInstallCommand.ps1 @@ -0,0 +1,113 @@ +function Get-CIPPMSPAppInstallCommand { + <# + .SYNOPSIS + Builds the install/uninstall command lines for an MSP RMM app for a single tenant. + .DESCRIPTION + Shared by Invoke-AddMSPApp (manual/queue deploy) and New-CIPPIntuneAppDeployment + (the 'Deploy Intune Application Template' standard). Each parameter value may be: + - a flat string, optionally containing %CIPP variables% that resolve per-tenant + (Application Template shape), or + - an object keyed by tenant customerId (legacy per-tenant deploy shape). + Values are resolved for the tenant, run through Get-CIPPTextReplacement so any + %variables% are substituted with the tenant's value, then escaped with + ConvertTo-CIPPSafePwshArg before being placed on the command line. + .PARAMETER RmmName + The MSP tool identifier (datto, syncro, Huntress, automate, cwcommand, ninja, NCentral). + .PARAMETER Params + The params object from the app config / request body. + .PARAMETER Tenant + The tenant object, requires customerId and defaultDomainName. + .PARAMETER PackageName + Package name for ninja/NCentral installs (not stored under params). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$RmmName, + + $Params, + + [Parameter(Mandatory = $true)] + $Tenant, + + [string]$PackageName + ) + + $InstallParams = [PSCustomObject]$Params + + # Resolve a raw parameter value for this tenant: pick the per-tenant keyed value when the + # value is an object (legacy shape), otherwise use it as-is (template shape), then replace + # any %CIPP variables% using the tenant context. Returns the raw (unescaped) string. + function Resolve-MSPValue { + param($Value) + if ($null -eq $Value) { return '' } + if ($Value -is [string]) { + $Resolved = $Value + } elseif ($Value -is [System.Collections.IDictionary]) { + $Resolved = [string]$Value[$Tenant.customerId] + } elseif ($Value -is [pscustomobject]) { + $Resolved = [string]$Value.$($Tenant.customerId) + } else { + $Resolved = [string]$Value + } + if ($Resolved -match '%') { + $Resolved = Get-CIPPTextReplacement -TenantFilter $Tenant.defaultDomainName -Text $Resolved + } + return $Resolved + } + + $DetectionScriptContent = $null + + switch ($RmmName) { + 'datto' { + $DattoUrl = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.DattoURL) + $DattoGuid = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.DattoGUID) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -URL $DattoUrl -GUID $DattoGuid" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' + } + 'ninja' { + $NinjaPackage = ConvertTo-CIPPSafePwshArg -Value ([string]$PackageName) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -InstallParam $NinjaPackage" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' + } + 'Huntress' { + $HuntressOrgKey = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.Orgkey) + $HuntressAccountKey = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.AccountKey) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -OrgKey $HuntressOrgKey -acctkey $HuntressAccountKey" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Uninstall' + } + 'syncro' { + $SyncroUrl = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.ClientURL) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -URL $SyncroUrl" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' + } + 'NCentral' { + $NCentralPackage = ConvertTo-CIPPSafePwshArg -Value ([string]$PackageName) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -InstallParam $NCentralPackage" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' + } + 'automate' { + $ServerRaw = Resolve-MSPValue $InstallParams.Server + $AutomateServer = ConvertTo-CIPPSafePwshArg -Value $ServerRaw + $AutomateInstallerToken = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.InstallerToken) + $AutomateLocationId = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.LocationID) + $installCommandLine = "c:\windows\sysnative\windowspowershell\v1.0\powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Server $AutomateServer -InstallerToken $AutomateInstallerToken -LocationID $AutomateLocationId" + $uninstallCommandLine = "c:\windows\sysnative\windowspowershell\v1.0\powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1 -Server $AutomateServer" + $DetectionScriptContent = (Get-Content 'AddMSPApp\automate.detection.ps1' -Raw) -replace '##SERVER##', $ServerRaw + } + 'cwcommand' { + $CwClientUrl = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.ClientURL) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Url $CwClientUrl" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' + } + default { + throw "Unknown MSP app type '$RmmName'" + } + } + + return [PSCustomObject]@{ + InstallCommandLine = $installCommandLine + UninstallCommandLine = $uninstallCommandLine + DetectionScriptContent = $DetectionScriptContent + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSAMCertificate.ps1 b/Modules/CIPPCore/Public/Get-CIPPSAMCertificate.ps1 new file mode 100644 index 0000000000000..776d81380351e --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSAMCertificate.ps1 @@ -0,0 +1,98 @@ +function Get-CIPPSAMCertificate { + <# + .SYNOPSIS + Retrieves the SAM certificate from Key Vault or the DevSecrets table + + .DESCRIPTION + Loads the stored SAM certificate PFX and materializes it as an X509Certificate2 with + its private key, for use with Get-GraphTokenFromCert. The read path is storage-mode + agnostic: a Key Vault certificate's private key is exposed through the secrets endpoint + under the same name, so a single secret GET covers both the certificate and the + secret-fallback storage modes. Read-only - never creates or renews a certificate. + + .PARAMETER Name + Storage name of the certificate. Defaults to SAMCertificate. + + .PARAMETER VaultName + Name of the Key Vault. If not provided, derives via Get-CippKeyVaultName. + + .PARAMETER SkipCache + Bypass the in-memory certificate cache and fetch from storage. + + .EXAMPLE + $SAMCert = Get-CIPPSAMCertificate + Get-GraphTokenFromCert -TenantId $env:TenantID -AppId $env:ApplicationID -Certificate $SAMCert.Certificate + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string]$Name = 'SAMCertificate', + + [Parameter(Mandatory = $false)] + [string]$VaultName, + + [switch]$SkipCache + ) + + # Serve from cache when fresh (1 hour TTL) + if (-not $SkipCache -and $script:SAMCertificateCache.$Name -and $script:SAMCertificateCache.$Name.FetchedAt -gt (Get-Date).AddHours(-1)) { + return $script:SAMCertificateCache.$Name.Result + } + + if (-not $SkipCache -and $Name -eq 'SAMCertificate' -and $env:SAMCertificate) { + # Preloaded by Get-CIPPAuthentication at startup (and refreshed by Set-CIPPSAMCertificate) + $PfxBase64 = $env:SAMCertificate + } elseif ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $Table = Get-CIPPTable -tablename 'DevSecrets' + $Secret = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" + $PfxBase64 = $Secret.$Name + } else { + try { + $PfxBase64 = Get-CippKeyVaultSecret -VaultName $VaultName -Name $Name -AsPlainText -ErrorAction Stop + } catch { + # A missing secret means the certificate has not been created yet (bootstrap pending) + if ($_.Exception.Message -match 'SecretNotFound|404') { + return $null + } + throw + } + } + + if ([string]::IsNullOrEmpty($PfxBase64)) { + return $null + } + + $PfxBytes = [Convert]::FromBase64String($PfxBase64) + try { + # Ephemeral avoids writing key files to disk on the Linux consumption plan + $Certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + $PfxBytes, + [string]::Empty, + [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + ) + } catch { + # EphemeralKeySet is not supported on all platforms (e.g. macOS local dev) + $Certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + $PfxBytes, + [string]::Empty, + [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable + ) + } + + $Result = [PSCustomObject]@{ + Certificate = $Certificate + Thumbprint = $Certificate.Thumbprint + NotBefore = $Certificate.NotBefore.ToUniversalTime() + NotAfter = $Certificate.NotAfter.ToUniversalTime() + } + + if (-not $script:SAMCertificateCache) { + $script:SAMCertificateCache = [HashTable]::Synchronized(@{}) + } + $script:SAMCertificateCache.$Name = @{ + Result = $Result + FetchedAt = Get-Date + } + + return $Result +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSPODeletedSites.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPODeletedSites.ps1 new file mode 100644 index 0000000000000..696adeb0ec1d3 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSPODeletedSites.ps1 @@ -0,0 +1,60 @@ +function Get-CIPPSPODeletedSites { + <# + .SYNOPSIS + List deleted SharePoint sites (tenant recycle bin) via CSOM + + .DESCRIPTION + Retrieves the deleted site collections still restorable from the SharePoint tenant recycle + bin using the CSOM GetDeletedSitePropertiesFromSharePoint method, following the same paging + pattern as Get-CIPPSPOSite. + + .PARAMETER TenantFilter + Tenant to query + + .EXAMPLE + Get-CIPPSPODeletedSites -TenantFilter 'contoso.onmicrosoft.com' + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' } + + $AllSites = [System.Collections.Generic.List[object]]::new() + $StartIndex = $null + + do { + $StartIndexParameter = if ($null -ne $StartIndex) { + "$([System.Security.SecurityElement]::Escape($StartIndex))" + } else { + '' + } + $XML = @" +$StartIndexParameter +"@ + + $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + $CsomError = ($Results | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage + if ($CsomError) { throw $CsomError } + + $SiteCollection = $Results | Where-Object { $_._Child_Items_ } + if ($SiteCollection) { + foreach ($Site in $SiteCollection._Child_Items_) { + [void]$AllSites.Add($Site) + } + $StartIndex = $SiteCollection.NextStartIndexFromSharePoint + } else { + $StartIndex = $null + } + } while ($StartIndex) + + return $AllSites +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSPOExternalUsers.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPOExternalUsers.ps1 new file mode 100644 index 0000000000000..522f788fed179 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSPOExternalUsers.ps1 @@ -0,0 +1,56 @@ +function Get-CIPPSPOExternalUsers { + <# + .SYNOPSIS + List the SharePoint tenant external users store via CSOM + + .DESCRIPTION + Enumerates every external user SharePoint Online knows about (the store behind + Get-SPOExternalUser), using the CSOM Office365Tenant.GetExternalUsers method against the + admin endpoint. This includes legacy email-authenticated guests (urn:spo:guest) that have + no backing Entra object, and B2B guests whose Entra user may since have been deleted. + + .PARAMETER TenantFilter + Tenant to query + + .EXAMPLE + Get-CIPPSPOExternalUsers -TenantFilter 'contoso.onmicrosoft.com' + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' } + + $AllUsers = [System.Collections.Generic.List[object]]::new() + $Position = 0 + $PageSize = 50 + + do { + # Office365Tenant (TenantManagement) constructor -> GetExternalUsers(position, pageSize, filter, sortOrder) + $XML = @" +$Position$PageSize0 +"@ + + $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + $CsomError = ($Results | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage + if ($CsomError) { throw $CsomError } + + $ResultObject = $Results | Where-Object { $null -ne $_.TotalUserCount } | Select-Object -First 1 + $Batch = @($ResultObject.ExternalUserCollection._Child_Items_) + foreach ($User in $Batch) { + [void]$AllUsers.Add($User) + } + $Position = $ResultObject.UserCollectionPosition + # Continue while SPO reports more users beyond the current position. + } while ($Batch.Count -eq $PageSize -and $Position -ge 0 -and $AllUsers.Count -lt [int]$ResultObject.TotalUserCount) + + return $AllUsers +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 index 3f4a118c1f9ee..8f35bca98ef3a 100644 --- a/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 @@ -10,9 +10,16 @@ function Get-CIPPSPOSite { .PARAMETER TenantFilter Tenant to query + .PARAMETER SiteUrl + When provided, fetches the properties of this single site (CSOM GetSitePropertiesByUrl) + instead of enumerating every site in the tenant. + .EXAMPLE Get-CIPPSPOSite -TenantFilter 'contoso.onmicrosoft.com' + .EXAMPLE + Get-CIPPSPOSite -TenantFilter 'contoso.onmicrosoft.com' -SiteUrl 'https://contoso.sharepoint.com/sites/MySite' + .FUNCTIONALITY Internal @@ -20,12 +27,27 @@ function Get-CIPPSPOSite { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$SiteUrl ) $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter $AdminUrl = $SharePointInfo.AdminUrl + if ($SiteUrl) { + # Single-site fast path: Tenant Constructor -> GetSitePropertiesByUrl -> Query all properties + $XML = @" +$([System.Security.SecurityElement]::Escape($SiteUrl))true +"@ + $AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' } + $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + $Site = $Results | Where-Object { $_._ObjectType_ -match 'SiteProperties' } | Select-Object -First 1 + if (-not $Site) { + throw "Could not retrieve site properties for $SiteUrl" + } + return $Site + } + $XML = @' false '@ diff --git a/Modules/CIPPCore/Public/Get-CIPPSharedMailboxAccountEnabledReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPSharedMailboxAccountEnabledReport.ps1 new file mode 100644 index 0000000000000..72c961f2a8cb5 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSharedMailboxAccountEnabledReport.ps1 @@ -0,0 +1,100 @@ +function Get-CIPPSharedMailboxAccountEnabledReport { + <# + .SYNOPSIS + Generates the "shared mailbox with enabled account" report from the CIPP Reporting database + + .DESCRIPTION + Reproduces the live Invoke-ListSharedMailboxAccountEnabled payload entirely from cached data, + joining the cached 'Mailboxes' dataset (to identify SharedMailbox recipients) with the cached + 'Users' dataset (for accountEnabled / assignedLicenses / onPremisesSyncEnabled) by UPN. Only + shared mailboxes whose user account is enabled are returned. No dedicated cache writer is needed — + both source datasets are already populated on the scheduled cache cycle. + + .PARAMETER TenantFilter + The tenant to generate the report for. 'AllTenants' fans out across every tenant present in the + Mailboxes cache. + + .EXAMPLE + Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter 'contoso.onmicrosoft.com' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + try { + # Handle AllTenants by recursing per tenant present in the Mailboxes cache + if ($TenantFilter -eq 'AllTenants') { + $AllMailboxItems = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'Mailboxes' + $Tenants = @($AllMailboxItems | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } | Select-Object -ExpandProperty PartitionKey -Unique) + + $TenantList = Get-Tenants -IncludeErrors + $Tenants = $Tenants | Where-Object { $TenantList.defaultDomainName -contains $_ } + + $AllResults = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Tenant in $Tenants) { + try { + $TenantResults = Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $Tenant + foreach ($Result in $TenantResults) { + $Result | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Tenant -Force + $AllResults.Add($Result) + } + } catch { + Write-LogMessage -API 'SharedMailboxAccountEnabledReport' -tenant $Tenant -message "Failed to get report for tenant: $($_.Exception.Message)" -sev Warning + } + } + return $AllResults + } + + # Mailboxes cache identifies which mailboxes are shared (recipientTypeDetails) and the join key (UPN) + $MailboxItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } + if (-not $MailboxItems) { + throw 'No mailbox data found in reporting database. Sync the report data first.' + } + + # Users cache carries the account/license fields the live endpoint pulls from Graph /users + $UserItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Users' | Where-Object { $_.RowKey -ne 'Users-Count' } + + # Most-recent cache timestamp across both source datasets + $CacheTimestamp = (@($MailboxItems) + @($UserItems) | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp + + # Build a UPN -> user lookup (hashtable string keys are case-insensitive, matching UPN semantics) + $UserByUPN = @{} + foreach ($Item in $UserItems) { + $User = $Item.Data | ConvertFrom-Json + if ($User.userPrincipalName) { + $UserByUPN[$User.userPrincipalName] = $User + } + } + + $Results = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Item in $MailboxItems) { + $Mailbox = $Item.Data | ConvertFrom-Json + if ($Mailbox.recipientTypeDetails -ne 'SharedMailbox') { continue } + + $User = $UserByUPN[$Mailbox.UPN] + if (-not $User -or -not $User.accountEnabled) { continue } + + # Match the live Invoke-ListSharedMailboxAccountEnabled shape exactly. 'id' must be the user's + # object id — the page's "Block Sign In" action posts it to ExecDisableUser. + $Results.Add([PSCustomObject]@{ + UserPrincipalName = $User.userPrincipalName + displayName = $User.displayName + givenName = $User.givenName + surname = $User.surname + accountEnabled = $User.accountEnabled + assignedLicenses = $User.assignedLicenses + id = $User.id + onPremisesSyncEnabled = $User.onPremisesSyncEnabled + CacheTimestamp = $CacheTimestamp + }) + } + + return $Results + + } catch { + Write-LogMessage -API 'SharedMailboxAccountEnabledReport' -tenant $TenantFilter -message "Failed to generate shared mailbox account enabled report: $($_.Exception.Message)" -sev Error + throw + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSitSinglePackXml.ps1 b/Modules/CIPPCore/Public/Get-CIPPSitSinglePackXml.ps1 new file mode 100644 index 0000000000000..96b22868a3228 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSitSinglePackXml.ps1 @@ -0,0 +1,92 @@ +function Get-CIPPSitSinglePackXml { + <# + .SYNOPSIS + Reduce a (possibly multi-SIT) rule pack XML to a standalone pack containing just one SIT. + .DESCRIPTION + Custom regex SITs each get their own pack, but document-fingerprint SITs all share the one fixed + "Document Fingerprint Rule Package". To template just one SIT, this keeps the target entity and the + transitive closure of everything it references (by idRef / linkedProcessorIdRef), and removes every + other entity, its detection elements, and its Resource - then assigns a fresh RulePack id so the + result deploys as a new custom pack. + + Subtractive (rather than rebuilding) so the original structure is preserved exactly, including the + fingerprint shape where the Affinity entity is nested inside a wrapper. + Works for regex (Entity -> Pattern -> Regex/Keyword) and fingerprint (Affinity -> Evidence -> + Fingerprint -> AdvancedFingerprint) alike. Namespace-agnostic. + .PARAMETER PackXml + The full rule pack XML (e.g. from Get-DlpSensitiveInformationTypeRulePackage). + .PARAMETER EntityId + The SIT entity id (the SIT's Id). If blank, resolved from EntityName via the Resource. + .PARAMETER EntityName + The SIT name, used to resolve the entity id when EntityId is blank. + .OUTPUTS + A single-SIT rule pack XML string (utf-16 declared). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$PackXml, + [string]$EntityId, + [string]$EntityName + ) + + [xml]$doc = $PackXml + + # Resolve the entity id from the SIT name if needed (Resource.Name -> Resource.idRef). + if ([string]::IsNullOrWhiteSpace($EntityId) -and $EntityName) { + foreach ($res in $doc.SelectNodes("//*[local-name()='Resource']")) { + $nm = $res.SelectSingleNode("*[local-name()='Name']") + if ($nm -and ([string]$nm.InnerText).Trim() -eq $EntityName) { $EntityId = [string]$res.idRef; break } + } + } + if ([string]::IsNullOrWhiteSpace($EntityId)) { throw 'Could not resolve the SIT entity id.' } + + # Map every element that carries an id (anywhere - entities can be nested in a Version wrapper). + $byId = @{} + foreach ($el in $doc.SelectNodes("//*[@id]")) { $byId[[string]$el.id] = $el } + if (-not $byId.ContainsKey($EntityId)) { throw "Entity '$EntityId' not found in the rule package." } + + # Transitive closure of ids the target entity needs. + $keep = New-Object 'System.Collections.Generic.HashSet[string]' + [void]$keep.Add($EntityId) + $stack = New-Object System.Collections.Stack + $stack.Push($byId[$EntityId]) + while ($stack.Count -gt 0) { + $el = $stack.Pop() + $walk = New-Object System.Collections.Stack + $walk.Push($el) + while ($walk.Count -gt 0) { + $n = $walk.Pop() + foreach ($a in @($n.Attributes)) { + if ($a.Name -in @('idRef', 'linkedProcessorIdRef')) { + $rid = [string]$a.Value + if ($byId.ContainsKey($rid) -and -not $keep.Contains($rid)) { + [void]$keep.Add($rid) + $stack.Push($byId[$rid]) + } + } + } + foreach ($ch in $n.ChildNodes) { if ($ch.NodeType -eq 'Element') { $walk.Push($ch) } } + } + } + + # Remove other entities, the detection elements they (and not the target) own, and other Resources. + $toRemove = New-Object System.Collections.ArrayList + foreach ($e in $doc.SelectNodes("//*[local-name()='Entity' or local-name()='Affinity']")) { + if ([string]$e.id -ne $EntityId) { [void]$toRemove.Add($e) } + } + foreach ($e in $doc.SelectNodes("//*[local-name()='Regex' or local-name()='Keyword' or local-name()='Fingerprint' or local-name()='AdvancedFingerprint']")) { + if (-not $keep.Contains([string]$e.id)) { [void]$toRemove.Add($e) } + } + foreach ($r in $doc.SelectNodes("//*[local-name()='Resource']")) { + if ([string]$r.idRef -ne $EntityId) { [void]$toRemove.Add($r) } + } + foreach ($node in $toRemove) { [void]$node.ParentNode.RemoveChild($node) } + + # Fresh RulePack id so this deploys as a new custom pack (not the fixed managed pack id). + $rulePack = $doc.SelectSingleNode("//*[local-name()='RulePack']") + if ($rulePack -and $rulePack.Attributes['id']) { $rulePack.id = (New-Guid).Guid } + + return '' + $doc.DocumentElement.OuterXml +} diff --git a/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 b/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 new file mode 100644 index 0000000000000..fbf099fff32a2 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 @@ -0,0 +1,166 @@ +function Get-CIPPTestResultsTenants { + <# + .SYNOPSIS + Retrieves CIPP test results across one, many, or all tenants with flexible filtering. + + .DESCRIPTION + Queries the shared CippTestResults table server-side (partition / row / status / type / + risk / category) so a single test can be compared across every tenant in one call. This is + the cross-tenant counterpart to Get-CIPPTestResults (which is scoped to a single tenant). + + Custom test rows are enriched with their definition (Description / ReturnType / + MarkdownTemplate) from the CustomPowershellScripts table so the off-canvas detail renders + identically to the per-tenant dashboard. Every row is stamped with its tenant identity + (Tenant / TenantId / TenantName) and a serialisable LastRun timestamp for display. + + .PARAMETER TenantFilter + One or more tenant domains. Omit, or pass 'AllTenants' / 'allTenants', to query every tenant. + + .PARAMETER TestId + One or more test IDs (the row's RowKey), e.g. 'CustomScript-'. + + .PARAMETER Status + One or more statuses to filter on (Passed / Failed / Investigate / Skipped / Informational). + + .PARAMETER TestType + Restrict to a single test type (Identity / Devices / Custom). + + .PARAMETER Risk + Restrict to a single risk level (High / Medium / Low). + + .PARAMETER Category + Restrict to a single category. + + .EXAMPLE + Get-CIPPTestResultsTenants -TestId 'CustomScript-1234' -TestType 'Custom' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string[]]$TenantFilter, + + [Parameter(Mandatory = $false)] + [string[]]$TestId, + + [Parameter(Mandatory = $false)] + [string[]]$Status, + + [Parameter(Mandatory = $false)] + [string]$TestType, + + [Parameter(Mandatory = $false)] + [string]$Risk, + + [Parameter(Mandatory = $false)] + [string]$Category + ) + + $Table = Get-CippTable -tablename 'CippTestResults' + + # Build a single OData filter so all narrowing happens server-side. A cross-partition scan + # (no PartitionKey clause) is acceptable here because it is always bounded by a RowKey/Status/ + # TestType clause when driven from the UI. + $FilterParts = [System.Collections.Generic.List[string]]::new() + + $AllTenants = (-not $TenantFilter) -or ($TenantFilter -contains 'AllTenants') -or ($TenantFilter -contains 'allTenants') + if (-not $AllTenants) { + $TenantClause = (@($TenantFilter | Where-Object { $_ }) | ForEach-Object { "PartitionKey eq '$_'" }) -join ' or ' + if ($TenantClause) { $FilterParts.Add("($TenantClause)") } + } + if ($TestId) { + $TestClause = (@($TestId | Where-Object { $_ }) | ForEach-Object { "RowKey eq '$_'" }) -join ' or ' + if ($TestClause) { $FilterParts.Add("($TestClause)") } + } + if ($Status) { + $StatusClause = (@($Status | Where-Object { $_ }) | ForEach-Object { "Status eq '$_'" }) -join ' or ' + if ($StatusClause) { $FilterParts.Add("($StatusClause)") } + } + if ($TestType) { $FilterParts.Add("TestType eq '$TestType'") } + if ($Risk) { $FilterParts.Add("Risk eq '$Risk'") } + if ($Category) { $FilterParts.Add("Category eq '$Category'") } + + $Filter = $FilterParts -join ' and ' + + $GetParams = @{} + if ($Filter) { $GetParams.Filter = $Filter } + $Results = @(Get-CIPPAzDataTableEntity @Table @GetParams) + + if ($Results.Count -eq 0) { return @() } + + # Map tenant domain (PartitionKey) -> tenant identity for display and access control. + $TenantLookup = @{} + try { + foreach ($Tenant in (Get-Tenants -IncludeErrors)) { + if ($Tenant.defaultDomainName) { + $TenantLookup[$Tenant.defaultDomainName] = $Tenant + } + } + } catch { + Write-Warning "Get-CIPPTestResultsTenants: failed to load tenant list: $($_.Exception.Message)" + } + + # Enrich Custom rows with their definition (latest version per ScriptGuid), mirroring the + # per-tenant enrichment in Invoke-ListTests so the shared off-canvas renders identically. + $CustomMeta = @{} + if (@($Results | Where-Object { $_.TestType -eq 'Custom' }).Count -gt 0) { + $ScriptTable = Get-CippTable -tablename 'CustomPowershellScripts' + $Scripts = @(Get-CIPPAzDataTableEntity @ScriptTable -Filter "PartitionKey eq 'CustomScript'") + $LatestByGuid = @{} + foreach ($Script in $Scripts) { + if (-not $Script.ScriptGuid) { continue } + $Existing = $LatestByGuid[$Script.ScriptGuid] + if (-not $Existing -or [int]$Script.Version -gt [int]$Existing.Version) { + $LatestByGuid[$Script.ScriptGuid] = $Script + } + } + foreach ($Script in $LatestByGuid.Values) { + # Treat a missing Enabled property as enabled, matching Invoke-CIPPTestCollection. + # A disabled (or deleted) script can still have stale results in the table; surfacing + # this lets the UI show that the data no longer reflects an active test. + $EnabledProp = $Script.PSObject.Properties['Enabled'] + $CustomMeta[$Script.ScriptGuid] = [PSCustomObject]@{ + ScriptName = $Script.ScriptName ?? '' + Description = $Script.Description ?? '' + ReturnType = $Script.ReturnType ?? 'JSON' + MarkdownTemplate = $Script.MarkdownTemplate ?? '' + Enabled = (-not $EnabledProp) -or [bool]$EnabledProp.Value + } + } + } + + foreach ($Result in $Results) { + $TenantInfo = $TenantLookup[$Result.PartitionKey] + $Result | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Result.PartitionKey -Force + $Result | Add-Member -NotePropertyName 'TenantId' -NotePropertyValue ($TenantInfo.customerId ?? '') -Force + $Result | Add-Member -NotePropertyName 'TenantName' -NotePropertyValue ($TenantInfo.displayName ?? $Result.PartitionKey) -Force + + # Surface the Azure entity timestamp as a stable, serialisable last-run field. + $LastRun = $Result.Timestamp + if ($LastRun -is [DateTimeOffset]) { + $LastRun = $LastRun.UtcDateTime.ToString('o') + } elseif ($LastRun) { + $LastRun = [string]$LastRun + } + $Result | Add-Member -NotePropertyName 'LastRun' -NotePropertyValue $LastRun -Force + + if ($Result.TestType -eq 'Custom') { + $ScriptGuid = ($Result.RowKey -replace '^CustomScript-', '') + if (-not [string]::IsNullOrWhiteSpace($ScriptGuid) -and $CustomMeta.ContainsKey($ScriptGuid)) { + $Meta = $CustomMeta[$ScriptGuid] + $Result | Add-Member -NotePropertyName 'Description' -NotePropertyValue $Meta.Description -Force + $Result | Add-Member -NotePropertyName 'ReturnType' -NotePropertyValue $Meta.ReturnType -Force + $Result | Add-Member -NotePropertyName 'MarkdownTemplate' -NotePropertyValue $Meta.MarkdownTemplate -Force + $Result | Add-Member -NotePropertyName 'Enabled' -NotePropertyValue $Meta.Enabled -Force + if ([string]::IsNullOrWhiteSpace($Result.Name) -and $Meta.ScriptName) { + $Result | Add-Member -NotePropertyName 'Name' -NotePropertyValue $Meta.ScriptName -Force + } + } else { + # Result exists but the script no longer does (deleted). The data is stale — flag + # it as not enabled so the column stays populated and truthful. + $Result | Add-Member -NotePropertyName 'Enabled' -NotePropertyValue $false -Force + } + } + } + + return $Results +} diff --git a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 index f96bef07090cc..a89ce2bee2662 100644 --- a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 @@ -16,6 +16,16 @@ function Get-CIPPTextReplacement { $Text, [switch]$EscapeForJson ) + # Escapes a replacement value so it can be safely spliced into a serialized JSON + # string literal. Handles quotes, backslashes, newlines, tabs and control chars. + function ConvertTo-CIPPJsonEscapedString { + param($Value) + if ($null -eq $Value) { return '' } + $Encoded = [string]$Value | ConvertTo-Json -Compress + # Strip the surrounding quotes ConvertTo-Json adds, leaving just the escaped body. + return $Encoded.Substring(1, $Encoded.Length - 2) + } + if ($Text -isnot [string]) { return , $Text } @@ -29,6 +39,8 @@ function Get-CIPPTextReplacement { '%serial%', '%systemroot%', '%systemdrive%', + '%system32%', + '%osdrive%', '%temp%', '%tenantid%', '%tenantfilter%', @@ -68,7 +80,7 @@ function Get-CIPPTextReplacement { if (-not $Var.PSObject.Properties['Value']) { continue } $Val = $Var.Value if ($EscapeForJson.IsPresent) { - $Val = $Val -replace '(?-'. Use the known offload suffix as the + # node name (Get-CippOffloadSuffix), NOT the second dash segment - a dashed main-app name + # (e.g. 'compaction-01-z2ir2') would otherwise yield a wrong node like '01'. + $AvailableNodes = $Nodes | Where-Object { (Test-CippOffloadFunctionApp -SiteName $_.RowKey) -and $_.Version -eq $MainFunctionVersion } | ForEach-Object { Get-CippOffloadSuffix -SiteName $_.RowKey } + + # Get node name for the current app: the offload suffix, or 'http' for the main app. + $Node = Get-CippOffloadSuffix -SiteName $FunctionName + if (-not $Node) { $Node = 'http' } diff --git a/Modules/CIPPCore/Public/Get-CippCustomDataAttributes.ps1 b/Modules/CIPPCore/Public/Get-CippCustomDataAttributes.ps1 index 90a8469c53547..8ed8727c6e1ce 100644 --- a/Modules/CIPPCore/Public/Get-CippCustomDataAttributes.ps1 +++ b/Modules/CIPPCore/Public/Get-CippCustomDataAttributes.ps1 @@ -17,12 +17,12 @@ function Get-CippCustomDataAttributes { if ($CustomData) { if ($Type -eq 'SchemaExtension') { $Name = $CustomData.id - foreach ($TargetObject in $CustomData.targetTypes) { + foreach ($Target in $CustomData.targetTypes) { foreach ($Property in $CustomData.properties) { [PSCustomObject]@{ name = '{0}.{1}' -f $Name, $Property.name type = $Type - targetObject = $TargetObject + targetObject = $Target dataType = $Property.type isMultiValued = $false } @@ -30,11 +30,11 @@ function Get-CippCustomDataAttributes { } } elseif ($Type -eq 'DirectoryExtension') { $Name = $CustomDataEntity.RowKey - foreach ($TargetObject in $CustomData.targetObjects) { + foreach ($Target in $CustomData.targetObjects) { [PSCustomObject]@{ name = $Name type = $Type - targetObject = $TargetObject + targetObject = $Target dataType = $CustomData.dataType isMultiValued = $CustomData.isMultiValued } diff --git a/Modules/CIPPCore/Public/Get-CippKeyVaultName.ps1 b/Modules/CIPPCore/Public/Get-CippKeyVaultName.ps1 new file mode 100644 index 0000000000000..f538397e20e53 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CippKeyVaultName.ps1 @@ -0,0 +1,55 @@ +function Get-CippKeyVaultName { + <# + .SYNOPSIS + Returns the name of the CIPP Azure Key Vault for the current instance. + + .DESCRIPTION + The Key Vault is named after the main App Service instance, so its name equals + $env:WEBSITE_SITE_NAME on the main app. + + Two things have to be handled: + + 1. Dashed instance names. Earlier code derived the vault name as + ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0], which keeps only the segment before + the first dash. That silently truncated any instance name containing a dash - + e.g. 'compaction-01-z2ir2' became 'compaction' - so every secret call was pointed + at a vault that does not exist (404). The full name must be kept intact. + + 2. Offloaded function apps. When function offloading is enabled, extra Function Apps + are deployed alongside the main app and share its Key Vault. They are named + '-' (e.g. 'compaction-01-z2ir2-standards'). Those apps must + resolve the SAME vault as the main app, so a known offload suffix is stripped from + the end of the site name. Only the fixed offload suffixes are stripped, never an + arbitrary trailing segment, so a legitimate dashed vault name is left intact. + + This is the single source of truth for the vault name so those bugs cannot reappear + per call site. + + .EXAMPLE + $VaultName = Get-CippKeyVaultName + #> + [CmdletBinding()] + [OutputType([string])] + param() + + # Primary: the App Service site name IS the vault name (on the main app). + # Fallback: the full deployment id. Never split on '-' (that is the truncation bug this + # helper exists to prevent); a dashed vault name must be kept whole. + $Name = if (-not [string]::IsNullOrWhiteSpace($env:WEBSITE_SITE_NAME)) { + $env:WEBSITE_SITE_NAME + } elseif (-not [string]::IsNullOrWhiteSpace($env:WEBSITE_DEPLOYMENT_ID)) { + $env:WEBSITE_DEPLOYMENT_ID + } else { + return $null + } + + # If running on an offloaded app ('-'), strip the known suffix so it + # resolves the SAME vault as the main app. Get-CippOffloadSuffix is the single source of + # truth for the suffix list. + $Suffix = Get-CippOffloadSuffix -SiteName $Name + if ($Suffix) { + $Name = $Name -replace "-$([regex]::Escape($Suffix))$", '' + } + + return $Name +} diff --git a/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 b/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 index b4d032f2c6774..30f0ac4b08f05 100644 --- a/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 +++ b/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 @@ -37,10 +37,9 @@ function Get-CippKeyVaultSecret { try { # Derive vault name if not provided if (-not $VaultName) { - if ($env:WEBSITE_DEPLOYMENT_ID) { - $VaultName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] - } else { - throw 'VaultName not provided and WEBSITE_DEPLOYMENT_ID environment variable not set' + $VaultName = Get-CippKeyVaultName + if (-not $VaultName) { + throw 'VaultName not provided and could not be derived (WEBSITE_SITE_NAME / WEBSITE_DEPLOYMENT_ID not set)' } } diff --git a/Modules/CIPPCore/Public/Get-CippOffloadSuffix.ps1 b/Modules/CIPPCore/Public/Get-CippOffloadSuffix.ps1 new file mode 100644 index 0000000000000..fc828ca8d9a7f --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CippOffloadSuffix.ps1 @@ -0,0 +1,49 @@ +function Get-CippOffloadSuffix { + <# + .SYNOPSIS + Returns the offload-app suffix for a function app name, or $null when it is not an + offloaded app. + + .DESCRIPTION + When function offloading is enabled, extra Function Apps are deployed alongside the + main app and share its resources (Key Vault, etc.). They are named + '-' - e.g. 'compaction-01-z2ir2-standards'. + + This is the SINGLE SOURCE OF TRUTH for the known offload suffixes so that vault-name + derivation ([[Get-CippKeyVaultName]]) and offload detection stay in sync. Matching is + anchored to the end and requires a whole '-' segment, so a legitimate dashed + main-app name (e.g. 'compaction-01-z2ir2', which contains dashes but is NOT offloaded) + is never misdetected. + + Keep $OffloadSuffixes in sync with the deployment's offload app names. + + .PARAMETER SiteName + Function app name to inspect. Defaults to $env:WEBSITE_SITE_NAME (the current app). + + .EXAMPLE + Get-CippOffloadSuffix -SiteName 'compaction-01-z2ir2-standards' # -> 'standards' + + .EXAMPLE + Get-CippOffloadSuffix -SiteName 'compaction-01-z2ir2' # -> $null + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $false)] + [AllowNull()] + [AllowEmptyString()] + [string]$SiteName = $env:WEBSITE_SITE_NAME + ) + + $OffloadSuffixes = @('proc', 'auditlog', 'standards', 'usertasks') + + if ([string]::IsNullOrWhiteSpace($SiteName)) { return $null } + + foreach ($Suffix in $OffloadSuffixes) { + if ($SiteName -match "-$([regex]::Escape($Suffix))$") { + return $Suffix + } + } + + return $null +} diff --git a/Modules/CIPPCore/Public/Get-DefenderCves.ps1 b/Modules/CIPPCore/Public/Get-DefenderCves.ps1 new file mode 100644 index 0000000000000..0a36aa2930663 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-DefenderCves.ps1 @@ -0,0 +1,127 @@ +function get-DefenderCVEs { + <# + .SYNOPSIS + Caches all vulnerabilities devices for a tenant + + .PARAMETER TenantFilter + The tenant to cache vulnerabilities for + + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + try { + $AllVulns = Get-DefenderTvmRaw -TenantId $TenantFilter -MaxPages 0 + + if (-not $AllVulns) { + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "No vulnerability data returned from Defender TVM" -sev 'Warning' + return + } + + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Retrieved $($AllVulns.Count) CVE records from Defender TVM" -sev 'Info' + try{ + # Initialize a tracker for this tenant session + $CveAggregator = @{} + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Aggregator Failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + try{ + # Group the raw TVM records into unified CVE buckets + foreach ($Vuln in $AllVulns) { + $CveId = $Vuln.cveId + try{ + if (-not $CveAggregator.ContainsKey($CveId)) { + # Establish global CVE & software properties for this specific tenant + $CveAggregator[$CveId] = @{ + cveId = $CveId + customerId = $TenantFilter + softwareVendor = $Vuln.softwareVendor ?? '' + softwareName = $Vuln.softwareName ?? '' + vulnerabilitySeverityLevel = $Vuln.vulnerabilitySeverityLevel ?? '' + recommendedSecurityUpdate = $Vuln.recommendedSecurityUpdate ?? '' + recommendedSecurityUpdateUrl = $Vuln.recommendedSecurityUpdateUrl ?? '' + exploitabilityLevel = $Vuln.exploitabilityLevel ?? '' + + # Arrays to collect device metadata efficiently + AffectedDevices = [System.Collections.Generic.List[object]]::new() + } + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Failed to establish global: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + try{ + # Extract properties specific to this device instance + $DevicePayload = @{ + deviceId = ($Vuln.deviceId -join ',') ?? '' + deviceName = ($Vuln.deviceName -join ',') ?? '' + osVersion = $Vuln.osVersion ?? '' + softwareVersion = ($Vuln.softwareVersion -join ',') ?? '' + diskPaths = if ($Vuln.diskPaths) { $Vuln.diskPaths -join ';' } else { '' } + registryPaths = if ($Vuln.registryPaths) { $Vuln.registryPaths -join ';' } else { '' } + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Failed to extract: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + # Append to our tracking list + [void]$CveAggregator[$CveId].AffectedDevices.Add($DevicePayload) + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Allover Build: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + + $Entities = [System.Collections.Generic.List[object]]::new() + + foreach ($CveKey in $CveAggregator.Keys) { + $CveData = $CveAggregator[$CveKey] + + # Flatten or convert device info arrays into a compact, compressed JSON string + $CompactDeviceJson = $CveData.AffectedDevices | ConvertTo-Json -Compress + + [void]$Entities.Add(@{ + PartitionKey = $CveKey + RowKey = $TenantFilter # RowKey becomes just the Tenant, ensuring 1 row per CVE per Tenant + customerId = $TenantFilter + cveId = $CveKey + softwareVendor = $CveData.softwareVendor + softwareName = $CveData.softwareName + vulnerabilitySeverityLevel = $CveData.vulnerabilitySeverityLevel + recommendedSecurityUpdate = $CveData.recommendedSecurityUpdate + recommendedSecurityUpdateUrl = $CveData.recommendedSecurityUpdateUrl + exploitabilityLevel = $CveData.exploitabilityLevel + + # Meta aggregation counts + deviceCount = $CveData.AffectedDevices.Count + + # All individual device variations compressed safely inside a single field + deviceDetailsJson = $CompactDeviceJson + + lastUpdated = [string]$(Get-Date (Get-Date).ToUniversalTime() -UFormat '+%Y-%m-%dT%H:%M:%S.000Z') + }) + } + + if ($Entities.Count -eq 0) { + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "No valid CVE records to cache" -sev 'Warning' + return + } + + $SuccessCount = 0 + $FailCount = 0 + + $UniqueCves = ($Entities | Select-Object -ExpandProperty cveId -Unique).Count + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Retrieved $UniqueCves Unique CVEs" -sev 'Info' + + return $Entities + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "CVE Cache Refresh failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + throw + } +} diff --git a/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 b/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 new file mode 100644 index 0000000000000..7e1b567738dba --- /dev/null +++ b/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 @@ -0,0 +1,65 @@ +function Get-DefenderTvmRaw { + <# + .SYNOPSIS + Fetch Defender TVM SoftwareVulnerabilitiesByMachine with paging. + .PARAMETER TenantId + Microsoft Entra tenant id to query. + .PARAMETER MaxPages + Optional page cap (0 = no cap). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$TenantId, + [int]$MaxPages = 0 + ) + + $scope = 'https://api.securitycenter.microsoft.com/.default' + $uri = 'https://api.securitycenter.microsoft.com/api/machines/SoftwareVulnerabilitiesByMachine' + $all = New-Object System.Collections.Generic.List[object] + $page = 0 + + try { + do { + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Fetching page $($page + 1)" -Sev 'Debug' + + $resp = New-GraphGetRequest -tenantid $TenantId -uri $uri -scope $scope + + if ($resp -is [System.Collections.IDictionary]) { + if ($resp.ContainsKey('value')) { + $rows = $resp.value + $nextLink = $resp.'@odata.nextLink' + if ($rows) { $all.AddRange($rows) } + $uri = $nextLink + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Page $($page + 1): $($rows.Count) records" -Sev 'Debug' + } + else { + $all.Add($resp) + $uri = $null + } + } + elseif ($resp -is [System.Collections.IEnumerable] -and $resp -isnot [string]) { + $all.AddRange($resp) + $uri = $null + } + else { + $all.Add($resp) + $uri = $null + } + + $page++ + + if ($page -gt 100) { + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Reached 100 page safety limit — stopping" -Sev 'Warning' + break + } + + } while ($uri -and ($MaxPages -eq 0 -or $page -lt $MaxPages)) + + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Defender TVM fetch complete: $($all.Count) records across $page page(s)" -Sev 'Info' + return $all + } + catch { + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Error on page $page`: $($_.Exception.Message)" -Sev 'Error' + throw + } +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 index 257b7b22aad05..5628ffc486ebd 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 @@ -1,8 +1,20 @@ -function Get-GraphToken($tenantid, $scope, $AsApp, $AppID, $AppSecret, $refreshToken, $ReturnRefresh, $SkipCache) { +function Get-GraphToken { <# .FUNCTIONALITY Internal #> + [CmdletBinding()] + param( + $tenantid, + $scope, + $AsApp, + $AppID, + $AppSecret, + $refreshToken, + $ReturnRefresh, + $SkipCache, + [switch]$UseCertificate + ) if (!$scope) { $scope = 'https://graph.microsoft.com/.default' } if (!$tenantid) { $tenantid = $env:TenantID } @@ -12,6 +24,7 @@ function Get-GraphToken($tenantid, $scope, $AsApp, $AppID, $AppSecret, $refreshT if ($UseSharedTokenCache) { $CacheClientId = if ($AppID) { [string]$AppID } else { [string]$env:ApplicationID } $GrantType = if ($asApp -eq $true -or ($null -ne $AppID -and $null -ne $AppSecret)) { 'client_credentials' } else { 'refresh_token' } + if ($UseCertificate -eq $true) { $GrantType = "${GrantType}_certificate" } $SharedTokenCacheKey = [CIPP.CIPPTokenCache]::BuildKey([string]$tenantid, [string]$scope, [bool]$asApp, $CacheClientId, $GrantType) $SharedCacheEntry = [CIPP.CIPPTokenCache]::Lookup($SharedTokenCacheKey, 120) if ($SharedCacheEntry.Found -and -not [string]::IsNullOrWhiteSpace($SharedCacheEntry.TokenPayloadJson)) { @@ -48,144 +61,163 @@ function Get-GraphToken($tenantid, $scope, $AsApp, $AppID, $AppSecret, $refreshT } } try { - if (!$env:SetFromProfile) { $CIPPAuth = Get-CIPPAuthentication; Write-Host 'Could not get Refreshtoken from environment variable. Reloading token.' } - $ConfigTable = Get-CippTable -tablename 'Config' - $Filter = "PartitionKey eq 'AppCache' and RowKey eq 'AppCache'" - $AppCache = Get-CIPPAzDataTableEntity @ConfigTable -Filter $Filter - #force auth update is appId is not the same as the one in the environment variable. - if ($AppCache.ApplicationId -and $env:ApplicationID -ne $AppCache.ApplicationId) { - Write-Host "Setting environment variable ApplicationID to $($AppCache.ApplicationId)" - $CIPPAuth = Get-CIPPAuthentication - } - $refreshToken = $env:RefreshToken - #Get list of tenants that have 'directTenant' set to true - #get directtenants directly from table, avoid get-tenants due to performance issues - $TenantsTable = Get-CippTable -tablename 'Tenants' - $Filter = "PartitionKey eq 'Tenants' and delegatedPrivilegeStatus eq 'directTenant'" - $ClientType = Get-CIPPAzDataTableEntity @TenantsTable -Filter $Filter | Where-Object { $_.customerId -eq $tenantid -or $_.defaultDomainName -eq $tenantid } - if ($tenantid -ne $env:TenantID -and $clientType.delegatedPrivilegeStatus -eq 'directTenant') { - Write-Host "Using direct tenant refresh token for $($clientType.customerId)" - $ClientRefreshToken = Get-Item -Path "env:\$($clientType.customerId)" -ErrorAction SilentlyContinue + if (!$env:SetFromProfile) { $CIPPAuth = Get-CIPPAuthentication; Write-Host 'Could not get Refreshtoken from environment variable. Reloading token.' } + $ConfigTable = Get-CippTable -tablename 'Config' + $Filter = "PartitionKey eq 'AppCache' and RowKey eq 'AppCache'" + $AppCache = Get-CIPPAzDataTableEntity @ConfigTable -Filter $Filter + #force auth update is appId is not the same as the one in the environment variable. + if ($AppCache.ApplicationId -and $env:ApplicationID -ne $AppCache.ApplicationId) { + Write-Host "Setting environment variable ApplicationID to $($AppCache.ApplicationId)" + $CIPPAuth = Get-CIPPAuthentication + } + $refreshToken = $env:RefreshToken + #Get list of tenants that have 'directTenant' set to true + #get directtenants directly from table, avoid get-tenants due to performance issues + $TenantsTable = Get-CippTable -tablename 'Tenants' + $Filter = "PartitionKey eq 'Tenants' and delegatedPrivilegeStatus eq 'directTenant'" + $ClientType = Get-CIPPAzDataTableEntity @TenantsTable -Filter $Filter | Where-Object { $_.customerId -eq $tenantid -or $_.defaultDomainName -eq $tenantid } + if ($tenantid -ne $env:TenantID -and $clientType.delegatedPrivilegeStatus -eq 'directTenant') { + Write-Host "Using direct tenant refresh token for $($clientType.customerId)" + $ClientRefreshToken = Get-Item -Path "env:\$($clientType.customerId)" -ErrorAction SilentlyContinue - if ($null -eq $ClientRefreshToken) { - # Lazy load the refresh token from Key Vault only when needed - Write-Host "Fetching refresh token for direct tenant $($clientType.customerId) from Key Vault" - try { - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - # Development environment - get from table storage - $Table = Get-CIPPTable -tablename 'DevSecrets' - $Secret = Get-AzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" - $secretname = $clientType.customerId -replace '-', '_' - if ($Secret.$secretname) { - Set-Item -Path "env:\$($clientType.customerId)" -Value $Secret.$secretname -Force - $ClientRefreshToken = Get-Item -Path "env:\$($clientType.customerId)" -ErrorAction SilentlyContinue - } - } else { - # Production environment - get from Key Vault - $keyvaultname = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] - $secret = Get-CippKeyVaultSecret -VaultName $keyvaultname -Name $clientType.customerId -AsPlainText -ErrorAction Stop - if ($secret) { - Set-Item -Path "env:\$($clientType.customerId)" -Value $secret -Force - $ClientRefreshToken = Get-Item -Path "env:\$($clientType.customerId)" -ErrorAction SilentlyContinue + if ($null -eq $ClientRefreshToken) { + # Lazy load the refresh token from Key Vault only when needed + Write-Host "Fetching refresh token for direct tenant $($clientType.customerId) from Key Vault" + try { + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + # Development environment - get from table storage + $Table = Get-CIPPTable -tablename 'DevSecrets' + $Secret = Get-AzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" + $secretname = $clientType.customerId -replace '-', '_' + if ($Secret.$secretname) { + Set-Item -Path "env:\$($clientType.customerId)" -Value $Secret.$secretname -Force + $ClientRefreshToken = Get-Item -Path "env:\$($clientType.customerId)" -ErrorAction SilentlyContinue + } + } else { + # Production environment - get from Key Vault + $keyvaultname = Get-CippKeyVaultName + $secret = Get-CippKeyVaultSecret -VaultName $keyvaultname -Name $clientType.customerId -AsPlainText -ErrorAction Stop + if ($secret) { + Set-Item -Path "env:\$($clientType.customerId)" -Value $secret -Force + $ClientRefreshToken = Get-Item -Path "env:\$($clientType.customerId)" -ErrorAction SilentlyContinue + } } + } catch { + Write-Host "Failed to retrieve refresh token for direct tenant $($clientType.customerId): $($_.Exception.Message)" } - } catch { - Write-Host "Failed to retrieve refresh token for direct tenant $($clientType.customerId): $($_.Exception.Message)" } - } - $refreshToken = $ClientRefreshToken.Value - } + $refreshToken = $ClientRefreshToken.Value + } - $AuthBody = @{ - client_id = $env:ApplicationID - client_secret = $env:ApplicationSecret - scope = $Scope - refresh_token = $refreshToken - grant_type = 'refresh_token' - } - if ($asApp -eq $true) { $AuthBody = @{ client_id = $env:ApplicationID client_secret = $env:ApplicationSecret scope = $Scope - grant_type = 'client_credentials' - } - } - - if ($null -ne $AppID -and $null -ne $refreshToken) { - $AuthBody = @{ - client_id = $appid refresh_token = $refreshToken - scope = $Scope grant_type = 'refresh_token' } - } + if ($asApp -eq $true) { + $AuthBody = @{ + client_id = $env:ApplicationID + client_secret = $env:ApplicationSecret + scope = $Scope + grant_type = 'client_credentials' + } + } - if ($null -ne $AppID -and $null -ne $AppSecret) { - $AuthBody = @{ - client_id = $AppID - client_secret = $AppSecret - scope = $Scope - grant_type = 'client_credentials' + if ($null -ne $AppID -and $null -ne $refreshToken) { + $AuthBody = @{ + client_id = $appid + refresh_token = $refreshToken + scope = $Scope + grant_type = 'refresh_token' + } } - } - # Rebuild cache key after credential loading (env vars may have been set by Get-CIPPAuthentication) - if ($UseSharedTokenCache) { - $CacheClientId = if ($AppID) { [string]$AppID } else { [string]$env:ApplicationID } - $GrantType = if ($asApp -eq $true -or ($null -ne $AppID -and $null -ne $AppSecret)) { 'client_credentials' } else { 'refresh_token' } - $SharedTokenCacheKey = [CIPP.CIPPTokenCache]::BuildKey([string]$tenantid, [string]$scope, [bool]$asApp, $CacheClientId, $GrantType) - } + if ($null -ne $AppID -and $null -ne $AppSecret) { + $AuthBody = @{ + client_id = $AppID + client_secret = $AppSecret + scope = $Scope + grant_type = 'client_credentials' + } + } - try { - $AccessToken = (Invoke-CIPPRestMethod -Method post -Uri "https://login.microsoftonline.com/$($tenantid)/oauth2/v2.0/token" -Body $Authbody -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop) - if ($null -eq $AccessToken.expires_on -and $AccessToken.expires_in) { - $ExpiresOn = [int](Get-Date -UFormat %s -Millisecond 0) + $AccessToken.expires_in - Add-Member -InputObject $AccessToken -NotePropertyName 'expires_on' -NotePropertyValue $ExpiresOn -Force + # Rebuild cache key after credential loading (env vars may have been set by Get-CIPPAuthentication) + if ($UseSharedTokenCache) { + $CacheClientId = if ($AppID) { [string]$AppID } else { [string]$env:ApplicationID } + $GrantType = if ($asApp -eq $true -or ($null -ne $AppID -and $null -ne $AppSecret)) { 'client_credentials' } else { 'refresh_token' } + if ($UseCertificate -eq $true) { $GrantType = "${GrantType}_certificate" } + $SharedTokenCacheKey = [CIPP.CIPPTokenCache]::BuildKey([string]$tenantid, [string]$scope, [bool]$asApp, $CacheClientId, $GrantType) } - if ($UseSharedTokenCache -and $SharedTokenCacheKey) { - try { - $TokenPayloadJson = $AccessToken | ConvertTo-Json -Depth 20 -Compress - [CIPP.CIPPTokenCache]::Store($SharedTokenCacheKey, $TokenPayloadJson, [int64]$AccessToken.expires_on) - } catch { - # Ignore shared cache write failures + try { + if ($UseCertificate -eq $true) { + $SAMCert = Get-CIPPSAMCertificate -ErrorAction Stop + if (-not $SAMCert) { throw 'No SAM certificate available. Run Update-CIPPSAMCertificate to create one.' } + } + if ($UseCertificate -eq $true -and $AuthBody.grant_type -eq 'client_credentials') { + # App-only with certificate: use the dedicated cert token function + # (has its own cache under the same key and the AADSTS700027 retry) + $AccessToken = Get-GraphTokenFromCert -TenantId $tenantid -AppId $AuthBody.client_id -Scope $scope -Certificate $SAMCert.Certificate -SkipCache:($SkipCache -eq $true) -ErrorAction Stop + if (-not $AccessToken.access_token) { throw "Could not get a token using the SAM certificate for $tenantid" } + } else { + if ($UseCertificate -eq $true) { + # Delegated with certificate: authenticate the app with a signed assertion + # instead of the client secret; the refresh token still provides user context + $null = $AuthBody.Remove('client_secret') + $AuthBody.client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + $AuthBody.client_assertion = New-CIPPCertificateAssertion -TenantId $tenantid -AppId $AuthBody.client_id -Certificate $SAMCert.Certificate + } + $AccessToken = (Invoke-CIPPRestMethod -Method post -Uri "https://login.microsoftonline.com/$($tenantid)/oauth2/v2.0/token" -Body $Authbody -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop) + } + if ($null -eq $AccessToken.expires_on -and $AccessToken.expires_in) { + $ExpiresOn = [int](Get-Date -UFormat %s -Millisecond 0) + $AccessToken.expires_in + Add-Member -InputObject $AccessToken -NotePropertyName 'expires_on' -NotePropertyValue $ExpiresOn -Force } - } - if ($ReturnRefresh) { return $AccessToken } - return @{ Authorization = "Bearer $($AccessToken.access_token)" } - } catch { - # Track consecutive Graph API failures - $TenantsTable = Get-CippTable -tablename Tenants - $Filter = "PartitionKey eq 'Tenants' and (defaultDomainName eq '{0}' or customerId eq '{0}')" -f $tenantid - $Tenant = Get-CIPPAzDataTableEntity @TenantsTable -Filter $Filter - if (!$Tenant.RowKey) { - $donotset = $true - $Tenant = [pscustomobject]@{ - GraphErrorCount = 0 - LastGraphTokenError = '' - LastGraphError = '' - PartitionKey = 'TenantFailed' - RowKey = 'Failed' + if ($UseSharedTokenCache -and $SharedTokenCacheKey) { + try { + $TokenPayloadJson = $AccessToken | ConvertTo-Json -Depth 20 -Compress + [CIPP.CIPPTokenCache]::Store($SharedTokenCacheKey, $TokenPayloadJson, [int64]$AccessToken.expires_on) + } catch { + # Ignore shared cache write failures + } } - } - $Tenant.LastGraphError = if ( $_.ErrorDetails.Message) { - if (Test-Json $_.ErrorDetails.Message -ErrorAction SilentlyContinue) { - $msg = $_.ErrorDetails.Message | ConvertFrom-Json - "$($msg.error):$($msg.error_description)" + + if ($ReturnRefresh) { return $AccessToken } + return @{ Authorization = "Bearer $($AccessToken.access_token)" } + } catch { + # Track consecutive Graph API failures + $TenantsTable = Get-CippTable -tablename Tenants + $Filter = "PartitionKey eq 'Tenants' and (defaultDomainName eq '{0}' or customerId eq '{0}')" -f $tenantid + $Tenant = Get-CIPPAzDataTableEntity @TenantsTable -Filter $Filter + if (!$Tenant.RowKey) { + $donotset = $true + $Tenant = [pscustomobject]@{ + GraphErrorCount = 0 + LastGraphTokenError = '' + LastGraphError = '' + PartitionKey = 'TenantFailed' + RowKey = 'Failed' + } + } + $Tenant.LastGraphError = if ( $_.ErrorDetails.Message) { + if (Test-Json $_.ErrorDetails.Message -ErrorAction SilentlyContinue) { + $msg = $_.ErrorDetails.Message | ConvertFrom-Json + "$($msg.error):$($msg.error_description)" + } else { + "$($_.ErrorDetails.Message)" + } } else { - "$($_.ErrorDetails.Message)" + $_.Exception.Message } - } else { - $_.Exception.Message - } - $Tenant.GraphErrorCount++ + $Tenant.GraphErrorCount++ - if (!$donotset) { Update-AzDataTableEntity -Force @TenantsTable -Entity $Tenant } - throw "Could not get token: $($Tenant.LastGraphError)" - } + if (!$donotset) { Update-AzDataTableEntity -Force @TenantsTable -Entity $Tenant } + throw "Could not get token: $($Tenant.LastGraphError)" + } } finally { # Always release the per-key lock if we acquired it if ($LockAcquired -and $SharedTokenCacheKey) { diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 index 7d619a133d9fe..4ff324b320611 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 @@ -8,80 +8,36 @@ function Get-GraphTokenFromCert { [string]$Scope = 'https://graph.microsoft.com/.default', [Parameter(Mandatory = $true)] - [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + [switch]$SkipCache ) ######################################################### # Create Bearer Token From Certificate for HBU Graph ######################################################### - # get sha256 hash of certificate - $sha256 = New-Object System.Security.Cryptography.SHA256CryptoServiceProvider - $hash = $sha256.ComputeHash($Certificate.RawData) - $hash = [Convert]::ToBase64String($hash) - - # Create JWT timestamp for expiration - $StartDate = (Get-Date '1970-01-01T00:00:00Z' ).ToUniversalTime() - $JWTExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End (Get-Date).ToUniversalTime().AddMinutes(2)).TotalSeconds - $JWTExpiration = [math]::Round($JWTExpirationTimeSpan, 0) - - # Create JWT validity start timestamp - $NotBeforeExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End ((Get-Date).ToUniversalTime())).TotalSeconds - $NotBefore = [math]::Round($NotBeforeExpirationTimeSpan, 0) - - # Create JWT header - $JWTHeader = @{ - alg = 'PS256' - typ = 'JWT' - # Use the CertificateBase64Hash and replace/strip to match web encoding of base64 - 'x5t#S256' = $hash -replace '\+', '-' -replace '/', '_' -replace '=' - } - - # Create JWT payload - $JWTPayLoad = @{ - # Issuer = your application - iss = $AppId - - # What endpoint is allowed to use this JWT - aud = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" - - # JWT ID: random guid - jti = [guid]::NewGuid() - - # Expiration timestamp - exp = $JWTExpiration - - # Not to be used before - nbf = $NotBefore - - # JWT Subject - sub = $AppId + # Check the token cache before building a new assertion. Uses the shared .NET cache when + # loaded (cross-runspace), otherwise a script-scope fallback (local dev). + $UseSharedTokenCache = ($SkipCache -ne $true) -and ($null -ne ('CIPP.CIPPTokenCache' -as [type])) + if ($UseSharedTokenCache) { + $TokenCacheKey = [CIPP.CIPPTokenCache]::BuildKey([string]$TenantId, [string]$Scope, $true, [string]$AppId, 'client_credentials_certificate') + $CacheEntry = [CIPP.CIPPTokenCache]::Lookup($TokenCacheKey, 120) + if ($CacheEntry.Found -and -not [string]::IsNullOrWhiteSpace($CacheEntry.TokenPayloadJson)) { + try { + return ($CacheEntry.TokenPayloadJson | ConvertFrom-Json -ErrorAction Stop) + } catch { + [CIPP.CIPPTokenCache]::Remove($TokenCacheKey) + } + } + } elseif ($SkipCache -ne $true) { + $ScriptCacheKey = "$TenantId|$AppId|$Scope" + $Cached = $script:CertTokenCache.$ScriptCacheKey + if ($Cached.expires_on -and [int](Get-Date -UFormat %s -Millisecond 0) -lt ($Cached.expires_on - 120)) { + return $Cached + } } - # Convert header and payload to base64 - $JWTHeaderToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTHeader | ConvertTo-Json)) - $EncodedHeader = [System.Convert]::ToBase64String($JWTHeaderToByte) - - $JWTPayLoadToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTPayload | ConvertTo-Json)) - $EncodedPayload = [System.Convert]::ToBase64String($JWTPayLoadToByte) - - # Join header and Payload with "." to create a valid (unsigned) JWT - $JWT = $EncodedHeader + '.' + $EncodedPayload - - # Get the private key object of your certificate - # $PrivateKey = $Certificate.PrivateKey - $PrivateKey = ([System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)) - - # Define RSA signature and hashing algorithm - $RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pss - $HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256 - - # Create a signature of the JWT - $Signature = [Convert]::ToBase64String( - $PrivateKey.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT), $HashAlgorithm, $RSAPadding) - ) -replace '\+', '-' -replace '/', '_' -replace '=' - - # Join the signature to the JWT with "." - $JWT = $JWT + '.' + $Signature + # Build the signed client assertion (shared with Get-GraphToken -UseCertificate) + $JWT = New-CIPPCertificateAssertion -TenantId $TenantId -AppId $AppId -Certificate $Certificate # Create a hash with body parameters $Body = @{ @@ -108,10 +64,38 @@ function Get-GraphTokenFromCert { Headers = $Header } - try { - return Invoke-CIPPRestMethod @PostSplat - } catch { - Write-Error $_ + # AADSTS700027 occurs transiently after certificate rotation while the load-balanced + # token service nodes catch up with the directory - retry briefly before giving up. + $MaxRetries = 3 + for ($Attempt = 1; $Attempt -le $MaxRetries; $Attempt++) { + try { + $AccessToken = Invoke-CIPPRestMethod @PostSplat -ErrorAction Stop + if ($AccessToken.access_token) { + if ($null -eq $AccessToken.expires_on -and $AccessToken.expires_in) { + $ExpiresOn = [int](Get-Date -UFormat %s -Millisecond 0) + $AccessToken.expires_in + Add-Member -InputObject $AccessToken -NotePropertyName 'expires_on' -NotePropertyValue $ExpiresOn -Force + } + if ($UseSharedTokenCache) { + try { + [CIPP.CIPPTokenCache]::Store($TokenCacheKey, ($AccessToken | ConvertTo-Json -Depth 20 -Compress), [int64]$AccessToken.expires_on) + } catch { + # Ignore shared cache write failures + } + } elseif ($SkipCache -ne $true) { + if (-not $script:CertTokenCache) { $script:CertTokenCache = [HashTable]::Synchronized(@{}) } + $script:CertTokenCache.$ScriptCacheKey = $AccessToken + } + } + return $AccessToken + } catch { + if ($Attempt -lt $MaxRetries -and $_.ErrorDetails.Message -match 'AADSTS700027') { + Write-Warning "Certificate not yet recognized by the token service (attempt $Attempt of $MaxRetries). Retrying in 10 seconds." + Start-Sleep -Seconds 10 + } else { + Write-Error $_ + return + } + } } } diff --git a/Modules/CIPPCore/Public/GraphHelper/New-CIPPCertificateAssertion.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-CIPPCertificateAssertion.ps1 new file mode 100644 index 0000000000000..bda4ea3aa77e6 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/New-CIPPCertificateAssertion.ps1 @@ -0,0 +1,94 @@ +function New-CIPPCertificateAssertion { + <# + .SYNOPSIS + Builds a PS256-signed JWT client assertion from a certificate + + .DESCRIPTION + Creates the signed JWT used as client_assertion when authenticating an app against the + Entra token endpoint with a certificate instead of a client secret. Shared by the + client_credentials flow (Get-GraphTokenFromCert) and the refresh_token flow + (Get-GraphToken -UseCertificate). + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantId, + + [Parameter(Mandatory = $true)] + [string]$AppId, + + [Parameter(Mandatory = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + # get sha256 hash of certificate for the x5t#S256 header + $sha256 = New-Object System.Security.Cryptography.SHA256CryptoServiceProvider + $hash = $sha256.ComputeHash($Certificate.RawData) + $hash = [Convert]::ToBase64String($hash) + + # Create JWT timestamp for expiration + $StartDate = (Get-Date '1970-01-01T00:00:00Z' ).ToUniversalTime() + $JWTExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End (Get-Date).ToUniversalTime().AddMinutes(2)).TotalSeconds + $JWTExpiration = [math]::Round($JWTExpirationTimeSpan, 0) + + # Create JWT validity start timestamp + $NotBeforeExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End ((Get-Date).ToUniversalTime())).TotalSeconds + $NotBefore = [math]::Round($NotBeforeExpirationTimeSpan, 0) + + # Create JWT header + $JWTHeader = @{ + alg = 'PS256' + typ = 'JWT' + # Use the CertificateBase64Hash and replace/strip to match web encoding of base64 + 'x5t#S256' = $hash -replace '\+', '-' -replace '/', '_' -replace '=' + } + + # Create JWT payload + $JWTPayLoad = @{ + # Issuer = your application + iss = $AppId + + # What endpoint is allowed to use this JWT + aud = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" + + # JWT ID: random guid + jti = [guid]::NewGuid() + + # Expiration timestamp + exp = $JWTExpiration + + # Not to be used before + nbf = $NotBefore + + # JWT Subject + sub = $AppId + } + + # Convert header and payload to base64 + $JWTHeaderToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTHeader | ConvertTo-Json)) + $EncodedHeader = [System.Convert]::ToBase64String($JWTHeaderToByte) + + $JWTPayLoadToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTPayload | ConvertTo-Json)) + $EncodedPayload = [System.Convert]::ToBase64String($JWTPayLoadToByte) + + # Join header and Payload with "." to create a valid (unsigned) JWT + $JWT = $EncodedHeader + '.' + $EncodedPayload + + # Get the private key object of your certificate + $PrivateKey = ([System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)) + + # Define RSA signature and hashing algorithm + $RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pss + $HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256 + + # Create a signature of the JWT + $Signature = [Convert]::ToBase64String( + $PrivateKey.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT), $HashAlgorithm, $RSAPadding) + ) -replace '\+', '-' -replace '/', '_' -replace '=' + + # Join the signature to the JWT with "." + return $JWT + '.' + $Signature +} diff --git a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 index f562d32c2eedc..01bae571afc76 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 @@ -20,7 +20,7 @@ function New-ExoRequest { [Parameter(Mandatory = $false, ParameterSetName = 'ExoRequest')] [bool]$useSystemMailbox, - [string]$tenantid, + [string]$tenantid = $env:TenantID, [bool]$NoAuthCheck, @@ -32,7 +32,8 @@ function New-ExoRequest { [switch]$AvailableCmdlets, $ModuleVersion = '3.9.2', - [switch]$AsApp + [switch]$AsApp, + [switch]$UseCertificate ) if ((Get-AuthorisedRequest -TenantID $tenantid) -or $NoAuthCheck -eq $True) { if ($Compliance.IsPresent) { @@ -40,7 +41,9 @@ function New-ExoRequest { } else { $Resource = 'https://outlook.office365.com' } - $token = Get-GraphToken -Tenantid $tenantid -scope "$Resource/.default" -AsApp:$AsApp.IsPresent + # -UseCertificate authenticates the app with the SAM certificate instead of the + # client secret: delegated (refresh token) by default, app-only with -AsApp + $token = Get-GraphToken -Tenantid $tenantid -scope "$Resource/.default" -AsApp:$AsApp.IsPresent -UseCertificate:$UseCertificate if ($cmdParams) { #if cmdParams is a pscustomobject, convert to hashtable, otherwise leave as is diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 index a943f97e3c743..a99502edcdd3f 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 @@ -19,6 +19,13 @@ function New-GraphGetRequest { [hashtable]$extraHeaders, [switch]$ReturnRawResponse, [switch]$SkipValueExtraction, + # Emit each page straight to the pipeline instead of buffering the whole + # paginated result. Peak memory becomes one page rather than the entire + # dataset — use for large collections piped into a streaming consumer + # (e.g. Set-CIPPDBCache* | Add-CIPPDbItem). Trade-off: on a mid-pagination + # failure, pages already emitted have flowed downstream before the throw. + [switch]$Stream, + [switch]$UseCertificate, $Headers ) @@ -32,11 +39,10 @@ function New-GraphGetRequest { if ($headers) { $headers = $Headers } else { - if ($scope -eq 'ExchangeOnline') { - $headers = Get-GraphToken -tenantid $tenantid -scope 'https://outlook.office365.com/.default' -AsApp $asapp -SkipCache $skipTokenCache - } else { - $headers = Get-GraphToken -tenantid $tenantid -scope $scope -AsApp $asapp -SkipCache $skipTokenCache - } + $TokenScope = if ($scope -eq 'ExchangeOnline') { 'https://outlook.office365.com/.default' } else { $scope } + # -UseCertificate authenticates the app with the SAM certificate instead of the + # client secret: delegated (refresh token) by default, app-only with -AsApp $true + $headers = Get-GraphToken -tenantid $tenantid -scope $TokenScope -AsApp $asapp -SkipCache $skipTokenCache -UseCertificate:$UseCertificate } if ($ComplexFilter) { $headers['ConsistencyLevel'] = 'eventual' @@ -58,7 +64,11 @@ function New-GraphGetRequest { } - $ReturnedData = do { + # Pagination loop. Its per-page output ($data.value etc.) is either buffered + # into $ReturnedData and returned (default), or streamed straight to the + # pipeline (-Stream). Same loop body either way — see the dispatch below. + $Pager = { + do { $RetryCount = 0 $MaxRetries = 3 $RequestSuccessful = $false @@ -180,7 +190,14 @@ function New-GraphGetRequest { } } while (-not $RequestSuccessful -and $RetryCount -le $MaxRetries) } until ([string]::IsNullOrEmpty($NextURL) -or $NextURL -is [object[]] -or ' ' -eq $NextURL) - return $ReturnedData + } + + if ($Stream) { + & $Pager + } else { + $ReturnedData = & $Pager + return $ReturnedData + } } else { Write-Error 'Not allowed. You cannot manage your own tenant or tenants not under your scope' } diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 index c69c34636766c..1eeb101e7c309 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 @@ -19,6 +19,7 @@ function New-GraphPOSTRequest { $returnHeaders = $false, $maxRetries = 3, $ScheduleRetry = $false, + [switch]$UseCertificate, $headers ) @@ -26,7 +27,9 @@ function New-GraphPOSTRequest { if ($Headers) { $Headers = $Headers } else { - $Headers = Get-GraphToken -tenantid $tenantid -scope $scope -AsApp $asapp -SkipCache $skipTokenCache + # -UseCertificate authenticates the app with the SAM certificate instead of the + # client secret: delegated (refresh token) by default, app-only with -AsApp $true + $Headers = Get-GraphToken -tenantid $tenantid -scope $scope -AsApp $asapp -SkipCache $skipTokenCache -UseCertificate:$UseCertificate } if ($AddedHeaders) { foreach ($header in $AddedHeaders.GetEnumerator()) { @@ -55,6 +58,7 @@ function New-GraphPOSTRequest { } catch { $ShouldRetry = $false $WaitTime = 0 + $RetryReason = '' $RawErrorBody = $_.ErrorDetails.Message $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message @@ -67,20 +71,24 @@ function New-GraphPOSTRequest { $RetryAfterHeader = $_.Exception.Response.Headers['Retry-After'] if ($RetryAfterHeader) { $WaitTime = [int]$RetryAfterHeader - Write-Warning "Rate limited (429). Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $maxRetries" - $ShouldRetry = $true + $RetryReason = 'Rate limited (429).' + } else { + $WaitTime = Get-Random -Minimum 1.1 -Maximum 4.1 + $RetryReason = 'Rate limited (429) with no Retry-After header.' } + $ShouldRetry = $true } # Check for "Resource temporarily unavailable" elseif ($Message -like '*Resource temporarily unavailable*' -or $Message -like '*Too many requests*') { $WaitTime = Get-Random -Minimum 1.1 -Maximum 3.1 - Write-Warning "Resource temporarily unavailable. Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $maxRetries" + $RetryReason = 'Resource temporarily unavailable.' $ShouldRetry = $true } if ($ShouldRetry) { $RetryCount++ if ($RetryCount -lt $maxRetries) { + Write-Warning "$RetryReason Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $maxRetries" Start-Sleep -Seconds $WaitTime } } else { @@ -113,6 +121,7 @@ function New-GraphPOSTRequest { if ($IgnoreErrors) { $RetryParameters.IgnoreErrors = $IgnoreErrors } if ($returnHeaders) { $RetryParameters.ReturnHeaders = $returnHeaders } if ($maxRetries) { $RetryParameters.maxRetries = $maxRetries } + if ($UseCertificate) { $RetryParameters.UseCertificate = $true } # Create the scheduled task object $TaskObject = [PSCustomObject]@{ diff --git a/Modules/CIPPCore/Public/GraphHelper/New-TeamsRequestV2.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-TeamsRequestV2.ps1 new file mode 100644 index 0000000000000..71972d62a4157 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/New-TeamsRequestV2.ps1 @@ -0,0 +1,198 @@ +function New-TeamsRequestV2 { + <# + .SYNOPSIS + Talks to the Teams admin ConfigAPI (api.interfaces.records.teams.microsoft.com) + directly, instead of the MicrosoftTeams PowerShell module. + + .DESCRIPTION + The MicrosoftTeams module routes policy-definition writes through the legacy + /Skype.Policy/tenants/policies surface, which returns 40301 Forbidden under CSP/GDAP + (for both delegate-with-Teams-Admin and app-only-with-Global-Admin tokens). The Teams + admin center (ACMS) instead uses /Skype.Policy/configurations/{Type}/configuration/{Identity}, + which authorizes fine with the roles CIPP already has. This helper speaks that surface. + + All HTTP goes through Invoke-CIPPRestMethod (pooled CIPP.CIPPRestClient) so gzip + responses are decompressed and JSON is deserialized consistently across platforms + (raw Invoke-RestMethod does NOT decompress gzip in the Linux worker, which yields + garbage/null objects and unreadable error bodies). + + Operations: + Get -> GET configurations/{Type}/configuration/{Identity} (single object) + Get -ListAll -> GET configurations/{Type} (all instances, array) + Set -> PUT configurations/{Type}/configuration/{Identity} (MERGE; only changed props) + New -> PUT configurations/{Type}/configuration/{Identity} (create named policy) [best-effort] + Remove -> DELETE configurations/{Type}/configuration/{Identity} [best-effort] + + .PARAMETER TenantFilter + Target tenant (GUID or default domain). + + .PARAMETER Type + ConfigAPI type (e.g. 'TeamsMeetingPolicy'), a cmdlet noun, or a full cmdlet name + (e.g. 'Set-CsTenantFederationConfiguration'); the verb is stripped and known + noun->type aliases are applied automatically. + + .PARAMETER Action + Get (default) | Set | New | Remove. + + .PARAMETER Identity + Policy instance identity. Default 'Global'. (e.g. 'Tag:Default' or a custom name.) + + .PARAMETER Parameters + Hashtable of properties to write (Set/New). Only these are sent (merge). + + .PARAMETER ListAll + Get: return every instance of the type (array) instead of one identity. + + .PARAMETER AsApp + Use an app-only (client_credentials) token instead of the delegate token. + + .PARAMETER NoRead + Set: skip the read-for-Key step and PUT only the bare changed props (ACMS-style). + Faster; safe for flat/Host-authority types and federation. Default is read-modify-write + (adds the Key envelope when the type carries one) for maximum compatibility. + + .PARAMETER UseServiceDiscovery + Resolve the per-tenant ConfigApi host + X-MS-Forest via Teams.Tenant/tenants and use + them (and, for federation/ACS types, the OcsPowershellWebservice target headers). + + .EXAMPLE + New-TeamsRequestV2 -TenantFilter $t -Type TeamsMeetingPolicy -Action Set -Parameters @{ AllowAnonymousUsersToJoinMeeting = $false } + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] $TenantFilter, + [Parameter(Mandatory)] [string] $Type, + [ValidateSet('Get', 'Set', 'New', 'Remove')] [string] $Action = 'Get', + [string] $Identity = 'Global', + [hashtable] $Parameters = @{}, + [switch] $ListAll, + [switch] $AsApp, + [switch] $NoRead, + [switch] $UseServiceDiscovery + ) + + # ---- cmdlet-noun -> ConfigAPI type aliases (noun != type) ---- + $TypeAliases = @{ + 'TenantFederationConfiguration' = 'TenantFederationSettings' + } + # types backed by the legacy OcsPowershellWebservice (need target-uri headers via discovery) + $FederationTypes = @('TenantFederationSettings', 'TeamsAcsFederationConfiguration') + + # normalize Type: strip Get-/Set-/New-/Remove-Cs prefix, then alias + $ConfigType = $Type -replace '^(Get|Set|New|Remove|Grant|Revoke)-Cs', '' + if ($TypeAliases.ContainsKey($ConfigType)) { $ConfigType = $TypeAliases[$ConfigType] } + + # ---- token ---- + $TokenSplat = @{ tenantid = $TenantFilter; scope = '48ac35b8-9aa8-4d74-927d-1f4a14a0b239/.default' } + if ($AsApp) { $TokenSplat['AsApp'] = $true } + $TeamsToken = (Get-GraphToken @TokenSplat).Authorization -replace 'Bearer ' + + # ---- endpoint + forest (service discovery optional) ---- + $ApiHost = 'api.interfaces.records.teams.microsoft.com' + $Forest = $null + $AdminServiceEndpoint = $null + $AdminDomain = $null + if ($UseServiceDiscovery) { + try { + $disc = Invoke-CIPPRestMethod -Uri "https://$ApiHost/Teams.Tenant/tenants" -Method GET ` + -Headers @{ Authorization = "Bearer $TeamsToken"; Accept = 'application/json' } + if ($disc.serviceDiscovery.Endpoints.ConfigApiEndpoint) { $ApiHost = $disc.serviceDiscovery.Endpoints.ConfigApiEndpoint } + $Forest = $disc.serviceDiscovery.Headers.'X-MS-Forest' + $AdminServiceEndpoint = $disc.serviceDiscovery.Endpoints.AdminServiceEndpoint + $AdminDomain = ($disc.verifiedDomains | Where-Object { $_.name -like '*.onmicrosoft.com' -and $_.name -notlike '*.*.onmicrosoft.com' } | Select-Object -First 1).name + } catch { + Write-Verbose "Service discovery failed, using defaults: $($_.Exception.Message)" + } + } + $Base = "https://$ApiHost/Skype.Policy/configurations/$ConfigType" + + # ---- headers ---- + $Headers = @{ + Authorization = "Bearer $TeamsToken" + Accept = 'application/json' + 'x-authz-scope' = 'tenant' + 'x-ms-correlation-id' = (New-Guid).Guid + } + if ($Forest) { $Headers['x-ms-forest'] = $Forest } + if ($ConfigType -in $FederationTypes -and $AdminServiceEndpoint) { + $Headers['x-ms-target-uri'] = "https://$AdminServiceEndpoint/OcsPowershellWebservice" + $Headers['x-ms-tenant-id'] = $TenantFilter + } + $Query = if ($ConfigType -in $FederationTypes -and $AdminDomain) { "?adminDomain=$AdminDomain" } else { '' } + + switch ($Action) { + + 'Get' { + $Uri = if ($ListAll) { "$Base$Query" } else { "$Base/configuration/$Identity$Query" } + return Invoke-CIPPRestMethod -Uri $Uri -Method GET -Headers $Headers + } + + 'Set' { + $Uri = "$Base/configuration/$Identity$Query" + + # build body of only the changed props (skip control keys) + $Body = [ordered]@{} + foreach ($k in $Parameters.Keys) { + if ($k -in @('Identity', 'ErrorAction', 'Confirm', 'WhatIf', 'Verbose', 'Debug')) { continue } + $Body[$k] = $Parameters[$k] + } + + if (-not $NoRead) { + # read-modify-write: include the Key envelope when the type carries one (max compat) + try { + $Current = Invoke-CIPPRestMethod -Uri $Uri -Method GET -Headers $Headers + if ($Current -and ($Current.PSObject.Properties.Name -contains 'Key')) { + $Merged = [ordered]@{ Identity = $Identity; Key = $Current.Key } + foreach ($k in $Body.Keys) { $Merged[$k] = $Body[$k] } + $Body = $Merged + } + } catch { + Write-Verbose "Pre-read failed ($($_.Exception.Message)); sending bare props." + } + } + + $Json = $Body | ConvertTo-Json -Depth 25 -Compress + $StatusCode = $null + $RespBody = Invoke-CIPPRestMethod -Uri $Uri -Method PUT -Body $Json -ContentType 'application/json' ` + -Headers $Headers -SkipHttpErrorCheck -StatusCodeVariable StatusCode + if ([int]$StatusCode -ge 400) { + $Detail = if ($RespBody -is [string]) { $RespBody } elseif ($null -ne $RespBody) { $RespBody | ConvertTo-Json -Compress -Depth 10 } else { '' } + throw "Teams ConfigApi Set $ConfigType/$Identity failed: $StatusCode $Detail" + } + return [pscustomobject]@{ Type = $ConfigType; Identity = $Identity; StatusCode = [int]$StatusCode } + } + + 'New' { + # best-effort: create a named policy by PUTting the props at a new identity + $Uri = "$Base/configuration/$Identity$Query" + $Body = [ordered]@{ Identity = $Identity } + foreach ($k in $Parameters.Keys) { + if ($k -in @('Identity', 'ErrorAction', 'Confirm', 'WhatIf', 'Verbose', 'Debug')) { continue } + $Body[$k] = $Parameters[$k] + } + $Json = $Body | ConvertTo-Json -Depth 25 -Compress + $StatusCode = $null + $RespBody = Invoke-CIPPRestMethod -Uri $Uri -Method PUT -Body $Json -ContentType 'application/json' ` + -Headers $Headers -SkipHttpErrorCheck -StatusCodeVariable StatusCode + if ([int]$StatusCode -ge 400) { + $Detail = if ($RespBody -is [string]) { $RespBody } elseif ($null -ne $RespBody) { $RespBody | ConvertTo-Json -Compress -Depth 10 } else { '' } + throw "Teams ConfigApi New $ConfigType/$Identity failed: $StatusCode $Detail" + } + return [pscustomobject]@{ Type = $ConfigType; Identity = $Identity; StatusCode = [int]$StatusCode } + } + + 'Remove' { + $Uri = "$Base/configuration/$Identity$Query" + $StatusCode = $null + $RespBody = Invoke-CIPPRestMethod -Uri $Uri -Method DELETE -Headers $Headers -SkipHttpErrorCheck -StatusCodeVariable StatusCode + if ([int]$StatusCode -ge 400) { + $Detail = if ($RespBody -is [string]) { $RespBody } elseif ($null -ne $RespBody) { $RespBody | ConvertTo-Json -Compress -Depth 10 } else { '' } + throw "Teams ConfigApi Remove $ConfigType/$Identity failed: $StatusCode $Detail" + } + return [pscustomobject]@{ Type = $ConfigType; Identity = $Identity; StatusCode = [int]$StatusCode } + } + } +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 b/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 index 5b3ed653fe58c..ae206f1cd3f0f 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 @@ -3,10 +3,10 @@ function Set-CIPPOffloadFunctionTriggers { .SYNOPSIS Manages non-HTTP triggers on function apps based on offloading configuration. .DESCRIPTION - Automatically detects if running on an offloaded function app (contains hyphen in name). - If this is the main function app (no hyphen), checks the offloading state from Config table - and disables/enables timer, activity, orchestrator, and queue triggers accordingly. - Offloaded function apps (with hyphen) are skipped as they should have triggers enabled. + Automatically detects if running on an offloaded function app (name ends with a known + offload suffix). If this is the main function app, checks the offloading state from the + Config table and disables/enables timer, activity, orchestrator, and queue triggers + accordingly. Offloaded function apps are skipped as they should have triggers enabled. .EXAMPLE Set-CIPPOffloadFunctionTriggers Automatically manages triggers based on current function app context and offloading state. @@ -17,8 +17,9 @@ function Set-CIPPOffloadFunctionTriggers { # Get current function app name $FunctionAppName = $env:WEBSITE_SITE_NAME - # Check if this is an offloaded function app (contains hyphen) - if ($FunctionAppName -match '-') { + # Check if this is an offloaded function app (name ends with a known offload suffix). + # A dashed main-app name (e.g. 'compaction-01-z2ir2') is NOT offloaded. + if (Test-CippOffloadFunctionApp -SiteName $FunctionAppName) { return $true } diff --git a/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 b/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 index d585e01220894..566f250f36c9f 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 @@ -124,9 +124,19 @@ function Update-AppManagementPolicy { $DefaultPolicyBlocksCredentials = ($DefaultPolicy.applicationRestrictions.passwordCredentials | Where-Object { $_.restrictionType -in @('passwordAddition', 'symmetricKeyAddition') -and $_.state -eq 'enabled' }).Count -gt 0 } + # Determine if default policy restricts key credentials: asymmetricKeyLifetime caps + # certificate validity (blocks the 1 year SAM certificate), trustedCertificateAuthority + # blocks self-signed certificates entirely + $DefaultKeyRestrictions = @() + $DefaultPolicyBlocksKeyCredentials = $false + if ($DefaultPolicy.applicationRestrictions.keyCredentials) { + $DefaultKeyRestrictions = @($DefaultPolicy.applicationRestrictions.keyCredentials | Where-Object { $_.restrictionType -in @('asymmetricKeyLifetime', 'trustedCertificateAuthority') -and $_.state -eq 'enabled' }) + $DefaultPolicyBlocksKeyCredentials = $DefaultKeyRestrictions.Count -gt 0 + } + # If default policy blocks credentials and CIPP app doesn't have an exemption, create/update policy $PolicyAction = $null - if ($DefaultPolicyBlocksCredentials -and $CIPPApp) { + if (($DefaultPolicyBlocksCredentials -or $DefaultPolicyBlocksKeyCredentials) -and $CIPPApp) { # Check if a CIPP-SAM Exemption Policy already exists $ExistingExemptionPolicy = $AppPolicies | Where-Object { $_.displayName -eq 'CIPP Exemption Policy' } | Select-Object -First 1 @@ -141,6 +151,14 @@ function Update-AppManagementPolicy { # No password restrictions means it allows credentials $CIPPHasExemption = $true } + # When the default policy restricts key credentials, the exemption must explicitly + # disable those restriction types - otherwise certificate rotation stays blocked + if ($CIPPHasExemption -and $DefaultPolicyBlocksKeyCredentials) { + foreach ($Restriction in $DefaultKeyRestrictions) { + $ExplicitlyDisabled = $CIPPPolicy.restrictions.keyCredentials | Where-Object { $_.restrictionType -eq $Restriction.restrictionType -and $_.state -eq 'disabled' } + if (-not $ExplicitlyDisabled) { $CIPPHasExemption = $false } + } + } } if (-not $CIPPHasExemption) { @@ -164,7 +182,18 @@ function Update-AppManagementPolicy { restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' } ) - keyCredentials = @() + keyCredentials = @( + @{ + restrictionType = 'asymmetricKeyLifetime' + state = 'disabled' + restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' + } + @{ + restrictionType = 'trustedCertificateAuthority' + state = 'disabled' + restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' + } + ) } } diff --git a/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 new file mode 100644 index 0000000000000..3d5ae1633f994 --- /dev/null +++ b/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 @@ -0,0 +1,314 @@ +function Invoke-CIPPCATemplateBatch { + + # Future Use - Not currently used + + <# + .SYNOPSIS + Deploy all Conditional Access template standards for a single tenant in one + sequential pass, reconciling shared dependencies once up front. + .DESCRIPTION + Per-tenant batch path for the ConditionalAccessTemplate standard. Replaces the + previous one-activity-per-template fan-out (which caused 429 storms against the + ~1 req/s CA write endpoint, plus duplicate-dependency / c1-c99 / 1040 races) with a + single serial deployment. Dependencies (named locations, auth contexts, auth + strengths) are reconciled ONCE via Resolve-CIPPCADependencies, then each policy is + deployed sequentially using New-CIPPCAPolicy -DependencyMap. Reporting is folded + into the same loop and remains per-template (one compare field per template). + + Dispatched internally from Push-CIPPStandard when a grouped batch item (carrying + BatchTemplates) is dequeued. This is NOT a user-selectable standard. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Tenant, + $Templates, + [int64]$QueuedTime = 0, + $Headers + ) + + $Templates = @($Templates | Where-Object { $_ }) + if ($Templates.Count -eq 0) { + Write-Information "No CA templates to deploy for $Tenant" + return + } + + # Always refresh the CA cache first. This is one long-running activity and both Phase 1 + # (dependency reconciliation) and Phase 2 (existing-policy checks / reporting) read off + # the snapshot, so it must reflect current tenant state before we begin. + try { + Write-Information "Refreshing Conditional Access DB cache for $Tenant before batch deploy" + Set-CIPPDBCacheConditionalAccessPolicies -TenantFilter $Tenant + } catch { + Write-Warning "Failed to refresh CA cache for $Tenant : $($_.Exception.Message)" + } + + # General Entra license gate - applies to every CA template in the batch + $TestResult = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_general' -TenantFilter $Tenant -Preset Entra + if ($TestResult -eq $false) { + foreach ($t in $Templates) { + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($t.Settings.TemplateList.value)" -FieldValue 'This tenant does not have the required license for this standard.' -Tenant $Tenant + } + return + } + + # Preload snapshots from the freshly-updated cache + try { + $AllCAPolicies = New-CIPPDbRequest -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + $PreloadedLocations = New-CIPPDbRequest -TenantFilter $Tenant -Type 'NamedLocations' + $PreloadedSecurityDefaults = New-CIPPDbRequest -TenantFilter $Tenant -Type 'SecurityDefaults' + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not load the ConditionalAccessTemplate cache for $Tenant. Error: $ErrorMessage" -Sev Error + return + } + # Auth strengths are cached; auth contexts are not - Resolve-CIPPCADependencies fetches + # contexts live and falls back to a live fetch if the strengths snapshot is empty. + try { $PreloadedAuthStrengths = New-CIPPDbRequest -TenantFilter $Tenant -Type 'AuthenticationStrengths' } catch { $PreloadedAuthStrengths = $null } + + # Preload tenant-wide lookups ONCE for the whole batch (reused by every policy deploy and the + # report conversion). Without this, New-CIPPCAPolicy and New-CIPPCATemplate each re-fetch + # users/groups/servicePrincipals/vacation-groups per policy - dozens of redundant Graph calls. + $UGRequests = @( + @{ id = 'users'; url = 'users?$select=id,displayName&$top=999'; method = 'GET' } + @{ id = 'groups'; url = 'groups?$select=id,displayName&$top=999'; method = 'GET' } + ) + $PreloadedUsers = $null; $PreloadedGroups = $null; $PreloadedServicePrincipals = $null; $PreloadedVacationGroups = $null + try { + $UGResults = New-GraphBulkRequest -Requests $UGRequests -tenantid $Tenant -asapp $true + $PreloadedUsers = ($UGResults | Where-Object { $_.id -eq 'users' }).body.value + $PreloadedGroups = ($UGResults | Where-Object { $_.id -eq 'groups' }).body.value + } catch { Write-Warning "Failed to preload users/groups for $Tenant : $($_.Exception.Message)" } + try { + $PreloadedServicePrincipals = New-GraphGETRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId&$top=999' -tenantid $Tenant -asApp $true + } catch { Write-Warning "Failed to preload service principals for $Tenant : $($_.Exception.Message)" } + try { + $PreloadedVacationGroups = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=startsWith(displayName,'Vacation Exclusion')&`$select=id,displayName&`$top=999&`$count=true" -ComplexFilter -tenantid $Tenant -asApp $true + } catch { Write-Warning "Failed to preload vacation exclusion groups for $Tenant : $($_.Exception.Message)" } + + $Table = Get-CippTable -tablename 'templates' + + # Load each template's JSON once + $CATemplates = foreach ($t in $Templates) { + $TemplateValue = $t.Settings.TemplateList.value + $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$TemplateValue'" + $JSON = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON + if (-not $JSON) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Conditional Access template '$($t.Settings.TemplateList.label)' ($TemplateValue) could not be loaded from the template store - skipping." -Sev 'Error' + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$TemplateValue" -FieldValue "Template '$($t.Settings.TemplateList.label)' could not be loaded from the template store." -Tenant $Tenant + continue + } + [pscustomobject]@{ + Settings = $t.Settings + TemplateId = $t.TemplateId + RawJSON = $JSON + WillRemediate = $false + NeedsRemediation = $false + Skip = $false + DeployError = $null + } + } + $CATemplates = @($CATemplates) + if ($CATemplates.Count -eq 0) { return } + + # Resolve P2 capability once for the whole tenant (reused for every risk-based policy) + $TenantHasP2 = [bool](Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -Preset EntraP2 -SkipLog) + + # Nested helper: compare ONE template against the current deployed state, write its compare + # field in the renderable CurrentValue/ExpectedValue shape, and return a status string: + # 'Compliant' | 'Drifted' | 'Missing' | 'P2Blocked' | 'Failed' | 'Error'. + function Set-CABatchCompareStatus { + param($ct) + $Settings = $ct.Settings + $TemplateValue = $Settings.TemplateList.value + $FieldName = "standards.ConditionalAccessTemplate.$TemplateValue" + + Set-CippStandardInfoContext -StandardInfo @{ + Standard = 'ConditionalAccessTemplate' + StandardTemplateId = $ct.TemplateId + ConditionalAccessTemplateId = $TemplateValue + } + + try { + $Policy = $ct.RawJSON | ConvertFrom-Json -Depth 10 + if ($null -eq $Policy) { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Template '$($Settings.TemplateList.label)' could not be parsed." } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Error' + } + + # Override the template's state with the standard's state for drift comparison + if ($Settings.state -and $Settings.state -ne 'donotchange') { + $Policy | Add-Member -NotePropertyName 'state' -NotePropertyValue $Settings.state -Force + } + + # Resolve the template's location GUIDs to display names so they compare like-for-like + # with the deployed policy. The template's OWN LocationInfo carries the id->name map + # (the GUID is the source tenant's id); fall back to this tenant's named-location cache. + if ($Policy.conditions.locations) { + $LocNameById = @{} + foreach ($li in @($Policy.LocationInfo)) { if ($li.id -and $li.displayName) { $LocNameById[$li.id] = $li.displayName } } + foreach ($pl in @($PreloadedLocations)) { if ($pl.id -and $pl.displayName -and -not $LocNameById.ContainsKey($pl.id)) { $LocNameById[$pl.id] = $pl.displayName } } + foreach ($LocDir in 'includeLocations', 'excludeLocations') { + if ($Policy.conditions.locations.PSObject.Properties.Name -contains $LocDir -and $Policy.conditions.locations.$LocDir) { + $Policy.conditions.locations.$LocDir = @($Policy.conditions.locations.$LocDir | ForEach-Object { + if ($LocNameById.ContainsKey($_)) { $LocNameById[$_] } else { $_ } + }) + } + } + } + + $CheckExisting = $AllCAPolicies | Where-Object -Property displayName -EQ $Settings.TemplateList.label + if ($CheckExisting -is [array] -and $CheckExisting.Count -gt 1) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Found $($CheckExisting.Count) Conditional Access policies named '$($Settings.TemplateList.label)' in $Tenant. Comparing against the first; duplicate policies should be cleaned up." -Sev 'Warning' + $CheckExisting = $CheckExisting[0] + } + + if (!$CheckExisting) { + $NeedsP2 = ($Policy.conditions.userRiskLevels.Count -gt 0 -or $Policy.conditions.signInRiskLevels.Count -gt 0) + if ($ct.DeployError) { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Failed to deploy: $($ct.DeployError)" } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Failed' + } elseif ($NeedsP2 -and -not $TenantHasP2) { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Policy requires an AAD Premium P2 license, which this tenant does not have.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'P2Blocked' + } else { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Policy is missing from this tenant.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Missing' + } + } + + $templateResult = New-CIPPCATemplate -TenantFilter $Tenant -JSON $CheckExisting -preloadedLocations $PreloadedLocations -preloadedUsers $PreloadedUsers -preloadedGroups $PreloadedGroups + $CompareObj = ConvertFrom-Json -ErrorAction SilentlyContinue -InputObject $templateResult + if ($null -eq $CompareObj) { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Tenant policy conversion returned null.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Error' + } + try { + $Compare = Compare-CIPPIntuneObject -ReferenceObject $Policy -DifferenceObject $CompareObj -CompareType 'ca' + } catch { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Error comparing policy: $($_.Exception.Message)" } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Error' + } + if (!$Compare) { + Set-CIPPStandardsCompareField -FieldName $FieldName -FieldValue $true -CurrentValue @{ Differences = 'No Differences found' } -ExpectedValue @{ Differences = 'No Differences found' } -Tenant $Tenant + return 'Compliant' + } else { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = $Compare } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Drifted' + } + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Error evaluating conditional access template $TemplateValue. Error: $ErrorMessage" -sev 'Error' + return 'Error' + } + } + + # Per-template rerun decision (marker written here so a redelivered batch skips handled ones) + foreach ($ct in $CATemplates) { + if ($QueuedTime -gt 0) { + $API = "ConditionalAccessTemplate_$($ct.TemplateId)_$($ct.Settings.TemplateList.value)" + if (Test-CIPPRerun -Type Standard -Tenant $Tenant -API $API -Settings $ct.Settings -BaseTime $QueuedTime) { + Write-Information "Detected rerun for $API. Skipping." + $ct.Skip = $true + } + } + } + + # ---- Evaluate: compare every in-scope template against the CURRENT state, write its result, + # and flag only the ones that actually need remediation (missing or drifted). Compliant + # policies are reported and then left untouched - no needless PATCH on every run. ---- + foreach ($ct in $CATemplates) { + if ($ct.Skip) { continue } + if (-not ($ct.Settings.report -eq $true -or $ct.Settings.remediate -eq $true)) { continue } + $Status = Set-CABatchCompareStatus -ct $ct + if ($ct.Settings.remediate -eq $true -and $Status -in @('Missing', 'Drifted')) { + $ct.WillRemediate = $true + $ct.NeedsRemediation = $true + } + } + + # ---- Reconcile shared dependencies ONCE, only for the policies we will actually deploy ---- + $DeployObjects = [System.Collections.Generic.List[object]]::new() + foreach ($ct in $CATemplates) { + if ($ct.NeedsRemediation) { $DeployObjects.Add(($ct.RawJSON | ConvertFrom-Json)) } + } + $DependencyMap = $null + if ($DeployObjects.Count -gt 0) { + try { + $DependencyMap = Resolve-CIPPCADependencies -TenantFilter $Tenant -PolicyObjects $DeployObjects -AllNamedLocations $PreloadedLocations -AllAuthStrengthPolicies $PreloadedAuthStrengths -Overwrite $true -Headers $Headers -APIName 'Standards' + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to reconcile Conditional Access dependencies for $Tenant. Error: $ErrorMessage" -sev 'Error' + $DependencyMap = $null + } + } + + # ---- Remediate: deploy only the missing/drifted policies, sequentially ---- + $RemediatedAny = $false + foreach ($ct in $CATemplates) { + if (-not $ct.NeedsRemediation -or -not $DependencyMap) { continue } + $Settings = $ct.Settings + $TemplateValue = $Settings.TemplateList.value + Set-CippStandardInfoContext -StandardInfo @{ + Standard = 'ConditionalAccessTemplate' + StandardTemplateId = $ct.TemplateId + ConditionalAccessTemplateId = $TemplateValue + } + try { + $NewCAPolicy = @{ + replacePattern = 'displayName' + TenantFilter = $Tenant + state = $Settings.state + RawJSON = $ct.RawJSON + Overwrite = $true + APIName = 'Standards' + Headers = $Headers + DisableSD = $Settings.DisableSD + CreateGroups = $Settings.CreateGroups ?? $false + PreloadedCAPolicies = $AllCAPolicies + PreloadedLocations = $PreloadedLocations + PreloadedSecurityDefaults = $PreloadedSecurityDefaults + DependencyMap = $DependencyMap + PreloadedServicePrincipals = $PreloadedServicePrincipals + PreloadedUsers = $PreloadedUsers + PreloadedGroups = $PreloadedGroups + PreloadedVacationGroups = $PreloadedVacationGroups + } + $null = New-CIPPCAPolicy @NewCAPolicy + $RemediatedAny = $true + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + # Record the deploy failure so the re-report surfaces the reason instead of "missing" + $ct.DeployError = $ErrorMessage + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to create or update conditional access rule from template $TemplateValue. Error: $ErrorMessage" -sev 'Error' + } + } + + # ---- Refresh + re-report ONLY the policies we just deployed ---- + # Only refresh when something actually changed ($RemediatedAny is set on a successful + # create/update). When nothing was deployed there's no need to re-pull the cache at all. + if ($RemediatedAny) { + # Give Graph a moment to propagate the new/updated policies before refreshing the cache, + # otherwise the refresh can race ahead of eventual consistency and miss just-created ones. + Start-Sleep -Seconds 5 + try { + Set-CIPPDBCacheConditionalAccessPolicies -TenantFilter $Tenant + $AllCAPolicies = New-CIPPDbRequest -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + $PreloadedLocations = New-CIPPDbRequest -TenantFilter $Tenant -Type 'NamedLocations' + # Refresh groups too so the report resolves any exclusion groups just created during deploy + $UGResults = New-GraphBulkRequest -Requests $UGRequests -tenantid $Tenant -asapp $true + $PreloadedUsers = ($UGResults | Where-Object { $_.id -eq 'users' }).body.value + $PreloadedGroups = ($UGResults | Where-Object { $_.id -eq 'groups' }).body.value + } catch { + Write-Warning "Failed to refresh CA snapshot after remediation for $Tenant : $($_.Exception.Message)" + } + foreach ($ct in $CATemplates) { + if ($ct.NeedsRemediation -and -not $ct.Skip) { + $null = Set-CABatchCompareStatus -ct $ct + } + } + } + + Set-CippStandardInfoContext -StandardInfo $null +} diff --git a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index fda21748c5147..1c2eb64addd24 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -15,6 +15,7 @@ function Invoke-CIPPDBCacheCollection { - ConditionalAccess: CA policies and registration details - IdentityProtection: Risky users/SPs, risk detections, PIM - Intune: Managed devices, policies, app protection + - Defender: Defender Vulnerabilities .PARAMETER CollectionType The group of cache functions to execute @@ -31,7 +32,7 @@ function Invoke-CIPPDBCacheCollection { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet('Graph', 'ExchangeConfig', 'ExchangeData', 'ConditionalAccess', 'IdentityProtection', 'Intune', 'Compliance', 'CopilotUsage', 'SharePoint', 'Teams')] + [ValidateSet('Graph', 'ExchangeConfig', 'ExchangeData', 'ConditionalAccess', 'IdentityProtection', 'Intune', 'Compliance', 'CopilotUsage', 'SharePoint', 'Teams', 'Defender')] [string]$CollectionType, [Parameter(Mandatory = $true)] @@ -149,11 +150,15 @@ function Invoke-CIPPDBCacheCollection { 'CsExternalAccessPolicy' 'CsTenantFederationConfiguration' 'CsTeamsMessagingPolicy' + 'CsTeamsMessagingConfiguration' 'CsTeamsAppPermissionPolicy' 'Teams' 'TeamsActivity' 'TeamsVoice' ) + Defender = @( + 'DefenderCVEs' + ) } $CacheTypes = $Collections[$CollectionType] diff --git a/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 index 88d7e2bc5ba5e..1c3d7345c4177 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 @@ -243,7 +243,7 @@ function Invoke-CIPPRestMethod { # ------------------------------------------------------------------ if (-not $SkipHttpErrorCheck -and -not $Result.IsSuccess) { $ErrorMessage = "Response status code does not indicate success: $($Result.StatusCode)" - $Exception = [System.Net.Http.HttpRequestException]::new($ErrorMessage) + $Exception = [CIPP.CIPPHttpRequestException]::new($ErrorMessage, [int]$Result.StatusCode, $Result.ResponseHeaders, $Result.Content) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new($Exception, 'WebCmdletWebResponseException', [System.Management.Automation.ErrorCategory]::InvalidOperation, $Uri) if (-not [string]::IsNullOrWhiteSpace($Result.Content)) { $ErrorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new($Result.Content) diff --git a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 index e03fd8ee901f7..1963ff0b6de30 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 @@ -17,6 +17,7 @@ function Invoke-CIPPTestCollection { - CIS → Invoke-CippTestCIS_* - SMB1001 → Invoke-CippTestSMB1001_* - CopilotReadiness → Invoke-CippTestCopilotReady* + - E8 → Invoke-CippTestE8_* - Custom → Special: enumerates enabled ScriptGuids from DB and calls Invoke-CippTestCustomScripts once per guid (the function requires a ScriptGuid parameter to filter the table query) @@ -33,7 +34,7 @@ function Invoke-CIPPTestCollection { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom')] + [ValidateSet('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'E8', 'Custom')] [string]$SuiteName, [Parameter(Mandatory = $true)] @@ -51,6 +52,7 @@ function Invoke-CIPPTestCollection { SMB1001 = 'Invoke-CippTestSMB1001_*' CopilotReadiness = 'Invoke-CippTestCopilotReady*' GenericTests = 'Invoke-CippTestGenericTest*' + E8 = 'Invoke-CippTestE8_*' } $SuiteStopwatch = [System.Diagnostics.Stopwatch]::StartNew() diff --git a/Modules/CIPPCore/Public/New-CIPPBackup.ps1 b/Modules/CIPPCore/Public/New-CIPPBackup.ps1 index f1f155657c970..741037aa50a9e 100644 --- a/Modules/CIPPCore/Public/New-CIPPBackup.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPBackup.ps1 @@ -161,6 +161,10 @@ function New-CIPPBackup { # If building full URL fails, fall back to resource path $blobUrl = $resourcePath } + + # Best-effort off-site replication to an external storage account. + $ReplType = if ($backupType -eq 'CIPP') { 'Core' } else { 'Tenant' } + Push-CIPPBackupReplication -BackupType $ReplType -BlobName $blobName -Content $BackupData -Headers $Headers } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -headers $Headers -API $APINAME -message "Blob upload failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage diff --git a/Modules/CIPPCore/Public/New-CIPPBackupTask.ps1 b/Modules/CIPPCore/Public/New-CIPPBackupTask.ps1 index 55f189947694d..d8dfdeecb4f6b 100644 --- a/Modules/CIPPCore/Public/New-CIPPBackupTask.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPBackupTask.ps1 @@ -27,7 +27,7 @@ function New-CIPPBackupTask { } 'users' { Measure-CippTask -TaskName 'Users' -EventName 'CIPP.BackupCompleted' -Script { - New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/users?$top=999' -tenantid $TenantFilter | Select-Object * -ExcludeProperty mail, provisionedPlans, onPrem*, *passwordProfile*, *serviceProvisioningErrors*, isLicenseReconciliationNeeded, isManagementRestricted, isResourceAccount, *date*, *external*, identities, deletedDateTime, isSipEnabled, assignedPlans, cloudRealtimeCommunicationInfo, deviceKeys, provisionedPlan, securityIdentifier | ForEach-Object { + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/users?$top=999' -tenantid $TenantFilter | Select-Object * -ExcludeProperty mail, provisionedPlans, onPrem*, *passwordProfile*, *serviceProvisioningErrors*, isLicenseReconciliationNeeded, isManagementRestricted, isResourceAccount, *date*, *external*, identities, deletedDateTime, imAddresses, isSipEnabled, assignedPlans, cloudRealtimeCommunicationInfo, deviceKeys, provisionedPlan, securityIdentifier | ForEach-Object { #remove the property if the value is $null $_.psobject.properties | Where-Object { $null -eq $_.Value } | ForEach-Object { $_.psobject.properties.Remove($_.Name) diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index f2b236fdf9b54..415c81a66f12b 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -1,4 +1,3 @@ - function New-CIPPCAPolicy { [CmdletBinding()] param ( @@ -13,7 +12,12 @@ function New-CIPPCAPolicy { $Headers, $PreloadedCAPolicies = $null, $PreloadedLocations = $null, - $PreloadedSecurityDefaults = $null + $PreloadedSecurityDefaults = $null, + $DependencyMap = $null, + $PreloadedServicePrincipals = $null, + $PreloadedUsers = $null, + $PreloadedGroups = $null, + $PreloadedVacationGroups = $null ) # Helper function to replace group display names with GUIDs @@ -111,6 +115,14 @@ function New-CIPPCAPolicy { if ($JSONobj.conditions.users.excludeGuestsOrExternalUsers.externalTenants.Members) { $JSONobj.conditions.users.excludeGuestsOrExternalUsers.externalTenants.PSObject.Properties.Remove('@odata.context') } + if ($JSONobj.sessionControls) { + if ($JSONobj.sessionControls.disableResilienceDefaults -ne $true) { + $JSONobj.sessionControls.PSObject.Properties.Remove('disableResilienceDefaults') + } + if (@($JSONobj.sessionControls.PSObject.Properties).Count -eq 0) { + $JSONobj.PSObject.Properties.Remove('sessionControls') + } + } if ($State -and $State -ne 'donotchange') { $JSONobj | Add-Member -NotePropertyName 'state' -NotePropertyValue $State -Force } @@ -137,9 +149,9 @@ function New-CIPPCAPolicy { } } - # Get named locations once if needed + # Get named locations once if needed (skipped when a shared DependencyMap is supplied - deps were reconciled up front) $AllNamedLocations = $null - if ($JSONobj.LocationInfo) { + if (-not $DependencyMap -and $JSONobj.LocationInfo) { if ($PreloadedLocations) { Write-Information 'Using preloaded named locations' $AllNamedLocations = $PreloadedLocations @@ -155,9 +167,9 @@ function New-CIPPCAPolicy { } } - # Get authentication strength policies once if needed + # Get authentication strength policies once if needed (skipped when a shared DependencyMap is supplied) $AllAuthStrengthPolicies = $null - if ($JSONobj.GrantControls.authenticationStrength.policyType -eq 'custom' -or $JSONobj.GrantControls.authenticationStrength.policyType -eq 'BuiltIn') { + if (-not $DependencyMap -and ($JSONobj.GrantControls.authenticationStrength.policyType -eq 'custom' -or $JSONobj.GrantControls.authenticationStrength.policyType -eq 'BuiltIn')) { try { Write-Information 'Fetching authentication strength policies...' $AllAuthStrengthPolicies = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies/' -tenantid $TenantFilter -asApp $true @@ -168,9 +180,9 @@ function New-CIPPCAPolicy { } } - # Get authentication context class references once if needed + # Get authentication context class references once if needed (skipped when a shared DependencyMap is supplied) $AllAuthContexts = $null - if ($JSONobj.AuthContextInfo) { + if (-not $DependencyMap -and $JSONobj.AuthContextInfo) { try { Write-Information 'Fetching authentication context class references...' $AllAuthContexts = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationContextClassReferences' -tenantid $TenantFilter -asApp $true @@ -181,9 +193,13 @@ function New-CIPPCAPolicy { } } - # Get service principals once if needed + # Get service principals once if needed (use preloaded set when supplied to avoid a + # tenant-wide fetch on every policy in a batch) $AllServicePrincipals = $null if (($JSONobj.conditions.applications.includeApplications -and $JSONobj.conditions.applications.includeApplications -notcontains 'All') -or ($JSONobj.conditions.applications.excludeApplications -and $JSONobj.conditions.applications.excludeApplications -notcontains 'All')) { + if ($PreloadedServicePrincipals) { + $AllServicePrincipals = $PreloadedServicePrincipals + } else { try { Write-Information 'Fetching all service principals...' $AllServicePrincipals = New-GraphGETRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId&$top=999' -tenantid $TenantFilter -asApp $true @@ -192,19 +208,26 @@ function New-CIPPCAPolicy { Write-Information "Error fetching service principals: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" throw "Failed to fetch service principals: $($ErrorMessage.NormalizedError)" } + } } #If Grant Controls contains authenticationStrength, create these and then replace the id if ($JSONobj.GrantControls.authenticationStrength.policyType -eq 'custom' -or $JSONobj.GrantControls.authenticationStrength.policyType -eq 'BuiltIn') { - $ExistingStrength = $AllAuthStrengthPolicies | Where-Object -Property displayName -EQ $JSONobj.GrantControls.authenticationStrength.displayName - if ($ExistingStrength) { - $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } - + if ($DependencyMap) { + # Dependencies were reconciled up front - resolve the id from the shared map by display name + $StrengthName = $JSONobj.GrantControls.authenticationStrength.displayName + $JSONobj.GrantControls.authenticationStrength = @{ id = $DependencyMap.AuthStrength[$StrengthName] } } else { - $Body = ConvertTo-Json -InputObject $JSONobj.GrantControls.authenticationStrength - $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies' -body $body -Type POST -tenantid $TenantFilter -asApp $true -ScheduleRetry $true - $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } - Write-LogMessage -Headers $Headers -API $APIName -message "Created new Authentication Strength Policy: $($JSONobj.GrantControls.authenticationStrength.displayName)" -Sev 'Info' + $ExistingStrength = $AllAuthStrengthPolicies | Where-Object -Property displayName -EQ $JSONobj.GrantControls.authenticationStrength.displayName + if ($ExistingStrength) { + $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } + + } else { + $Body = ConvertTo-Json -InputObject $JSONobj.GrantControls.authenticationStrength + $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies' -body $body -Type POST -tenantid $TenantFilter -asApp $true -ScheduleRetry $true + $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } + Write-LogMessage -Headers $Headers -API $APIName -message "Created new Authentication Strength Policy: $($JSONobj.GrantControls.authenticationStrength.displayName)" -Sev 'Info' + } } } @@ -234,8 +257,20 @@ function New-CIPPCAPolicy { # Handle authentication context class references - create if missing, replace displayNames with IDs if ($JSONobj.AuthContextInfo) { - $AuthContextLookupTable = foreach ($authContext in $JSONobj.AuthContextInfo) { - if (-not $authContext.displayName) { continue } + if ($DependencyMap) { + # Build this policy's lookup from its own AuthContextInfo + the shared id map. + # templateId stays scoped to THIS policy so per-template ids never collide across policies. + $AuthContextLookupTable = foreach ($authContext in $JSONobj.AuthContextInfo) { + if (-not $authContext.displayName) { continue } + [pscustomobject]@{ + id = $DependencyMap.AuthContexts[$authContext.displayName] + displayName = $authContext.displayName + templateId = $authContext.id + } + } + } else { + $AuthContextLookupTable = foreach ($authContext in $JSONobj.AuthContextInfo) { + if (-not $authContext.displayName) { continue } $ExistingContext = $AllAuthContexts | Where-Object -Property displayName -EQ $authContext.displayName if ($ExistingContext) { Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Matched authentication context: $($authContext.displayName)" -Sev 'Info' @@ -280,10 +315,10 @@ function New-CIPPCAPolicy { templateId = $authContext.id } } + } } - Write-Information 'Auth Context Lookup Table:' - Write-Information ($AuthContextLookupTable | ConvertTo-Json -Depth 10) + Write-Information "Auth Context Lookup Table: $(@($AuthContextLookupTable) | ConvertTo-Json -Depth 10 -Compress)" # Replace display names with actual IDs in the policy if ($AuthContextLookupTable -and $JSONobj.conditions.applications.includeAuthenticationContextClassReferences) { @@ -302,6 +337,21 @@ function New-CIPPCAPolicy { } #for each of the locations, check if they exist, if not create them. These are in $JSONobj.LocationInfo + if ($DependencyMap) { + # Build this policy's lookup from its own LocationInfo + the shared id map + $NewLocationsCreated = $DependencyMap.NewLocationsCreated + $LocationLookupTable = foreach ($locations in $JSONobj.LocationInfo) { + if (!$locations) { continue } + foreach ($location in $locations) { + if (!$location.displayName) { continue } + [pscustomobject]@{ + id = $DependencyMap.Locations[$location.displayName] + name = $location.displayName + templateId = $location.id + } + } + } + } else { $NewLocationsCreated = $false $LocationLookupTable = foreach ($locations in $JSONobj.LocationInfo) { if (!$locations) { continue } @@ -383,8 +433,8 @@ function New-CIPPCAPolicy { } } } - Write-Information 'Location Lookup Table:' - Write-Information ($LocationLookupTable | ConvertTo-Json -Depth 10) + } + Write-Information "Location Lookup Table: $(@($LocationLookupTable) | ConvertTo-Json -Depth 10 -Compress)" if ($LocationLookupTable -and $JSONobj.conditions.locations) { foreach ($location in $JSONobj.conditions.locations.includeLocations) { @@ -431,22 +481,28 @@ function New-CIPPCAPolicy { } try { Write-Information 'Replacement pattern for inclusions and exclusions is displayName.' - $Requests = @( - @{ - url = 'users?$select=id,displayName&$top=999' - method = 'GET' - id = 'users' - } - @{ - url = 'groups?$select=id,displayName&$top=999' - method = 'GET' - id = 'groups' - } - ) - $BulkResults = New-GraphBulkRequest -Requests $Requests -tenantid $TenantFilter -asapp $true + if ($null -ne $PreloadedUsers -and $null -ne $PreloadedGroups) { + # Use the batch-level preloaded lookups to avoid a users+groups fetch per policy + $users = $PreloadedUsers + $groups = $PreloadedGroups + } else { + $Requests = @( + @{ + url = 'users?$select=id,displayName&$top=999' + method = 'GET' + id = 'users' + } + @{ + url = 'groups?$select=id,displayName&$top=999' + method = 'GET' + id = 'groups' + } + ) + $BulkResults = New-GraphBulkRequest -Requests $Requests -tenantid $TenantFilter -asapp $true - $users = ($BulkResults | Where-Object { $_.id -eq 'users' }).body.value - $groups = ($BulkResults | Where-Object { $_.id -eq 'groups' }).body.value + $users = ($BulkResults | Where-Object { $_.id -eq 'users' }).body.value + $groups = ($BulkResults | Where-Object { $_.id -eq 'groups' }).body.value + } foreach ($userType in 'includeUsers', 'excludeUsers') { if ($JSONobj.conditions.users.PSObject.Properties.Name -contains $userType -and $JSONobj.conditions.users.$userType -notin 'All', 'None', 'GuestOrExternalUsers') { @@ -548,7 +604,12 @@ 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 | + $VacationGroups = if ($null -ne $PreloadedVacationGroups) { + $PreloadedVacationGroups + } else { + 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 + } + $ExistingVacationGroup = $VacationGroups | Where-Object { $CheckExisting.conditions.users.excludeGroups -contains $_.id } if ($ExistingVacationGroup) { if (-not ($JSONobj.conditions.users.PSObject.Properties.Name -contains 'excludeGroups')) { diff --git a/Modules/CIPPCore/Public/New-CIPPGroup.ps1 b/Modules/CIPPCore/Public/New-CIPPGroup.ps1 index ff186140304d0..d6913f7f55651 100644 --- a/Modules/CIPPCore/Public/New-CIPPGroup.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPGroup.ps1 @@ -275,6 +275,18 @@ function New-CIPPGroup { } $GraphRequest = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-DistributionGroup' -cmdParams $ExoParams + + $Aliases = @($GroupObject.aliases -split '\r?\n' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $SetParams = @{ Identity = $GraphRequest.Identity } + if ($GroupObject.hideFromGAL) { + $SetParams.HiddenFromAddressListsEnabled = $true + } + if ($Aliases.Count -gt 0) { + $SetParams.EmailAddresses = @{ '@odata.type' = '#Exchange.GenericHashTable'; Add = @($Aliases | ForEach-Object { "smtp:$_" }) } + } + if ($SetParams.Keys.Count -gt 1) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-DistributionGroup' -cmdParams $SetParams + } } $Result = [PSCustomObject]@{ diff --git a/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 b/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 index 2b7f3dfac5ff9..5c6d4492ca783 100644 --- a/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 @@ -78,6 +78,29 @@ function New-CIPPIntuneAppDeployment { } } + # Build IntuneBody from raw config if not pre-built (template/standard path). MSP apps store + # only the vendor + params in the template, so build the install command here using the shared + # helper, which resolves %CIPP variables% in the params per-tenant. + if (-not $IntuneBody -and $AppType -eq 'MSPApp') { + $MSPAppName = $AppConfig.MSPAppName ?? $AppConfig.rmmname.value ?? $AppConfig.rmmname + if ([string]::IsNullOrWhiteSpace($MSPAppName)) { + throw 'MSP app vendor (rmmname) is required for MSP app deployments but was not found in the template config.' + } + # Ensure the file-loading block below can locate the packaged app files. + $AppConfig | Add-Member -NotePropertyName 'MSPAppName' -NotePropertyValue $MSPAppName -Force + + $IntuneBody = Get-Content (Join-Path $env:CIPPRootPath "AddMSPApp\$MSPAppName.app.json") | ConvertFrom-Json + $IntuneBody.displayName = $AppConfig.Applicationname ?? $AppConfig.displayName + + $TenantObj = Get-Tenants -TenantFilter $TenantFilter + $CommandResult = Get-CIPPMSPAppInstallCommand -RmmName $MSPAppName -Params $AppConfig.params -Tenant $TenantObj -PackageName $AppConfig.PackageName + $IntuneBody.installCommandLine = $CommandResult.InstallCommandLine + $IntuneBody.UninstallCommandLine = $CommandResult.UninstallCommandLine + if ($CommandResult.DetectionScriptContent) { + $IntuneBody.detectionRules[0].scriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CommandResult.DetectionScriptContent)) + } + } + # Load files based on app type (only for types that need them) $Intunexml = $null $Infile = $null diff --git a/Modules/CIPPCore/Public/New-CIPPIntuneReportExportJob.ps1 b/Modules/CIPPCore/Public/New-CIPPIntuneReportExportJob.ps1 new file mode 100644 index 0000000000000..96c85aa1936d7 --- /dev/null +++ b/Modules/CIPPCore/Public/New-CIPPIntuneReportExportJob.ps1 @@ -0,0 +1,71 @@ +function New-CIPPIntuneReportExportJob { + <# + .SYNOPSIS + Submits an Intune report export job and stores the job id in the IntuneReportJobs table. + + .DESCRIPTION + Posts an export job to deviceManagement/reports/exportJobs for the given report and upserts + the job id into the IntuneReportJobs table (PartitionKey = tenant, RowKey = report name) so + a later run can poll and download the result. Used by the nightly report-export orchestrator + and by the DB cache functions to self-submit when no job is pending. + + .PARAMETER TenantFilter + The tenant to submit the export job for. + + .PARAMETER ReportName + The Intune report to export. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [ValidateSet('AppInvRawData', 'AppInstallStatusAggregate')] + [string]$ReportName + ) + + $Select = switch ($ReportName) { + 'AppInvRawData' { + @( + 'ApplicationKey', 'ApplicationName', 'ApplicationPublisher', 'ApplicationVersion', + 'DeviceId', 'DeviceName', 'OSDescription', 'OSVersion', 'Platform', + 'UserId', 'UserName', 'EmailAddress' + ) + } + 'AppInstallStatusAggregate' { + @( + 'ApplicationId', 'DisplayName', 'Publisher', 'Platform', 'AppVersion', 'AppPlatform', + 'InstalledDeviceCount', 'FailedDeviceCount', 'FailedUserCount', + 'PendingInstallDeviceCount', 'NotInstalledDeviceCount', 'FailedDevicePercentage' + ) + } + } + + $Body = @{ + reportName = $ReportName + format = 'json' + localizationType = 'replaceLocalizableValues' + select = $Select + } | ConvertTo-Json -Depth 5 + + $Job = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs' -tenantid $TenantFilter -body $Body + + if (-not $Job.id) { throw "Intune returned no job id for $ReportName" } + + $JobsTable = Get-CIPPTable -tablename 'IntuneReportJobs' + $Existing = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" + if ($Existing) { + Remove-AzDataTableEntity @JobsTable -Entity $Existing -Force -ErrorAction SilentlyContinue + } + + Add-CIPPAzDataTableEntity @JobsTable -Entity @{ + PartitionKey = $TenantFilter + RowKey = $ReportName + JobId = $Job.id + ReportName = $ReportName + SubmittedAt = ([DateTime]::UtcNow).ToString('o') + } -Force + + return $Job +} diff --git a/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 b/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 index 595b18dae5a79..b1b2dddf77c80 100644 --- a/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 @@ -39,7 +39,7 @@ function New-CIPPRestoreTask { # Helper function to clean user object for Graph API - removes reference properties, nulls, and empty strings function Clean-GraphObject { param($Object, [switch]$ExcludeId) - $excludeProps = @('password', 'passwordProfile', '@odata.type', 'manager', 'memberOf', 'createdOnBehalfOf', 'createdByAppId', 'deletedDateTime', 'authorizationInfo') + $excludeProps = @('password', 'passwordProfile', '@odata.type', 'manager', 'memberOf', 'createdOnBehalfOf', 'createdByAppId', 'deletedDateTime', 'authorizationInfo', 'imAddresses') if ($ExcludeId) { $excludeProps += @('id') } diff --git a/Modules/CIPPCore/Public/New-CIPPSAMCertificate.ps1 b/Modules/CIPPCore/Public/New-CIPPSAMCertificate.ps1 new file mode 100644 index 0000000000000..ff5c89dd5acda --- /dev/null +++ b/Modules/CIPPCore/Public/New-CIPPSAMCertificate.ps1 @@ -0,0 +1,103 @@ +function New-CIPPSAMCertificate { + <# + .SYNOPSIS + Generates a self-signed certificate for SAM app certificate-based authentication + + .DESCRIPTION + Creates a new RSA self-signed certificate in memory suitable for use as a key credential + on the SAM app registration (consumed by Get-GraphTokenFromCert). Pure generation - no + storage or Graph interaction. The PFX is exported without a password so it is + byte-compatible with how Key Vault exposes certificate private keys through the secrets + endpoint (application/x-pkcs12); protection comes from Key Vault access control. + + .PARAMETER ValidityDays + Number of days the certificate is valid for. Defaults to 365. + + .PARAMETER KeySize + RSA key size in bits. Defaults to 2048. + + .PARAMETER SubjectName + Certificate subject. Defaults to CN=CIPP-SAM-. + + .EXAMPLE + New-CIPPSAMCertificate + + .EXAMPLE + New-CIPPSAMCertificate -ValidityDays 730 -KeySize 4096 + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [int]$ValidityDays = 365, + + [Parameter(Mandatory = $false)] + [int]$KeySize = 2048, + + [Parameter(Mandatory = $false)] + [string]$SubjectName + ) + + if ([string]::IsNullOrEmpty($SubjectName)) { + # Machine name for local dev (WEBSITE_SITE_NAME is often set there too), site name in Azure + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $InstanceName = [System.Environment]::MachineName + } else { + $InstanceName = $env:WEBSITE_SITE_NAME + } + $SubjectName = "CN=CIPP-SAM-$InstanceName" + } + + $RSA = [System.Security.Cryptography.RSA]::Create($KeySize) + try { + $CertRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + $SubjectName, + $RSA, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ) + + # Key usage: digital signature only (client assertion signing) + $CertRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature, + $true + ) + ) + + # Enhanced key usage: client authentication + $EkuOids = [System.Security.Cryptography.OidCollection]::new() + $null = $EkuOids.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.2')) + $CertRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($EkuOids, $false) + ) + + # Subject key identifier + $CertRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]::new($CertRequest.PublicKey, $false) + ) + + # Backdate NotBefore to tolerate clock skew between this host and Entra ID + $NotBefore = (Get-Date).ToUniversalTime().AddMinutes(-15) + $NotAfter = $NotBefore.AddDays($ValidityDays) + + $Certificate = $CertRequest.CreateSelfSigned($NotBefore, $NotAfter) + try { + $PfxBytes = $Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx) + $PublicKeyBytes = $Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) + + return [PSCustomObject]@{ + Certificate = $Certificate + PfxBase64 = [Convert]::ToBase64String($PfxBytes) + PublicKeyBase64 = [Convert]::ToBase64String($PublicKeyBytes) + Thumbprint = $Certificate.Thumbprint + NotBefore = $NotBefore + NotAfter = $NotAfter + } + } catch { + $Certificate.Dispose() + throw + } + } finally { + $RSA.Dispose() + } +} diff --git a/Modules/CIPPCore/Public/New-CIPPSitRulePackXml.ps1 b/Modules/CIPPCore/Public/New-CIPPSitRulePackXml.ps1 index a202c9b89511d..59ecf84ec9410 100644 --- a/Modules/CIPPCore/Public/New-CIPPSitRulePackXml.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPSitRulePackXml.ps1 @@ -3,9 +3,17 @@ function New-CIPPSitRulePackXml { .SYNOPSIS Synthesize a Microsoft Purview Sensitive Information Type rule pack XML from simple inputs. .DESCRIPTION - New-DlpSensitiveInformationType only accepts a rule pack XML via -FileData (byte array). - For simple regex-based SITs, this helper builds a minimal valid rule pack with fresh GUIDs - so callers can pass it to the cmdlet without hand-authoring XML. + New-DlpSensitiveInformationTypeRulePackage imports a custom SIT *rule package* (regex/keyword + based, Type=Entity). It requires the 2011 'mce' schema namespace and UTF-16 encoded bytes - the + 2018 'search.external' namespace is rejected with a schema-validation error. + + For simple regex-based SITs this helper builds a minimal valid rule pack with fresh GUIDs so + callers can hand it to the cmdlet without authoring XML. (NOTE: the singular + New-DlpSensitiveInformationType cmdlet is a *document-fingerprint* primitive and must NOT be used + for regex SITs - it stores the FileData as a fingerprint and discards the regex.) + .NOTES + The returned string declares encoding="utf-16"; callers must encode it with + [System.Text.Encoding]::Unicode (UTF-16LE, no BOM) so the bytes match the declaration. .FUNCTIONALITY Internal #> @@ -16,7 +24,7 @@ function New-CIPPSitRulePackXml { [Parameter(Mandatory)][string]$Pattern, [int]$Confidence = 85, [int]$PatternsProximity = 300, - [string]$Locale = 'en-us', + [string]$Locale = 'en-US', [string]$PublisherName = 'CIPP' ) @@ -27,10 +35,10 @@ function New-CIPPSitRulePackXml { $esc = { param($s) [System.Security.SecurityElement]::Escape([string]$s) } return @" - - + + - +
diff --git a/Modules/CIPPCore/Public/New-CIPPTravelPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPTravelPolicy.ps1 new file mode 100644 index 0000000000000..21d3b4c0a5ef3 --- /dev/null +++ b/Modules/CIPPCore/Public/New-CIPPTravelPolicy.ps1 @@ -0,0 +1,87 @@ +function New-CIPPTravelPolicy { + <# + .SYNOPSIS + Creates a temporary travel conditional access policy and named location for vacation mode. + + .DESCRIPTION + Builds a template-style policy JSON and hands it to New-CIPPCAPolicy, which creates the country + named location from LocationInfo, waits for it to propagate, replaces the display name reference + with the location ID and retries policy creation on propagation errors. The resulting policy blocks + sign-ins for the included users from all locations except the travel destination. Entra ID has no + standalone 'allow' control, so restricting sign-ins to the travel destination is achieved by blocking + every other location. The policy and named location share the same display name so that + Remove-CIPPTravelPolicy can remove both when the vacation ends. + + .PARAMETER Headers + The headers to include in the request, typically containing authentication tokens. Supplied automatically by the API. + + .PARAMETER TenantFilter + The tenant identifier to filter the request. + + .PARAMETER Users + An array of user principal names or object IDs of the users travelling. + + .PARAMETER Countries + An array of ISO 3166-1 alpha-2 country codes for the travel destination(s). + + .PARAMETER PolicyName + The display name used for both the conditional access policy and the named location. + + .PARAMETER APIName + The name of the API operation being performed. Defaults to 'New-CIPPTravelPolicy'. + #> + [CmdletBinding()] + param( + $Headers, + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string[]]$Users, + [Parameter(Mandatory = $true)][string[]]$Countries, + [Parameter(Mandatory = $true)][string]$PolicyName, + [string]$APIName = 'New-CIPPTravelPolicy' + ) + try { + $GuidRegex = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$' + $UserIds = foreach ($User in $Users) { + if ($User -match $GuidRegex) { + $User + } else { + (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$([System.Web.HttpUtility]::UrlEncode($User))?`$select=id" -tenantid $TenantFilter -asApp $true).id + } + } + + $RawJSON = ConvertTo-Json -Depth 10 -InputObject @{ + displayName = $PolicyName + state = 'enabled' + conditions = @{ + users = @{ includeUsers = @($UserIds) } + applications = @{ includeApplications = @('All') } + clientAppTypes = @('all') + locations = @{ + includeLocations = @('All') + excludeLocations = @($PolicyName) + } + } + grantControls = @{ + operator = 'OR' + builtInControls = @('block') + } + LocationInfo = @( + @{ + '@odata.type' = '#microsoft.graph.countryNamedLocation' + displayName = $PolicyName + countriesAndRegions = @($Countries) + includeUnknownCountriesAndRegions = $false + } + ) + } + $null = New-CIPPCAPolicy -RawJSON $RawJSON -TenantFilter $TenantFilter -Overwrite $true -ReplacePattern 'none' -APIName $APIName -Headers $Headers + $Result = "Created temporary travel policy '$PolicyName' allowing sign-ins from $($Countries -join ', ') only." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' + return $Result + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to create temporary travel policy '$PolicyName': $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Error' -LogData $ErrorMessage + throw $Result + } +} diff --git a/Modules/CIPPCore/Public/New-VulnCsvBytes.ps1 b/Modules/CIPPCore/Public/New-VulnCsvBytes.ps1 new file mode 100644 index 0000000000000..be860ac91a379 --- /dev/null +++ b/Modules/CIPPCore/Public/New-VulnCsvBytes.ps1 @@ -0,0 +1,31 @@ +function New-VulnCsvBytes { + <# + .SYNOPSIS + Build a CSV payload (UTF-8 bytes) from objects with explicit headers. + .PARAMETER Rows + Array of PSCustomObject where property names match the provided headers. + .PARAMETER Headers + Ordered list of column headers (and property names). + #> + [CmdletBinding()] + param( + [Parameter()][object[]]$Rows = @(), + [Parameter(Mandatory)][string[]]$Headers + ) + + $Sb = [System.Text.StringBuilder]::new() + [void]$Sb.AppendLine(($Headers -join ',')) + + foreach ($Row in $Rows) { + $Cells = foreach ($Header in $Headers) { + $Val = $Row.$Header + if ($null -ne $Val) { + $S = [string]$Val + if ($S -match '[,"\r\n]') { '"' + ($S -replace '"', '""') + '"' } else { $S } + } else { '' } + } + [void]$Sb.AppendLine(($Cells -join ',')) + } + + return [System.Text.Encoding]::UTF8.GetBytes($Sb.ToString()) +} diff --git a/Modules/CIPPCore/Public/Push-CIPPBackupReplication.ps1 b/Modules/CIPPCore/Public/Push-CIPPBackupReplication.ps1 new file mode 100644 index 0000000000000..20d40026c41bc --- /dev/null +++ b/Modules/CIPPCore/Public/Push-CIPPBackupReplication.ps1 @@ -0,0 +1,83 @@ +function Push-CIPPBackupReplication { + <# + .SYNOPSIS + Replicates a CIPP backup blob to an external storage account using a container SAS URL. + + .DESCRIPTION + After a backup blob is written to the CIPP-bound storage account, this helper uploads an + identical copy to an external Azure Storage container. The destination is described by a + container-level SAS URL (with write+create permission) stored in Key Vault, so the secret + never lands in table storage or the browser. + + There are two independent replication targets: + Core -> KV secret 'BackupReplicationCore' (Config RowKey 'Core') for CIPP backups + Tenant -> KV secret 'BackupReplicationTenant' (Config RowKey 'Tenant') for scheduled tenant backups + + Replication is best-effort: any failure is logged but never thrown, so a replication problem + can never abort the underlying backup. + + .PARAMETER BackupType + 'Core' for CIPP backups, 'Tenant' for scheduled tenant backups. + + .PARAMETER BlobName + The blob file name to write (e.g. 'CIPPBackup_2024-01-15-1430.json'). + + .PARAMETER Content + The backup payload (JSON string) to upload. + + .PARAMETER Headers + Request headers passed through to Write-LogMessage for attribution. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Core', 'Tenant')] + [string]$BackupType, + + [Parameter(Mandatory = $true)] + [string]$BlobName, + + [Parameter(Mandatory = $true)] + [string]$Content, + + $Headers + ) + + $SecretName = "BackupReplication$BackupType" + + try { + # Only replicate when explicitly enabled for this scope. + $Table = Get-CIPPTable -TableName 'Config' + $Config = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'BackupReplication' and RowKey eq '$BackupType'" + if (-not $Config -or $Config.Enabled -ne $true) { + return + } + + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' + $SasUrl = (Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'BackupReplication$BackupType' and RowKey eq 'BackupReplication$BackupType'").SASUrl + } + else { + $SasUrl = Get-CippKeyVaultSecret -Name $SecretName -AsPlainText + } + + if ([string]::IsNullOrWhiteSpace($SasUrl)) { + Write-LogMessage -headers $Headers -API 'BackupReplication' -message "$BackupType backup replication is enabled but no SAS URL is stored" -Sev 'Warning' + return + } + + # Insert the blob name into the container SAS URL, before the query string. + $UrlParts = $SasUrl -split '\?', 2 + $BaseUrl = $UrlParts[0].TrimEnd('/') + $Target = "$BaseUrl/$BlobName" + if ($UrlParts.Count -gt 1 -and -not [string]::IsNullOrWhiteSpace($UrlParts[1])) { + $Target = "$Target`?$($UrlParts[1])" + } + + $null = Invoke-CIPPRestMethod -Uri $Target -Method 'PUT' -Body $Content -ContentType 'application/json; charset=utf-8' -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } + Write-LogMessage -headers $Headers -API 'BackupReplication' -message "Replicated $BackupType backup '$BlobName' to external storage" -Sev 'Debug' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API 'BackupReplication' -message "Failed to replicate $BackupType backup '$BlobName' to external storage: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + } +} diff --git a/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 b/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 index 1880d4c4960f5..c4f395369f1f5 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 @@ -52,15 +52,28 @@ function Remove-CIPPGroupMember { ) try { - $Requests = foreach ($m in $Member) { - if ($m -like '*#EXT#*') { $m = [System.Web.HttpUtility]::UrlEncode($m) } + $Requests = @( + foreach ($m in $Member) { + if ($m -like '*#EXT#*') { $m = [System.Web.HttpUtility]::UrlEncode($m) } + @{ + id = "users-$m" + url = "users/$($m)?`$select=id,userPrincipalName" + method = 'GET' + } + } @{ - id = $m - url = "users/$($m)?`$select=id,userPrincipalName" + id = 'group' + url = "groups/$($GroupId)?`$select=id,displayName" method = 'GET' } - } - $Users = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter + ) + $BulkResults = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter + $Users = @($BulkResults | Where-Object { $_.id -like 'users-*' }) + # Group display name for logging; falls back to the id if the lookup failed + # (e.g. the group was addressed by mail rather than GUID). + $GroupName = ($BulkResults | Where-Object { $_.id -eq 'group' }).body.displayName ?? $GroupId + $SuccessfulUsers = [System.Collections.Generic.List[string]]::new() + $FailedUsers = [System.Collections.Generic.List[string]]::new() if ($GroupType -eq 'Distribution list' -or $GroupType -eq 'Mail-Enabled Security') { $ExoBulkRequests = [System.Collections.Generic.List[object]]::new() @@ -75,7 +88,7 @@ function Remove-CIPPGroupMember { } }) $ExoLogs.Add(@{ - message = "Removed member $($User.body.userPrincipalName) from $($GroupId) group" + message = "Removed member $($User.body.userPrincipalName) from group $($GroupName)" target = $User.body.userPrincipalName }) } @@ -93,6 +106,7 @@ function Remove-CIPPGroupMember { $ExoError = $LastError | Where-Object { $ExoLog.target -in $_.target -and $_.error } if (!$LastError -or ($LastError.error -and $LastError.target -notcontains $ExoLog.target)) { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $ExoLog.message -Sev 'Info' + $SuccessfulUsers.Add($ExoLog.target) } } } @@ -106,20 +120,36 @@ function Remove-CIPPGroupMember { } $RemovalResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($RemovalRequests) foreach ($Result in $RemovalResults) { - if ($Result.status -ne 204) { - throw "Failed to remove member $($Result.id): $($Result.body.error.message)" + $UserPrincipalName = ($Users | Where-Object { $_.body.id -eq $Result.id }).body.userPrincipalName + if ($Result.status -lt 200 -or $Result.status -gt 299) { + # Select-Object -First 1: Get-NormalizedError can return multiple strings + # when a message matches more than one of its translation patterns. + $ErrorText = Get-NormalizedError -message ($Result.body.error.message ?? "Request failed with status $($Result.status)") | Select-Object -First 1 + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to remove member $UserPrincipalName from group $($GroupName): $ErrorText" -Sev 'Error' + $FailedUsers.Add("$UserPrincipalName ($ErrorText)") + } else { + $SuccessfulUsers.Add($UserPrincipalName) } } } - $UserList = ($Users.body.userPrincipalName -join ', ') - $Results = "Successfully removed user $UserList from $($GroupId)." + $Messages = [System.Collections.Generic.List[string]]::new() + if ($SuccessfulUsers.Count -gt 0) { + $Messages.Add("Successfully removed user $($SuccessfulUsers -join ', ') from group $($GroupName).") + } + if ($FailedUsers.Count -gt 0) { + $Messages.Add("Failed to remove $($FailedUsers -join '; ').") + } + $Results = $Messages -join ' ' + if ($SuccessfulUsers.Count -eq 0 -and $FailedUsers.Count -gt 0) { + throw $Results + } Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev Info return $Results } catch { $ErrorMessage = Get-CippException -Exception $_ $UserList = if ($Users) { ($Users.body.userPrincipalName -join ', ') } else { ($Member -join ', ') } - $Results = "Failed to remove user $UserList from $($GroupId): $($ErrorMessage.NormalizedError)" + $Results = "Failed to remove user $UserList from group $($GroupName ?? $GroupId): $($ErrorMessage.NormalizedError)" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev Error -LogData $ErrorMessage throw $Results } diff --git a/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 b/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 index 9422e8556faa8..ce8980fbcd148 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 @@ -10,6 +10,28 @@ function Remove-CIPPLicense { ) if ($Schedule.IsPresent) { + # Record which licenses the user holds right now, so the offboarding result (and any + # ticket/webhook/email it feeds) documents what was removed - useful for auditing and + # for restoring the account to its previous state. Sku names come from the cached + # license overview, which resolves the best available display name for the tenant. + $LicenseSuffix = '' + try { + $LicenseOverview = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'LicenseOverview') + $UserLicenseState = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($userid)?`$select=licenseAssignmentStates" -tenantid $TenantFilter + $ActiveSkuIds = @(($UserLicenseState.licenseAssignmentStates | Where-Object { $_.state -eq 'Active' }).skuId | Select-Object -Unique) + $LicenseNames = foreach ($SkuId in $ActiveSkuIds) { + $Sku = $LicenseOverview | Where-Object { $_.skuId -eq $SkuId } | Select-Object -First 1 + if ($Sku.License) { $Sku.License } else { $SkuId } + } + if ($LicenseNames) { + $LicenseSuffix = " Licenses currently assigned that will be removed: $(@($LicenseNames | Sort-Object -Unique) -join ', ')." + } else { + $LicenseSuffix = ' The user currently has no licenses assigned.' + } + } catch { + Write-Information "Could not enumerate current licenses for the scheduled removal message: $($_.Exception.Message)" + } + $ScheduledTask = @{ TenantFilter = $TenantFilter Name = "Remove License: $Username" @@ -30,37 +52,71 @@ function Remove-CIPPLicense { } } Add-CIPPScheduledTask -Task $ScheduledTask -hidden $false -DisallowDuplicateName $true - return "Scheduled license removal for $username" + return "Scheduled license removal for $username.$LicenseSuffix" } else { try { $ConvertTable = [System.IO.File]::ReadAllText((Join-Path $env:CIPPRootPath 'Config\ConversionTable.csv')) | ConvertFrom-Csv $User = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($userid)" -tenantid $tenantFilter - $GroupMemberships = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($userid)/memberOf/microsoft.graph.group?`$select=id,displayName,assignedLicenses" -tenantid $tenantFilter + $GroupMemberships = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($userid)/memberOf/microsoft.graph.group?`$select=id,displayName,assignedLicenses,mailEnabled,groupTypes" -tenantid $tenantFilter $LicenseGroups = $GroupMemberships | Where-Object { ($_.assignedLicenses | Measure-Object).Count -gt 0 } if ($LicenseGroups) { - # remove user from groups with licenses, these can only be graph groups - $RemoveRequests = foreach ($LicenseGroup in $LicenseGroups) { - @{ - id = $LicenseGroup.id - method = 'DELETE' - url = "groups/$($LicenseGroup.id)/members/$($User.id)/`$ref" + # remove user from license groups. Mail-enabled security groups can't be modified through Graph, so those go through Exchange Online instead + $GraphRemoveRequests = [System.Collections.Generic.List[object]]::new() + $ExoRemoveRequests = [System.Collections.Generic.List[object]]::new() + $ExoGroups = [System.Collections.Generic.List[object]]::new() + + foreach ($LicenseGroup in $LicenseGroups) { + $IsM365Group = $LicenseGroup.groupTypes -contains 'Unified' + if ($LicenseGroup.mailEnabled -and -not $IsM365Group) { + $ExoRemoveRequests.Add(@{ + CmdletInput = @{ + CmdletName = 'Remove-DistributionGroupMember' + Parameters = @{ + Identity = $LicenseGroup.id + Member = $User.id + BypassSecurityGroupManagerCheck = $true + } + } + }) + $ExoGroups.Add($LicenseGroup) + } else { + $GraphRemoveRequests.Add(@{ + id = $LicenseGroup.id + method = 'DELETE' + url = "groups/$($LicenseGroup.id)/members/$($User.id)/`$ref" + }) } } Write-Information 'Removing user from groups with licenses' - $RemoveResults = New-GraphBulkRequest -tenantid $tenantFilter -requests @($RemoveRequests) - Write-Information ($RemoveResults | ConvertTo-Json -Depth 5) - foreach ($Result in $RemoveResults) { - $Group = $LicenseGroups | Where-Object { $_.id -eq $Result.id } - $GroupName = $Group.displayName - if ($Result.status -eq 204) { - Write-LogMessage -headers $Headers -API $APIName -message "Removed $($User.displayName) from license group $GroupName" -Sev 'Info' -tenant $TenantFilter - "Removed $($User.displayName) from license group $GroupName" - } else { - Write-LogMessage -headers $Headers -API $APIName -message "Failed to remove $($User.displayName) from license group $GroupName. This is likely because its a Dynamic Group or synced with active directory." -Sev 'Error' -tenant $TenantFilter - "Failed to remove $($User.displayName) from license group $GroupName. This is likely because its a Dynamic Group or synced with active directory." + if ($GraphRemoveRequests.Count -gt 0) { + $RemoveResults = New-GraphBulkRequest -tenantid $tenantFilter -requests @($GraphRemoveRequests) + Write-Information ($RemoveResults | ConvertTo-Json -Depth 5) + foreach ($Result in $RemoveResults) { + $GroupName = ($LicenseGroups | Where-Object { $_.id -eq $Result.id }).displayName + if ($Result.status -eq 204) { + Write-LogMessage -headers $Headers -API $APIName -message "Removed $($User.displayName) from license group $GroupName" -Sev 'Info' -tenant $TenantFilter + "Removed $($User.displayName) from license group $GroupName" + } else { + Write-LogMessage -headers $Headers -API $APIName -message "Failed to remove $($User.displayName) from license group $GroupName. This is likely because its a Dynamic Group or synced with active directory." -Sev 'Error' -tenant $TenantFilter + "Failed to remove $($User.displayName) from license group $GroupName. This is likely because its a Dynamic Group or synced with active directory." + } + } + } + + if ($ExoRemoveRequests.Count -gt 0) { + $RawExoRequest = New-ExoBulkRequest -tenantid $tenantFilter -cmdletArray @($ExoRemoveRequests) + $LastError = $RawExoRequest | Select-Object -Last 1 + foreach ($ExoGroup in $ExoGroups) { + if (!$LastError -or ($LastError.error -and $LastError.target -notcontains $User.id)) { + Write-LogMessage -headers $Headers -API $APIName -message "Removed $($User.displayName) from mail-enabled license group $($ExoGroup.displayName)" -Sev 'Info' -tenant $TenantFilter + "Removed $($User.displayName) from mail-enabled license group $($ExoGroup.displayName)" + } else { + Write-LogMessage -headers $Headers -API $APIName -message "Failed to remove $($User.displayName) from mail-enabled license group $($ExoGroup.displayName). This is likely because its a Dynamic Group or synced with active directory." -Sev 'Error' -tenant $TenantFilter + "Failed to remove $($User.displayName) from mail-enabled license group $($ExoGroup.displayName)." + } } } } diff --git a/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 b/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 new file mode 100644 index 0000000000000..f62defdce7a55 --- /dev/null +++ b/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 @@ -0,0 +1,74 @@ +function Remove-CIPPSPOSiteUser { + <# + .SYNOPSIS + Remove a user from one or more SharePoint sites entirely + + .DESCRIPTION + Deletes the user from each site's user list via the SharePoint REST API with certificate + authentication, which removes them from every site group and direct permission grant on + that site at once. Site collection admins are refused (remove their admin flag first). + + .PARAMETER TenantFilter + Tenant the sites belong to + + .PARAMETER SiteUrls + One or more site URLs to remove the user from + + .PARAMETER LoginName + SharePoint claims login (i:0#.f|membership|upn); a bare UPN is converted automatically + + .EXAMPLE + Remove-CIPPSPOSiteUser -TenantFilter 'contoso.onmicrosoft.com' -SiteUrls @('https://contoso.sharepoint.com/sites/HR') -LoginName 'guest_example.com#ext#@contoso.onmicrosoft.com' + + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [Parameter(Mandatory = $true)] + [string[]]$SiteUrls, + [Parameter(Mandatory = $true)] + [string]$LoginName + ) + + if ($LoginName -notmatch '\|') { $LoginName = "i:0#.f|membership|$LoginName" } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + + $Succeeded = [System.Collections.Generic.List[string]]::new() + $Failed = [System.Collections.Generic.List[string]]::new() + + foreach ($SiteUrl in $SiteUrls) { + if (-not $PSCmdlet.ShouldProcess($SiteUrl, "Remove $LoginName")) { continue } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + try { + try { + $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $LoginName } + $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "could not resolve the user (ensureuser): $($_.Exception.Message)" + } + if (-not $EnsuredUser.Id) { throw 'could not resolve the user on the site.' } + if ($EnsuredUser.IsSiteAdmin) { + throw 'user is a site collection admin; remove their admin permission first (Remove Site Admin action).' + } + try { + $null = New-GraphPostRequest -uri "$BaseUri/web/siteusers/removebyid($($EnsuredUser.Id))" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "removal failed: $($_.Exception.Message)" + } + $Succeeded.Add($SiteUrl) + } catch { + $Failed.Add("$($SiteUrl): $($_.Exception.Message)") + } + } + + return [PSCustomObject]@{ + Succeeded = @($Succeeded) + Failed = @($Failed) + } +} diff --git a/Modules/CIPPCore/Public/Remove-CIPPTravelPolicy.ps1 b/Modules/CIPPCore/Public/Remove-CIPPTravelPolicy.ps1 new file mode 100644 index 0000000000000..c2f6b3a8295b1 --- /dev/null +++ b/Modules/CIPPCore/Public/Remove-CIPPTravelPolicy.ps1 @@ -0,0 +1,75 @@ +function Remove-CIPPTravelPolicy { + <# + .SYNOPSIS + Removes a temporary travel conditional access policy and its named location. + + .DESCRIPTION + Deletes the conditional access policy and the country named location that were created by + New-CIPPTravelPolicy. Both objects are located by their shared display name. The policy is + deleted first because the named location cannot be removed while a policy still references it. + + .PARAMETER Headers + The headers to include in the request, typically containing authentication tokens. Supplied automatically by the API. + + .PARAMETER TenantFilter + The tenant identifier to filter the request. + + .PARAMETER PolicyName + The display name shared by the conditional access policy and the named location. + + .PARAMETER APIName + The name of the API operation being performed. Defaults to 'Remove-CIPPTravelPolicy'. + #> + [CmdletBinding()] + param( + $Headers, + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$PolicyName, + [string]$APIName = 'Remove-CIPPTravelPolicy' + ) + try { + $Results = [System.Collections.Generic.List[string]]::new() + + $Policies = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies?$select=id,displayName&$top=999' -tenantid $TenantFilter -asApp $true | Where-Object { $_.displayName -eq $PolicyName } + foreach ($Policy in $Policies) { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies/$($Policy.id)" -type DELETE -tenantid $TenantFilter -asApp $true + $Results.Add("Deleted temporary travel policy '$PolicyName'.") + } + if (!$Policies) { + $Results.Add("No conditional access policy named '$PolicyName' was found, it may have been removed already.") + } + + $Locations = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations?$select=id,displayName&$top=999' -tenantid $TenantFilter -asApp $true | Where-Object { $_.displayName -eq $PolicyName } + foreach ($Location in $Locations) { + # Deleting a named location right after deleting the policy that references it can fail + # while the policy deletion propagates, so retry a few times. + $RetryCount = 0 + $MaxRetryCount = 5 + $Deleted = $false + do { + try { + $null = Set-CIPPNamedLocation -NamedLocationId $Location.id -TenantFilter $TenantFilter -Change 'delete' -APIName $APIName -Headers $Headers + $Deleted = $true + } catch { + $RetryCount++ + if ($RetryCount -ge $MaxRetryCount) { throw } + Write-Information "Named location '$PolicyName' could not be deleted yet, will retry..." + Start-Sleep -Seconds 5 + } + } while (!$Deleted -and $RetryCount -lt $MaxRetryCount) + $Results.Add("Deleted temporary travel named location '$PolicyName'.") + } + if (!$Locations) { + $Results.Add("No named location named '$PolicyName' was found, it may have been removed already.") + } + + $Result = $Results -join ' ' + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' + return $Result + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to remove temporary travel policy '$PolicyName': $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Error' -LogData $ErrorMessage + throw $Result + } +} diff --git a/Modules/CIPPCore/Public/Remove-CippKeyVaultSecret.ps1 b/Modules/CIPPCore/Public/Remove-CippKeyVaultSecret.ps1 index 667da0c800d82..67f676f38d043 100644 --- a/Modules/CIPPCore/Public/Remove-CippKeyVaultSecret.ps1 +++ b/Modules/CIPPCore/Public/Remove-CippKeyVaultSecret.ps1 @@ -23,10 +23,9 @@ function Remove-CippKeyVaultSecret { try { if (-not $VaultName) { - if ($env:WEBSITE_DEPLOYMENT_ID) { - $VaultName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] - } else { - throw 'VaultName not provided and WEBSITE_DEPLOYMENT_ID environment variable not set' + $VaultName = Get-CippKeyVaultName + if (-not $VaultName) { + throw 'VaultName not provided and could not be derived (WEBSITE_SITE_NAME / WEBSITE_DEPLOYMENT_ID not set)' } } diff --git a/Modules/CIPPCore/Public/Resolve-CIPPCADependencies.ps1 b/Modules/CIPPCore/Public/Resolve-CIPPCADependencies.ps1 new file mode 100644 index 0000000000000..02af8bcb8dfc8 --- /dev/null +++ b/Modules/CIPPCore/Public/Resolve-CIPPCADependencies.ps1 @@ -0,0 +1,216 @@ +function Resolve-CIPPCADependencies { + + # Future Use - Not currently used + + <# + .SYNOPSIS + Reconcile Conditional Access policy dependencies (named locations, authentication + contexts, authentication strengths) ONCE across a set of CA policy/template objects. + .DESCRIPTION + Used by the per-tenant CA batch deployment path (Invoke-CIPPCATemplateBatch) so that + dependencies shared by multiple policies are created/deduplicated a single time. This + avoids the duplicate named locations, c1-c99 authentication-context id collisions, and + error 1040 propagation races that occur when many CA policies deploy concurrently and + each one independently creates the dependencies it references. + + Returns displayName -> id maps that New-CIPPCAPolicy consumes via its -DependencyMap + parameter. Each policy still resolves its OWN references downstream (using its own + LocationInfo / AuthContextInfo) so per-template template-ids never collide across policies. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $TenantFilter, + [object[]]$PolicyObjects, + $AllNamedLocations = $null, + $AllAuthStrengthPolicies = $null, + $AllAuthContexts = $null, + $Overwrite = $true, + $Headers, + $APIName = 'CA Dependency Reconciliation' + ) + + $AuthStrengthMap = @{} + $AuthContextMap = @{} + $LocationMap = @{} + $NewLocationsCreated = $false + + $PolicyObjects = @($PolicyObjects | Where-Object { $_ }) + if ($PolicyObjects.Count -eq 0) { + return @{ + AuthStrength = $AuthStrengthMap + AuthContexts = $AuthContextMap + Locations = $LocationMap + NewLocationsCreated = $false + } + } + + # ---- Authentication strength policies ---- + $NeedAuthStrength = @($PolicyObjects | Where-Object { $_.GrantControls.authenticationStrength.policyType -in @('custom', 'BuiltIn') }) + if ($NeedAuthStrength.Count -gt 0) { + if ($null -eq $AllAuthStrengthPolicies) { + try { + $AllAuthStrengthPolicies = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies/' -tenantid $TenantFilter -asApp $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to fetch authentication strength policies: $($ErrorMessage.NormalizedError)" + } + } + foreach ($Policy in $NeedAuthStrength) { + $Strength = $Policy.GrantControls.authenticationStrength + $Name = $Strength.displayName + if (!$Name -or $AuthStrengthMap.ContainsKey($Name)) { continue } + $ExistingStrength = $AllAuthStrengthPolicies | Where-Object -Property displayName -EQ $Name | Select-Object -First 1 + if ($ExistingStrength) { + $AuthStrengthMap[$Name] = $ExistingStrength.id + } else { + $Body = ConvertTo-Json -InputObject $Strength -Depth 10 + try { + $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies' -body $Body -Type POST -tenantid $TenantFilter -asApp $true -ScheduleRetry $true + $AuthStrengthMap[$Name] = $GraphRequest.id + $AllAuthStrengthPolicies = @($AllAuthStrengthPolicies) + @([pscustomobject]@{ id = $GraphRequest.id; displayName = $Name }) + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Created new Authentication Strength Policy: $Name" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to create authentication strength policy '$Name': $($ErrorMessage.NormalizedError)" + } + } + } + } + + # ---- Authentication context class references ---- + $NeedAuthContext = @($PolicyObjects | Where-Object { $_.AuthContextInfo }) + if ($NeedAuthContext.Count -gt 0) { + if ($null -eq $AllAuthContexts) { + try { + $AllAuthContexts = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationContextClassReferences' -tenantid $TenantFilter -asApp $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to fetch authentication context class references: $($ErrorMessage.NormalizedError)" + } + } + foreach ($Policy in $NeedAuthContext) { + foreach ($authContext in $Policy.AuthContextInfo) { + if (-not $authContext.displayName) { continue } + $Name = $authContext.displayName + if ($AuthContextMap.ContainsKey($Name)) { continue } + $ExistingContext = $AllAuthContexts | Where-Object -Property displayName -EQ $Name | Select-Object -First 1 + if ($ExistingContext) { + $AuthContextMap[$Name] = $ExistingContext.id + } else { + # Find the next available ID (c1-c99) across the running set so concurrent + # contexts in the same batch never collide on the same id. + $UsedIds = @($AllAuthContexts.id) + $NewId = $null + for ($i = 1; $i -le 99; $i++) { + if ("c$i" -notin $UsedIds) { $NewId = "c$i"; break } + } + if (-not $NewId) { + throw "No available authentication context IDs (c1-c99) in tenant $TenantFilter" + } + $Body = @{ + id = $NewId + displayName = $Name + description = if ($authContext.description) { $authContext.description } else { '' } + isAvailable = $true + } | ConvertTo-Json -Compress + try { + $null = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationContextClassReferences' -body $Body -Type POST -tenantid $TenantFilter -asApp $true + $AuthContextMap[$Name] = $NewId + $AllAuthContexts = @($AllAuthContexts) + @([pscustomobject]@{ id = $NewId; displayName = $Name }) + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Created new Authentication Context: $Name with ID $NewId" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to create authentication context '$Name': $($ErrorMessage.NormalizedError)" + } + } + } + } + } + + # ---- Named locations ---- + $NeedLocations = @($PolicyObjects | Where-Object { $_.LocationInfo }) + if ($NeedLocations.Count -gt 0) { + if ($null -eq $AllNamedLocations) { + try { + $AllNamedLocations = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations?$top=999' -tenantid $TenantFilter -asApp $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to fetch named locations: $($ErrorMessage.NormalizedError)" + } + } + foreach ($Policy in $NeedLocations) { + foreach ($locations in $Policy.LocationInfo) { + if (!$locations) { continue } + foreach ($location in $locations) { + if (!$location.displayName) { continue } + $Name = $location.displayName + if ($LocationMap.ContainsKey($Name)) { continue } + $ExistingLocation = @($AllNamedLocations | Where-Object -Property displayName -EQ $Name) + $locationExists = $ExistingLocation.Count -gt 0 + if ($locationExists) { + $ExistingLocation = $ExistingLocation[0] + if ($Overwrite) { + $LocationUpdate = $location | Select-Object * -ExcludeProperty id + Remove-ODataProperties -Object $LocationUpdate + $Body = ConvertTo-Json -InputObject $LocationUpdate -Depth 10 + try { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/$($ExistingLocation.id)" -body $Body -Type PATCH -tenantid $TenantFilter -asApp $true -ScheduleRetry $true + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Updated existing Named Location: $Name" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Named Location '$Name' (id: $($ExistingLocation.id)) could not be updated - it may have been deleted. Will attempt to create it. Error: $($ErrorMessage.NormalizedError)" -Sev 'Warning' -LogData $ErrorMessage + $locationExists = $false + } + } + if ($locationExists) { + $LocationMap[$Name] = $ExistingLocation.id + } + } + if (-not $locationExists) { + if ($location.countriesAndRegions) { $location.countriesAndRegions = @($location.countriesAndRegions) } + $LocationBody = $location | Select-Object * -ExcludeProperty id + Remove-ODataProperties -Object $LocationBody + $Body = ConvertTo-Json -InputObject $LocationBody + try { + $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -body $Body -Type POST -tenantid $TenantFilter -asApp $true + Write-Information "Created named location with ID: $($GraphRequest.id)" + # Wait for location to be available before any policy references it + $retryCount = 0 + $MaxRetryCount = 5 + $LocationRequest = $null + do { + Start-Sleep -Seconds 3 + try { + $LocationRequest = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/$($GraphRequest.id)" -tenantid $TenantFilter -asApp $true -ErrorAction Stop + } catch { + Write-Information 'Location not yet available, will retry...' + } + $retryCount++ + } while ((!$LocationRequest -or !$LocationRequest.id) -and ($retryCount -lt $MaxRetryCount)) + + if (!$LocationRequest -or !$LocationRequest.id) { + Write-Warning "Location $Name created but could not verify availability after $MaxRetryCount attempts. Proceeding anyway." + } + $NewLocationsCreated = $true + $LocationMap[$Name] = $GraphRequest.id + $AllNamedLocations = @($AllNamedLocations) + @([pscustomobject]@{ id = $GraphRequest.id; displayName = $Name }) + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Created new Named Location: $Name" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to create named location $Name : $($ErrorMessage.NormalizedError)" + } + } + } + } + } + } + + return @{ + AuthStrength = $AuthStrengthMap + AuthContexts = $AuthContextMap + Locations = $LocationMap + NewLocationsCreated = $NewLocationsCreated + } +} diff --git a/Modules/CIPPCore/Public/Restore-CIPPSPODeletedSite.ps1 b/Modules/CIPPCore/Public/Restore-CIPPSPODeletedSite.ps1 new file mode 100644 index 0000000000000..e297c6d140a6d --- /dev/null +++ b/Modules/CIPPCore/Public/Restore-CIPPSPODeletedSite.ps1 @@ -0,0 +1,46 @@ +function Restore-CIPPSPODeletedSite { + <# + .SYNOPSIS + Restore a deleted SharePoint site from the tenant recycle bin via CSOM + + .DESCRIPTION + Restores a deleted site collection using the CSOM RestoreDeletedSite method against the + SharePoint admin endpoint (same ProcessQuery pattern as Set-CIPPSharePointPerms). + + .PARAMETER TenantFilter + Tenant the site belongs to + + .PARAMETER SiteUrl + Full URL of the deleted site to restore + + .EXAMPLE + Restore-CIPPSPODeletedSite -TenantFilter 'contoso.onmicrosoft.com' -SiteUrl 'https://contoso.sharepoint.com/sites/OldSite' + + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [Parameter(Mandatory = $true)] + [string]$SiteUrl + ) + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' } + + $XML = @" +$([System.Security.SecurityElement]::Escape($SiteUrl)) +"@ + + if ($PSCmdlet.ShouldProcess($SiteUrl, 'Restore deleted site')) { + $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + $CsomError = ($Results | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage + if ($CsomError) { throw $CsomError } + + return ($Results | Where-Object { $null -ne $_.IsComplete } | Select-Object -First 1) + } +} diff --git a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 index f2241e67deeb6..ffffabf6cdd01 100644 --- a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 @@ -16,6 +16,7 @@ function Send-CIPPAlert { $TableName, $RowKey = [string][guid]::NewGuid(), $Attachments, + $AffectedUser, [switch]$UseStandardizedSchema ) Write-Information 'Shipping Alert' @@ -127,7 +128,7 @@ function Send-CIPPAlert { return (Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq '$SecretName' and RowKey eq '$SecretName'").APIKey } - $KeyVaultName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $KeyVaultName = Get-CippKeyVaultName return (Get-CippKeyVaultSecret -VaultName $KeyVaultName -Name $SecretName -AsPlainText) } @@ -326,21 +327,31 @@ function Send-CIPPAlert { if ($Type -eq 'psa') { Write-Information 'Trying to send to PSA' - if ($config.sendtoIntegration) { - if ($PSCmdlet.ShouldProcess('PSA', 'Sending alert')) { - try { - $Alert = @{ - TenantId = $TenantFilter - AlertText = "$HTMLContent" - AlertTitle = "$($Title)" - } - New-CippExtAlert -Alert $Alert - Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Sent PSA alert $title" -sev info - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-Information "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" - Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + if (-not $config.sendtoIntegration) { + Write-Information 'PSA delivery skipped: sendtoIntegration is disabled in CippNotifications config. Enable it under Settings -> Notifications to route alerts to your PSA.' + return + } + if ($PSCmdlet.ShouldProcess('PSA', 'Sending alert')) { + try { + $Alert = @{ + TenantId = $TenantFilter + AlertText = "$HTMLContent" + AlertTitle = "$($Title)" + } + if ($AffectedUser) { + $Alert.AffectedUser = $AffectedUser + $UserLabel = if ($AffectedUser.UPN) { $AffectedUser.UPN } elseif ($AffectedUser.AzureOID) { "OID:$($AffectedUser.AzureOID)" } else { 'unknown' } + Write-Information "PSA alert AffectedUser: $UserLabel" + } + $PsaResult = New-CippExtAlert -Alert $Alert + if ($PsaResult) { + Write-Information "PSA result: $PsaResult" } + Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Sent PSA alert $title" -sev info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" + Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } } diff --git a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 index 465634d5e7bea..c8ab3e2bf6664 100644 --- a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 @@ -36,6 +36,47 @@ function Send-CIPPScheduledTaskAlert { $Attachments ) + function Format-AlertCellValue { + <# + ConvertTo-Html stringifies non-primitive property values with .ToString(), + which renders nested collections/objects as .NET type names like + 'System.Collections.Generic.List`1[System.Object]'. Flatten them to + readable text instead. '[[BR]]' survives ConvertTo-Html's HTML encoding + and is swapped for
after the fragment is generated. + #> + param($Value, [int]$Depth = 0) + if ($null -eq $Value) { return '' } + if ($Value -is [string]) { return $Value } + if ($Value -is [datetime]) { return $Value.ToString('yyyy-MM-dd HH:mm:ss') } + if ($Value -is [bool] -or $Value.GetType().IsPrimitive -or $Value -is [decimal]) { return "$Value" } + if ($Depth -ge 3) { return "$(try { $Value | ConvertTo-Json -Compress -Depth 5 } catch { $Value })" } + if ($Value -is [System.Collections.IDictionary]) { + return (@($Value.GetEnumerator() | ForEach-Object { "$($_.Key): $(Format-AlertCellValue -Value $_.Value -Depth ($Depth + 1))" }) -join '[[BR]]') + } + if ($Value -is [System.Collections.IEnumerable]) { + return (@($Value | ForEach-Object { Format-AlertCellValue -Value $_ -Depth ($Depth + 1) }) -join '[[BR]]') + } + $Props = @($Value.PSObject.Properties) + if ($Props.Count -gt 0) { + return (@($Props | ForEach-Object { "$($_.Name): $(Format-AlertCellValue -Value $_.Value -Depth ($Depth + 1))" }) -join '[[BR]]') + } + return "$Value" + } + + function ConvertTo-AlertDisplayRow { + # Normalizes a result row for ConvertTo-Html: hashtables become objects (so they + # render as columns instead of Keys/Values/Count) and every value is flattened. + param($Row) + if ($null -eq $Row -or $Row -is [string]) { return $Row } + $Display = [ordered]@{} + if ($Row -is [System.Collections.IDictionary]) { + foreach ($Key in $Row.Keys) { $Display[[string]$Key] = Format-AlertCellValue -Value $Row[$Key] -Depth 1 } + } else { + foreach ($Prop in $Row.PSObject.Properties) { $Display[$Prop.Name] = Format-AlertCellValue -Value $Prop.Value -Depth 1 } + } + [pscustomobject]$Display + } + try { Write-Information "Sending post-execution alerts for task $($TaskInfo.Name)" @@ -56,12 +97,15 @@ function Send-CIPPScheduledTaskAlert { # Build HTML with adaptive table styling $TableDesign = '' + $EncodedTaskName = [System.Web.HttpUtility]::HtmlEncode($TaskInfo.Name) + $EncodedTenantName = [System.Web.HttpUtility]::HtmlEncode($TenantFilter) + $AlertHeader = "

$EncodedTaskName

Tenant: $EncodedTenantName

" $FinalResults = if ($Results -is [array] -and $Results[0] -is [string]) { $Results | ConvertTo-Html -Fragment -Property @{ l = 'Text'; e = { $_ } } } else { - $Results | ConvertTo-Html -Fragment + $Results | ForEach-Object { ConvertTo-AlertDisplayRow -Row $_ } | ConvertTo-Html -Fragment } - $HTML = $FinalResults -replace '', "This alert is for tenant $TenantFilter.

$TableDesign
" | Out-String + $HTML = $FinalResults -replace '\[\[BR\]\]', '
' -replace '
', "$AlertHeader $TableDesign
" | Out-String # For alert tasks, add per-row snooze links to the email if ($TaskType -eq 'Alert' -and $Results -is [array] -and $Results.Count -gt 0 -and $Results[0] -isnot [string]) { @@ -137,11 +181,125 @@ function Send-CIPPScheduledTaskAlert { $NotificationFilter = "RowKey eq 'CippNotifications' and PartitionKey eq 'CippNotifications'" $NotificationConfig = [pscustomobject](Get-CIPPAzDataTableEntity @NotificationTable -Filter $NotificationFilter) $UseStandardizedSchema = [boolean]$NotificationConfig.UseStandardizedSchema + $TaskParameters = $TaskInfo.Parameters + if ($TaskParameters -is [string] -and $TaskParameters) { + try { $TaskParameters = $TaskParameters | ConvertFrom-Json -ErrorAction Stop } catch { $TaskParameters = $null } + } + $ExecutingUser = $TaskParameters.Headers.'x-ms-client-principal-name' # Send to configured alert targets switch -wildcard ($TaskInfo.PostExecution) { '*psa*' { - Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $HTML -TenantFilter $TenantFilter + $PsaSplitSent = $false + $TaskAffectedUser = $null + try { + $ExtConfigTable = Get-CIPPTable -TableName Extensionsconfig + $ExtConfig = (Get-CIPPAzDataTableEntity @ExtConfigTable).config | ConvertFrom-Json -ErrorAction SilentlyContinue + $HaloConfig = $ExtConfig.HaloPSA + + if ($HaloConfig -and $HaloConfig.Enabled) { + # Resolve an affected user from the scheduled task's own parameters first. + # User-targeted tasks (Edit user, license changes, sign-in state) carry the + # user in Parameters while their results often have no UPN column. A UPN-like + # value maps to UPN, a GUID to AzureOID; New-CippExtAlert resolves the rest + # via Graph. Placeholder values (%userid%) from trigger tasks are skipped. + $UserUpn = $null + $UserOid = $null + $UserDisplay = $null + $GuidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + + # Add/Edit user tasks nest the full target user in a UserObj parameter. + # The UPN is either stored directly or composed from username + domain + # (matching how New-CIPPUser/Set-CIPPUser build it). + $UserObj = $TaskParameters.UserObj + if ($UserObj) { + if ($UserObj.userPrincipalName -is [string] -and $UserObj.userPrincipalName -match '@') { + $UserUpn = $UserObj.userPrincipalName + } elseif ($UserObj.username -is [string] -and -not [string]::IsNullOrWhiteSpace($UserObj.username)) { + $UserDomain = if ($UserObj.Domain -is [string] -and $UserObj.Domain) { $UserObj.Domain } else { $UserObj.primDomain.value } + if ($UserDomain -is [string] -and -not [string]::IsNullOrWhiteSpace($UserDomain)) { $UserUpn = "$($UserObj.username)@$UserDomain" } + } + if ($UserObj.id -is [string] -and $UserObj.id -match $GuidPattern) { $UserOid = $UserObj.id } + if ($UserObj.displayName -is [string] -and -not [string]::IsNullOrWhiteSpace($UserObj.displayName)) { $UserDisplay = $UserObj.displayName } + } + + if (-not $UserUpn -and -not $UserOid) { + $ParamKeys = @('UserPrincipalName', 'UPN', 'username', 'user', 'userid', 'id') + foreach ($ParamKey in $ParamKeys) { + $ParamValue = $TaskParameters.$ParamKey + # Form fields store autocomplete selections as {label, value} objects. + if ($ParamValue -isnot [string] -and $null -ne $ParamValue.value -and $ParamValue.value -is [string]) { $ParamValue = $ParamValue.value } + if ($ParamValue -isnot [string] -or [string]::IsNullOrWhiteSpace($ParamValue) -or $ParamValue -match '%') { continue } + if (-not $UserUpn -and $ParamValue -match '@') { $UserUpn = $ParamValue } + elseif (-not $UserOid -and $ParamValue -match $GuidPattern) { $UserOid = $ParamValue } + } + } + + if ($UserUpn -or $UserOid) { + $TaskAffectedUser = [pscustomobject]@{ + UPN = $UserUpn + AzureOID = $UserOid + DisplayName = $UserDisplay + } + } + + # Per-task PsaTicketStrategy (configured on the task) overrides the global + # HaloPSA.LinkTicketsToUsers toggle. Lets MSPs decide on a per-task basis + # whether a wide result set (e.g. "users without MFA") should produce one + # ticket per user or one consolidated ticket per tenant. + $TaskStrategy = $TaskInfo.PsaTicketStrategy + $ShouldSplit = switch ($TaskStrategy) { + 'split' { $true } + 'consolidated' { $false } + default { [bool]$HaloConfig.LinkTicketsToUsers } + } + + if ($ShouldSplit -and $Results -is [array] -and $Results.Count -gt 0 -and $Results[0] -isnot [string]) { + $UpnFieldCandidates = @('UserPrincipalName', 'userPrincipalName', 'UPN', 'userId', 'Userkey', 'Member') + $RowProperties = $Results[0].PSObject.Properties.Name + $UpnField = $UpnFieldCandidates | Where-Object { $_ -in $RowProperties } | Select-Object -First 1 + + if ($UpnField) { + $DisplayFieldCandidates = @('DisplayName', 'displayName', 'userDisplayName') + $DisplayField = $DisplayFieldCandidates | Where-Object { $_ -in $RowProperties } | Select-Object -First 1 + + $Groups = $Results | Group-Object -Property $UpnField + + foreach ($Group in $Groups) { + $GroupKey = $Group.Name + $GroupHTMLFragment = $Group.Group | ForEach-Object { ConvertTo-AlertDisplayRow -Row $_ } | ConvertTo-Html -Fragment + $GroupHTML = $GroupHTMLFragment -replace '\[\[BR\]\]', '
' -replace '
', "$AlertHeader $TableDesign
" | Out-String + + if ([string]::IsNullOrWhiteSpace($GroupKey)) { + # Rows without a usable user identifier - fall back to the + # task-level affected user if one was resolved. + $GroupParams = @{ Type = 'psa'; Title = $title; HTMLContent = $GroupHTML; TenantFilter = $TenantFilter } + if ($TaskAffectedUser) { $GroupParams.AffectedUser = $TaskAffectedUser } + Send-CIPPAlert @GroupParams + } else { + $GroupDisplayName = if ($DisplayField) { $Group.Group[0].$DisplayField } else { $null } + $UserLabel = if ($GroupDisplayName) { "$GroupDisplayName ($GroupKey)" } else { $GroupKey } + $UserTitle = "$title - $UserLabel" + $AffectedUser = [pscustomobject]@{ + UPN = $GroupKey + DisplayName = $GroupDisplayName + } + Send-CIPPAlert -Type 'psa' -Title $UserTitle -HTMLContent $GroupHTML -TenantFilter $TenantFilter -AffectedUser $AffectedUser + } + } + $PsaSplitSent = $true + } + } + } + } catch { + Write-Information "Failed to resolve PSA affected user or split by user, falling back to consolidated ticket: $($_.Exception.Message)" + } + + if (-not $PsaSplitSent) { + $PsaParams = @{ Type = 'psa'; Title = $title; HTMLContent = $HTML; TenantFilter = $TenantFilter } + if ($TaskAffectedUser) { $PsaParams.AffectedUser = $TaskAffectedUser } + Send-CIPPAlert @PsaParams + } } '*email*' { $EmailParams = @{ @@ -181,10 +339,11 @@ function Send-CIPPScheduledTaskAlert { $Webhook = if ($UseStandardizedSchema) { $obj = [PSCustomObject]@{ - tenantId = $TenantInfo.customerId - tenant = $TenantFilter - taskType = $TaskType - task = [PSCustomObject]@{ + tenantId = $TenantInfo.customerId + tenant = $TenantFilter + taskType = $TaskType + executingUser = $ExecutingUser + task = [PSCustomObject]@{ id = $TaskInfo.RowKey name = $TaskInfo.Name command = $TaskInfo.Command @@ -194,8 +353,8 @@ function Send-CIPPScheduledTaskAlert { executed = $TaskInfo.ExecutedTime partition = $TaskInfo.PartitionKey } - results = $Results - alertComment = $TaskInfo.AlertComment + results = $Results + alertComment = $TaskInfo.AlertComment } if ($SnoozeInfo) { $obj | Add-Member -NotePropertyName 'snooze' -NotePropertyValue $SnoozeInfo } $obj diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 index fe55f61476386..18abcd1c7c659 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 @@ -3,12 +3,15 @@ function Set-CIPPAssignedApplication { param( $GroupName, $ExcludeGroup, + $ExcludeGroupIds, + $ExcludeGroupNames, $Intent, $AppType, $ApplicationId, $TenantFilter, $GroupIds, $AssignmentMode = 'replace', + $AssignmentDirection, $APIName = 'Assign Application', $Headers, $AssignmentFilterName, @@ -106,12 +109,12 @@ function Set-CIPPAssignedApplication { $resolvedGroupIds = @() if ($PSBoundParameters.ContainsKey('GroupIds') -and $GroupIds) { $resolvedGroupIds = $GroupIds - } else { + } elseif ($GroupName) { $GroupNames = $GroupName.Split(',') $resolvedGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $TenantFilter | ForEach-Object { $Group = $_ foreach ($SingleName in $GroupNames) { - if ($Group.displayName -like $SingleName) { + if ($Group.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) { $Group.id } } @@ -119,8 +122,10 @@ function Set-CIPPAssignedApplication { Write-Information "found $($resolvedGroupIds) groups" } - # We ain't found nothing so we panic - if (-not $resolvedGroupIds) { + # Only panic when an include target was actually requested. Exclude-only + # assignments legitimately resolve to no include groups here. + $IncludeRequested = $GroupName -or ($GroupIds -and @($GroupIds).Count -gt 0) + if (-not $resolvedGroupIds -and $IncludeRequested) { throw 'No matching groups resolved for assignment request.' } @@ -138,20 +143,34 @@ function Set-CIPPAssignedApplication { } } + # Normalize to an array so appending exclusions appends an element rather than + # merging hashtable keys (a single include group makes the switch return a scalar). + # Filter nulls so an exclude-only assignment doesn't carry an empty placeholder. + $MobileAppAssignment = @($MobileAppAssignment | Where-Object { $_ }) + # Add exclusion group assignments - if ($ExcludeGroup) { - Write-Host "Excluding group(s) from application assignment: $ExcludeGroup" - $ExcludeGroupNames = $ExcludeGroup.Split(',').Trim() - $ExcludeGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $TenantFilter | ForEach-Object { - $Group = $_ - foreach ($SingleName in $ExcludeGroupNames) { - if ($Group.displayName -like $SingleName) { - $Group.id + if ($ExcludeGroup -or ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0)) { + # Prefer explicit group IDs (from the picker); fall back to name resolution + # for templates/wizards/API callers that still send ExcludeGroup names. + if ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { + Write-Host "Excluding group(s) by id from application assignment: $($ExcludeGroupIds -join ', ')" + $ResolvedExcludeIds = @($ExcludeGroupIds) + } else { + Write-Host "Excluding group(s) from application assignment: $ExcludeGroup" + $ExcludeGroupNames = $ExcludeGroup.Split(',').Trim() + $ResolvedExcludeIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $TenantFilter | ForEach-Object { + $Group = $_ + foreach ($SingleName in $ExcludeGroupNames) { + if ($Group.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) { + $Group.id + } } } } - foreach ($egid in $ExcludeGroupIds) { + foreach ($egid in $ResolvedExcludeIds) { + # Graph rejects 'settings' on exclusion targets: + # "Exclusion assignment does not support MobileAppAssignment Settings." $MobileAppAssignment += @{ '@odata.type' = '#microsoft.graph.mobileAppAssignment' target = @{ @@ -159,7 +178,6 @@ function Set-CIPPAssignedApplication { groupId = $egid } intent = $Intent - settings = $assignmentSettings } } } @@ -176,59 +194,70 @@ function Set-CIPPAssignedApplication { } } - # If we're appending, we need to get existing assignments - if ($AssignmentMode -eq 'append') { + # Determine which existing assignments (if any) must be preserved. + # append -> keep all existing (minus ones the new set overrides) + # replace + direction -> keep everything except the direction being edited + # (Custom Group action only; legacy replace overwrites everything) + $DirectionScoped = -not [string]::IsNullOrWhiteSpace($AssignmentDirection) + $EditedType = switch ($AssignmentDirection) { + 'exclude' { '#microsoft.graph.exclusionGroupAssignmentTarget' } + 'include' { '#microsoft.graph.groupAssignmentTarget' } + default { $null } + } + $PreserveExisting = ($AssignmentMode -eq 'append') -or ($AssignmentMode -eq 'replace' -and $DirectionScoped) + + $ExistingAssignments = @() + if ($PreserveExisting) { try { $ExistingAssignments = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$($ApplicationId)/assignments" -tenantid $TenantFilter } catch { - Write-Warning "Unable to retrieve existing assignments for $ApplicationId. Proceeding with new assignments only. Error: $($_.Exception.Message)" - $ExistingAssignments = @() + $ErrorMessage = "Unable to retrieve existing assignments for $ApplicationId. Existing assignments must be preserved for assignment mode '$AssignmentMode' and direction '$AssignmentDirection'. Aborting to avoid removing assignments. Error: $($_.Exception.Message)" + Write-Warning $ErrorMessage + throw $ErrorMessage } } - # Deduplicate current assignments so the new ones override existing ones + # Decide which existing assignments to carry forward. + $KeptAssignments = [System.Collections.Generic.List[object]]::new() if ($ExistingAssignments) { - $ExistingAssignments = $ExistingAssignments | ForEach-Object { - $ExistingAssignment = $_ - switch ($ExistingAssignment.target.'@odata.type') { - '#microsoft.graph.groupAssignmentTarget' { - if ($ExistingAssignment.target.groupId -notin $MobileAppAssignment.target.groupId) { - $ExistingAssignment - } - } - '#microsoft.graph.exclusionGroupAssignmentTarget' { - if ($ExistingAssignment.target.groupId -notin $MobileAppAssignment.target.groupId) { - $ExistingAssignment - } - } - default { - if ($ExistingAssignment.target.'@odata.type' -notin $MobileAppAssignment.target.'@odata.type') { - $ExistingAssignment - } + foreach ($ExistingAssignment in @($ExistingAssignments)) { + $ExistingType = $ExistingAssignment.target.'@odata.type' + $Keep = if ($AssignmentMode -eq 'replace' -and $DirectionScoped) { + # Direction-scoped replace: drop every target of the edited type, keep the rest + # (the other direction plus All Users / All Devices broad targets). + $ExistingType -ne $EditedType + } else { + # Append: keep existing unless the new set overrides the same group/target. + switch ($ExistingType) { + '#microsoft.graph.groupAssignmentTarget' { $ExistingAssignment.target.groupId -notin $MobileAppAssignment.target.groupId } + '#microsoft.graph.exclusionGroupAssignmentTarget' { $ExistingAssignment.target.groupId -notin $MobileAppAssignment.target.groupId } + default { $ExistingType -notin $MobileAppAssignment.target.'@odata.type' } } } + if ($Keep) { + $KeptAssignments.Add($ExistingAssignment) + } } } $FinalAssignments = [System.Collections.Generic.List[object]]::new() - if ($AssignmentMode -eq 'append' -and $ExistingAssignments) { - $ExistingAssignments | ForEach-Object { - $FinalAssignments.Add(@{ - '@odata.type' = '#microsoft.graph.mobileAppAssignment' - target = $_.target - intent = $_.intent - settings = $_.settings - }) + if ($PreserveExisting) { + # Rebuild each assignment, omitting 'settings' on exclusion targets (Graph rejects it). + $AddAssignment = { + param($a) + $entry = @{ + '@odata.type' = '#microsoft.graph.mobileAppAssignment' + target = $a.target + intent = $a.intent + } + if ($a.target.'@odata.type' -ne '#microsoft.graph.exclusionGroupAssignmentTarget' -and $null -ne $a.settings) { + $entry.settings = $a.settings + } + $FinalAssignments.Add($entry) } - $MobileAppAssignment | ForEach-Object { - $FinalAssignments.Add(@{ - '@odata.type' = '#microsoft.graph.mobileAppAssignment' - target = $_.target - intent = $_.intent - settings = $_.settings - }) - } + $KeptAssignments | ForEach-Object { & $AddAssignment $_ } + $MobileAppAssignment | ForEach-Object { & $AddAssignment $_ } } else { $FinalAssignments = $MobileAppAssignment } @@ -238,12 +267,40 @@ function Set-CIPPAssignedApplication { $FinalAssignments ) } - if ($PSCmdlet.ShouldProcess($GroupName, "Assigning Application $ApplicationId")) { + $ShouldProcess = $PSCmdlet.ShouldProcess($GroupName, "Assigning Application $ApplicationId") + if ($ShouldProcess) { Start-Sleep -Seconds 1 $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$($ApplicationId)/assign" -tenantid $TenantFilter -type POST -body ($DefaultAssignmentObject | ConvertTo-Json -Compress -Depth 10) - Write-LogMessage -headers $Headers -API $APIName -message "Assigned Application $ApplicationId to $($GroupName)" -Sev 'Info' -tenant $TenantFilter } - return "Assigned Application $ApplicationId to $($GroupName)" + + $AssignedGroupsDisplay = if ($GroupName) { + $GroupName + } elseif ($GroupIds -and @($GroupIds).Count -gt 0) { + @($GroupIds) -join ', ' + } + + $ExcludedGroupsDisplay = if ($ExcludeGroupNames -and @($ExcludeGroupNames).Count -gt 0) { + @($ExcludeGroupNames) -join ', ' + } elseif ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { + @($ExcludeGroupIds) -join ', ' + } else { + $ExcludeGroup + } + + $ResultMessage = if ($ExcludedGroupsDisplay -and $AssignedGroupsDisplay) { + "Assigned Application $ApplicationId to $AssignedGroupsDisplay excluding group '$ExcludedGroupsDisplay'" + } elseif ($ExcludedGroupsDisplay) { + "Updated exclusions for Application $ApplicationId to group '$ExcludedGroupsDisplay'" + } elseif ($AssignmentDirection -eq 'exclude' -and $AssignmentMode -eq 'replace') { + "Cleared exclusions for Application $ApplicationId" + } else { + "Assigned Application $ApplicationId to $AssignedGroupsDisplay" + } + + if ($ShouldProcess) { + Write-LogMessage -headers $Headers -API $APIName -message $ResultMessage -Sev 'Info' -tenant $TenantFilter + } + return $ResultMessage } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -headers $Headers -API $APIName -message "Could not assign application $ApplicationId to $GroupName. Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 index c616658100f63..faff217f016f1 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 @@ -3,6 +3,8 @@ function Set-CIPPAssignedPolicy { param( $GroupName, $ExcludeGroup, + $ExcludeGroupIds, + $ExcludeGroupNames, $PolicyId, $Type, $TenantFilter, @@ -13,7 +15,8 @@ function Set-CIPPAssignedPolicy { $AssignmentFilterType = 'include', $GroupIds, $GroupNames, - $AssignmentMode = 'replace' + $AssignmentMode = 'replace', + $AssignmentDirection ) Write-Host "Assigning policy $PolicyId ($PlatformType/$Type) to $GroupName" @@ -86,14 +89,17 @@ function Set-CIPPAssignedPolicy { $resolvedGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$select=id,displayName&$top=999' -tenantid $TenantFilter | ForEach-Object { foreach ($SingleName in $GroupNames) { - if ($_.displayName -like $SingleName) { + if ($_.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) { $_.id } } } } - if (-not $resolvedGroupIds -or $resolvedGroupIds.Count -eq 0) { + # Only error when an include target was actually requested. Exclude-only + # assignments legitimately resolve to no include groups here. + $IncludeRequested = $GroupName -or ($GroupIds -and @($GroupIds).Count -gt 0) + if ((-not $resolvedGroupIds -or $resolvedGroupIds.Count -eq 0) -and $IncludeRequested) { $ErrorMessage = "No groups found matching the specified name(s): $GroupName. Policy not assigned." Write-LogMessage -headers $Headers -API $APIName -message $ErrorMessage -sev 'Warning' -tenant $TenantFilter throw $ErrorMessage @@ -111,19 +117,26 @@ function Set-CIPPAssignedPolicy { } } } - if ($ExcludeGroup) { - Write-Host "We're supposed to exclude a custom group. The group is $ExcludeGroup" - $ExcludeGroupNames = $ExcludeGroup.Split(',').Trim() - $ExcludeGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$select=id,displayName&$top=999' -tenantid $TenantFilter | - ForEach-Object { - foreach ($SingleName in $ExcludeGroupNames) { - if ($_.displayName -like $SingleName) { - $_.id + if ($ExcludeGroup -or ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0)) { + # Prefer explicit group IDs (from the picker); fall back to name resolution + # for templates/wizards/API callers that still send ExcludeGroup names. + if ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { + Write-Host "Excluding custom group(s) by id: $($ExcludeGroupIds -join ', ')" + $ResolvedExcludeIds = @($ExcludeGroupIds) + } else { + Write-Host "We're supposed to exclude a custom group. The group is $ExcludeGroup" + $ExcludeGroupNames = $ExcludeGroup.Split(',').Trim() + $ResolvedExcludeIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$select=id,displayName&$top=999' -tenantid $TenantFilter | + ForEach-Object { + foreach ($SingleName in $ExcludeGroupNames) { + if ($_.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) { + $_.id + } } } - } + } - foreach ($egid in $ExcludeGroupIds) { + foreach ($egid in $ResolvedExcludeIds) { $assignmentsList.Add( @{ target = @{ @@ -147,50 +160,51 @@ function Set-CIPPAssignedPolicy { } } - # If we're appending, we need to get existing assignments + # Determine which existing assignments (if any) must be preserved. + # append -> keep all existing (minus ones the new set overrides) + # replace + direction -> keep everything except the direction being edited + # (Custom Group action only; legacy replace overwrites everything) + $DirectionScoped = -not [string]::IsNullOrWhiteSpace($AssignmentDirection) + $EditedType = switch ($AssignmentDirection) { + 'exclude' { '#microsoft.graph.exclusionGroupAssignmentTarget' } + 'include' { '#microsoft.graph.groupAssignmentTarget' } + default { $null } + } + $PreserveExisting = ($AssignmentMode -eq 'append') -or ($AssignmentMode -eq 'replace' -and $DirectionScoped) + $ExistingAssignments = @() - if ($AssignmentMode -eq 'append') { + if ($PreserveExisting) { try { $uri = "https://graph.microsoft.com/beta/$($PlatformType)/$Type('$($PolicyId)')/assignments" $ExistingAssignments = New-GraphGetRequest -uri $uri -tenantid $TenantFilter Write-Host "Found $($ExistingAssignments.Count) existing assignments for policy $PolicyId" } catch { - Write-Warning "Unable to retrieve existing assignments for $PolicyId. Proceeding with new assignments only. Error: $($_.Exception.Message)" - $ExistingAssignments = @() + $ErrorMessage = "Unable to retrieve existing assignments for $PolicyId. Existing assignments must be preserved for assignment mode '$AssignmentMode' and direction '$AssignmentDirection'. Aborting to avoid removing assignments. Error: $($_.Exception.Message)" + Write-Warning $ErrorMessage + throw $ErrorMessage } } - # Deduplicate current assignments so the new ones override existing ones + # Decide which existing assignments to carry forward. + $FinalAssignments = [System.Collections.Generic.List[object]]::new() if ($ExistingAssignments -and $ExistingAssignments.Count -gt 0) { - $ExistingAssignments = $ExistingAssignments | ForEach-Object { - $ExistingAssignment = $_ - switch ($ExistingAssignment.target.'@odata.type') { - '#microsoft.graph.groupAssignmentTarget' { - if ($ExistingAssignment.target.groupId -notin $assignmentsList.target.groupId) { - $ExistingAssignment - } - } - '#microsoft.graph.exclusionGroupAssignmentTarget' { - if ($ExistingAssignment.target.groupId -notin $assignmentsList.target.groupId) { - $ExistingAssignment - } - } - default { - if ($ExistingAssignment.target.'@odata.type' -notin $assignmentsList.target.'@odata.type') { - $ExistingAssignment - } + foreach ($ExistingAssignment in $ExistingAssignments) { + $ExistingType = $ExistingAssignment.target.'@odata.type' + $Keep = if ($AssignmentMode -eq 'replace' -and $DirectionScoped) { + # Direction-scoped replace: drop every target of the edited type, keep the rest + # (the other direction plus All Users / All Devices broad targets). + $ExistingType -ne $EditedType + } else { + # Append: keep existing unless the new set overrides the same group/target. + switch ($ExistingType) { + '#microsoft.graph.groupAssignmentTarget' { $ExistingAssignment.target.groupId -notin $assignmentsList.target.groupId } + '#microsoft.graph.exclusionGroupAssignmentTarget' { $ExistingAssignment.target.groupId -notin $assignmentsList.target.groupId } + default { $ExistingType -notin $assignmentsList.target.'@odata.type' } } } - } - } - - # Build final assignments list - $FinalAssignments = [System.Collections.Generic.List[object]]::new() - if ($AssignmentMode -eq 'append' -and $ExistingAssignments) { - foreach ($existing in $ExistingAssignments) { - $FinalAssignments.Add(@{ - target = $existing.target - }) + if ($Keep) { + $FinalAssignments.Add(@{ target = $ExistingAssignment.target }) + } } } @@ -209,7 +223,8 @@ function Set-CIPPAssignedPolicy { $assignmentsObject = @{ $AssignmentPropertyName = @($FinalAssignments) } $AssignJSON = ConvertTo-Json -InputObject $assignmentsObject -Depth 10 -Compress - if ($PSCmdlet.ShouldProcess($GroupName, "Assigning policy $PolicyId")) { + $ShouldProcess = $PSCmdlet.ShouldProcess($GroupName, "Assigning policy $PolicyId") + if ($ShouldProcess) { $uri = "https://graph.microsoft.com/beta/$($PlatformType)/$Type('$($PolicyId)')/assign" $null = New-GraphPOSTRequest -uri $uri -tenantid $TenantFilter -type POST -body $AssignJSON @@ -218,17 +233,34 @@ function Set-CIPPAssignedPolicy { ($GroupNames -join ', ') } elseif ($GroupName) { $GroupName + } elseif ($GroupIds -and @($GroupIds).Count -gt 0) { + @($GroupIds) -join ', ' } else { - 'specified groups' + $null } - if ($ExcludeGroup) { - Write-LogMessage -headers $Headers -API $APIName -message "Assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludeGroup' on Policy $PolicyId" -Sev 'Info' -tenant $TenantFilter - return "Successfully assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludeGroup' on Policy $PolicyId" + $ExcludedGroupsDisplay = if ($ExcludeGroupNames -and @($ExcludeGroupNames).Count -gt 0) { + ($ExcludeGroupNames -join ', ') + } elseif ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { + ($ExcludeGroupIds -join ', ') } else { - Write-LogMessage -headers $Headers -API $APIName -message "Assigned group '$AssignedGroupsDisplay' on Policy $PolicyId" -Sev 'Info' -tenant $TenantFilter - return "Successfully assigned group '$AssignedGroupsDisplay' on Policy $PolicyId" + $ExcludeGroup + } + + $ResultMessage = if ($ExcludedGroupsDisplay -and $AssignedGroupsDisplay) { + "Successfully assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludedGroupsDisplay' on Policy $PolicyId" + } elseif ($ExcludedGroupsDisplay) { + "Successfully updated exclusions to group '$ExcludedGroupsDisplay' on Policy $PolicyId" + } elseif ($AssignmentDirection -eq 'exclude' -and $AssignmentMode -eq 'replace') { + "Successfully cleared exclusions on Policy $PolicyId" + } else { + "Successfully assigned group '$AssignedGroupsDisplay' on Policy $PolicyId" + } + + if ($ShouldProcess) { + Write-LogMessage -headers $Headers -API $APIName -message $ResultMessage -Sev 'Info' -tenant $TenantFilter } + return $ResultMessage } } catch { diff --git a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 index cd6d7d7d8c185..e21035825f43a 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 @@ -18,6 +18,10 @@ function Set-CIPPAuthenticationPolicy { [Parameter()][ValidateRange(8, 20)]$QRCodePinLength = 8, [Parameter()][ValidateSet('default', 'enabled', 'disabled')]$EmailAllowExternalIdToUseEmailOtp, [Parameter()][string[]]$EmailExcludeGroupIds, + [Parameter()][bool]$FIDO2AttestationEnforced, + [Parameter()][bool]$FIDO2SelfServiceRegistration, + [Parameter()][bool]$VoiceIsOfficePhoneAllowed, + [Parameter()][bool]$SmsIsUsableForSignIn, $APIName = 'Set Authentication Policy', $Headers ) @@ -39,37 +43,52 @@ function Set-CIPPAuthenticationPolicy { # FIDO2 'FIDO2' { if ($State -eq 'enabled') { - $CurrentInfo.isAttestationEnforced = $true - $CurrentInfo.isSelfServiceRegistrationAllowed = $true + # Honor passed values; otherwise default to enforced/allowed to preserve previous enable behavior + $CurrentInfo.isAttestationEnforced = if ($PSBoundParameters.ContainsKey('FIDO2AttestationEnforced')) { $FIDO2AttestationEnforced } else { $true } + $CurrentInfo.isSelfServiceRegistrationAllowed = if ($PSBoundParameters.ContainsKey('FIDO2SelfServiceRegistration')) { $FIDO2SelfServiceRegistration } else { $true } + $OptionalLogMessage = "with attestation enforced set to $($CurrentInfo.isAttestationEnforced) and self-service registration set to $($CurrentInfo.isSelfServiceRegistrationAllowed)" } } # Microsoft Authenticator 'MicrosoftAuthenticator' { if ($State -eq 'enabled') { + $AuthChanges = [System.Collections.Generic.List[string]]::new() # Set MS authenticator OTP state if parameter is passed in if ($null -ne $MicrosoftAuthenticatorSoftwareOathEnabled) { $CurrentInfo.isSoftwareOathEnabled = $MicrosoftAuthenticatorSoftwareOathEnabled - $OptionalLogMessage = "and MS Authenticator software OTP to $MicrosoftAuthenticatorSoftwareOathEnabled" + $AuthChanges.Add("software OTP set to $MicrosoftAuthenticatorSoftwareOathEnabled") } # Feature settings if ($MicrosoftAuthenticatorDisplayAppInfo) { $CurrentInfo.featureSettings.displayAppInformationRequiredState.state = $MicrosoftAuthenticatorDisplayAppInfo + $AuthChanges.Add("display app information set to $MicrosoftAuthenticatorDisplayAppInfo") } if ($MicrosoftAuthenticatorDisplayLocation) { $CurrentInfo.featureSettings.displayLocationInformationRequiredState.state = $MicrosoftAuthenticatorDisplayLocation + $AuthChanges.Add("display location set to $MicrosoftAuthenticatorDisplayLocation") } if ($MicrosoftAuthenticatorCompanionApp) { $CurrentInfo.featureSettings.companionAppAllowedState.state = $MicrosoftAuthenticatorCompanionApp + $AuthChanges.Add("companion app set to $MicrosoftAuthenticatorCompanionApp") } # numberMatchingRequiredState is permanently enabled by Microsoft and can no longer be toggled $CurrentInfo.featureSettings.PSObject.Properties.Remove('numberMatchingRequiredState') + if ($AuthChanges.Count -gt 0) { + $OptionalLogMessage = "with $($AuthChanges -join ', ')" + } } } # SMS 'SMS' { - # No special configuration needed + # SMS sign-in is set per include-target (smsAuthenticationMethodTarget.isUsableForSignIn) + if ($State -eq 'enabled' -and $PSBoundParameters.ContainsKey('SmsIsUsableForSignIn')) { + foreach ($Target in $CurrentInfo.includeTargets) { + $Target | Add-Member -NotePropertyName 'isUsableForSignIn' -NotePropertyValue $SmsIsUsableForSignIn -Force + } + $OptionalLogMessage = "with SMS sign-in set to $SmsIsUsableForSignIn" + } } # Temporary Access Pass @@ -80,7 +99,7 @@ function Set-CIPPAuthenticationPolicy { $CurrentInfo.maximumLifetimeInMinutes = $TAPMaximumLifetime $CurrentInfo.defaultLifetimeInMinutes = $TAPDefaultLifeTime $CurrentInfo.defaultLength = $TAPDefaultLength - $OptionalLogMessage = "with TAP isUsableOnce set to $TAPisUsableOnce" + $OptionalLogMessage = "with TAP isUsableOnce set to $TAPisUsableOnce, minimum lifetime $TAPMinimumLifetime min, maximum lifetime $TAPMaximumLifetime min, default lifetime $TAPDefaultLifeTime min, and default length $TAPDefaultLength" } } @@ -96,7 +115,10 @@ function Set-CIPPAuthenticationPolicy { # Voice call 'Voice' { - # No special configuration needed + if ($State -eq 'enabled' -and $PSBoundParameters.ContainsKey('VoiceIsOfficePhoneAllowed')) { + $CurrentInfo.isOfficePhoneAllowed = $VoiceIsOfficePhoneAllowed + $OptionalLogMessage = "with isOfficePhoneAllowed set to $VoiceIsOfficePhoneAllowed" + } } # Email OTP @@ -106,7 +128,8 @@ function Set-CIPPAuthenticationPolicy { $CurrentInfo.allowExternalIdToUseEmailOtp = $EmailAllowExternalIdToUseEmailOtp $OptionalLogMessage = "with allowExternalIdToUseEmailOtp set to $EmailAllowExternalIdToUseEmailOtp" } - if ($EmailExcludeGroupIds) { + # Present (even empty) means the caller is setting the exclude list; an empty array clears it + if ($PSBoundParameters.ContainsKey('EmailExcludeGroupIds')) { $CurrentInfo.excludeTargets = @( foreach ($id in $EmailExcludeGroupIds) { [pscustomobject]@{ @@ -115,7 +138,11 @@ function Set-CIPPAuthenticationPolicy { } } ) - $OptionalLogMessage += " and excluded groups set to $($EmailExcludeGroupIds -join ', ')" + if ($EmailExcludeGroupIds) { + $OptionalLogMessage += " and excluded groups set to $($EmailExcludeGroupIds -join ', ')" + } else { + $OptionalLogMessage += ' and excluded groups cleared' + } } } } @@ -130,6 +157,7 @@ function Set-CIPPAuthenticationPolicy { if ($State -eq 'enabled') { $CurrentInfo.standardQRCodeLifetimeInDays = $QRCodeLifetimeInDays $CurrentInfo.pinLength = $QRCodePinLength + $OptionalLogMessage = "with QR code lifetime $QRCodeLifetimeInDays days and PIN length $QRCodePinLength" } } default { diff --git a/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 index 49c9e061dbf59..62157a91fa0c9 100644 --- a/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 @@ -16,7 +16,7 @@ function Set-CIPPIntunePolicy { [int]$LevenshteinDistance = 0 ) - $RawJSON = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $RawJSON + $RawJSON = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $RawJSON -EscapeForJson if ($LevenshteinDistance -gt 5) { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "LevenshteinDistance is set to $LevenshteinDistance. Values above 5 can match unrelated policies; use with caution." -Sev Warning diff --git a/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 b/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 index b16d867d6f411..54a1a075c040f 100644 --- a/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 @@ -7,7 +7,7 @@ function Set-CIPPMailboxAccess { $TenantFilter, $APIName = 'Manage Shared Mailbox Access', $Headers, - [array]$AccessRights + [array]$AccessRights # Retained for caller compatibility; this helper grants FullAccess ) # Ensure AccessUser is always an array @@ -22,20 +22,12 @@ function Set-CIPPMailboxAccess { $Results = [system.collections.generic.list[string]]::new() - # Process each access user + # Delegate each grant to Set-CIPPMailboxPermission so the permission-level -> EXO cmdlet mapping, + # logging, cache sync, and error handling all live in one place. This helper grants FullAccess. foreach ($User in $AccessUser) { - try { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Add-MailboxPermission' -cmdParams @{Identity = $userid; user = $User; AutoMapping = $Automap; accessRights = $AccessRights; InheritanceType = 'all' } -Anchor $userid - - $Message = "Successfully added $($User) to $($userid) Shared Mailbox $($Automap ? 'with' : 'without') AutoMapping, with the following permissions: $AccessRights" - Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter - $Results.Add($Message) - } catch { - $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to add mailbox permissions for $($User) on $($userid). Error: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage - $Results.Add($Message) - } + $Results.Add( + (Set-CIPPMailboxPermission -UserId $userid -AccessUser $User -PermissionLevel 'FullAccess' -Action 'Add' -AutoMap $Automap -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers) + ) } return $Results diff --git a/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 b/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 index 3679e4a89c060..89e767d32a44a 100644 --- a/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 @@ -37,7 +37,7 @@ function Set-CIPPNotificationConfig { } Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force | Out-Null } else { - $KeyVaultName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $KeyVaultName = Get-CippKeyVaultName Set-CippKeyVaultSecret -VaultName $KeyVaultName -Name $SecretName -SecretValue (ConvertTo-SecureString -AsPlainText -Force -String $SecretValue) | Out-Null } } diff --git a/Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1 b/Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1 new file mode 100644 index 0000000000000..1fc745e0ca115 --- /dev/null +++ b/Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1 @@ -0,0 +1,110 @@ +function Set-CIPPSAMCertificate { + <# + .SYNOPSIS + Stores the SAM certificate PFX in Key Vault (dual-mode) or the DevSecrets table + + .DESCRIPTION + Persists a base64 PFX. In production it first attempts the Key Vault certificates + import API; if the managed identity lacks certificate permissions (403, the case on + deployments created before certificate permissions were added to the templates) or the + name is already occupied by a plain secret (409), it falls back to storing the base64 + PFX as a regular Key Vault secret. Both modes are readable through the secrets endpoint + under the same name, so Get-CIPPSAMCertificate does not need to know which mode is active. + In dev mode (Azurite) the PFX is stored on the DevSecrets table row instead. + + .PARAMETER PfxBase64 + The certificate as a base64-encoded passwordless PFX. + + .PARAMETER Name + Storage name used for both the Key Vault certificate and the fallback secret. Defaults to SAMCertificate. + + .PARAMETER VaultName + Name of the Key Vault. If not provided, derives via Get-CippKeyVaultName. + + .EXAMPLE + Set-CIPPSAMCertificate -PfxBase64 $Cert.PfxBase64 + #> + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')] + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$PfxBase64, + + [Parameter(Mandatory = $false)] + [string]$Name = 'SAMCertificate', + + [Parameter(Mandatory = $false)] + [string]$VaultName + ) + + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $Table = Get-CIPPTable -tablename 'DevSecrets' + $Secret = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" + if (!$Secret) { + throw 'DevSecrets table row not found. Cannot store SAM certificate in dev mode.' + } + $Secret | Add-Member -MemberType NoteProperty -Name $Name -Value $PfxBase64 -Force + Add-AzDataTableEntity @Table -Entity $Secret -Force + Update-CIPPSAMCertificateEnvCache -Name $Name -PfxBase64 $PfxBase64 + return @{ StorageMode = 'DevTable'; Name = $Name } + } + + if (-not $VaultName) { + $VaultName = Get-CippKeyVaultName + if (-not $VaultName) { + throw 'VaultName not provided and could not be derived (WEBSITE_SITE_NAME / WEBSITE_DEPLOYMENT_ID not set)' + } + } + + $Token = Get-CIPPAzIdentityToken -ResourceUrl 'https://vault.azure.net' + + # Attempt the certificates import API first. exportable must be true so the private + # key remains retrievable through the secrets endpoint. + $ImportBody = @{ + value = $PfxBase64 + policy = @{ + key_props = @{ + exportable = $true + kty = 'RSA' + key_size = 2048 + reuse_key = $false + } + secret_props = @{ + contentType = 'application/x-pkcs12' + } + } + } | ConvertTo-Json -Compress -Depth 10 + + $ImportUri = "https://$VaultName.vault.azure.net/certificates/$Name/import?api-version=7.4" + $StatusCode = $null + $ImportResponse = Invoke-CIPPRestMethod -Uri $ImportUri -Method POST -Body $ImportBody -ContentType 'application/json' -Headers @{ + Authorization = "Bearer $Token" + } -SkipHttpErrorCheck -StatusCodeVariable StatusCode + + if ($StatusCode -ge 200 -and $StatusCode -lt 300) { + Write-LogMessage -API 'SAMCertificate' -message "Stored SAM certificate '$Name' as a Key Vault certificate in vault '$VaultName'" -sev 'Info' + Update-CIPPSAMCertificateEnvCache -Name $Name -PfxBase64 $PfxBase64 + return @{ StorageMode = 'Certificate'; Name = $Name; VaultName = $VaultName } + } + + if ($StatusCode -eq 403 -or $StatusCode -eq 409) { + # 403: access policy has no certificate permissions (pre-existing deployments). + # 409: the name is already owned by a plain secret from prior secret-mode operation. + # Either way, fall back to storing the base64 PFX as a regular secret. + Write-Information "Key Vault certificate import returned $StatusCode, falling back to secret storage for '$Name'" + try { + $null = Set-CippKeyVaultSecret -VaultName $VaultName -Name $Name -SecretValue (ConvertTo-SecureString -String $PfxBase64 -AsPlainText -Force) + } catch { + # A 409 on the secret PUT means the name is backed by a Key Vault certificate but we can + # no longer import one - certificate permissions were revoked after operating in + # certificate mode. Manual intervention required; the previous certificate remains valid. + Write-LogMessage -API 'SAMCertificate' -message "Failed to store SAM certificate '$Name' in vault '$VaultName'. If certificate permissions were removed from the Key Vault access policy after the certificate was stored via the certificates API, restore them (get, list, import, update, delete). See Log Data for details." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) + throw + } + Update-CIPPSAMCertificateEnvCache -Name $Name -PfxBase64 $PfxBase64 + return @{ StorageMode = 'Secret'; Name = $Name; VaultName = $VaultName } + } + + $ErrorDetail = if ($ImportResponse.error.message) { $ImportResponse.error.message } else { $ImportResponse | ConvertTo-Json -Compress -Depth 5 } + throw "Key Vault certificate import for '$Name' in vault '$VaultName' failed with status $StatusCode : $ErrorDetail" +} diff --git a/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 b/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 index 4af8e4f6b55a2..df35ab4fd187e 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 @@ -43,13 +43,18 @@ function Set-CIPPSPOSite { $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter $AdminUrl = $SharePointInfo.AdminUrl - $AllowedTypes = @('Boolean', 'String', 'Int32') + $AllowedTypes = @('Boolean', 'String', 'Int32', 'Int64') + # Properties that are CSOM enums; their (numeric) value must be sent as Type="Enum". + $EnumProperties = @('SharingCapability', 'DefaultSharingLinkType', 'DefaultLinkPermission', 'SharingDomainRestrictionMode', 'ConditionalAccessPolicy') $SetProperty = [System.Collections.Generic.List[string]]::new() $x = 106 foreach ($Property in $Properties.Keys) { $PropertyType = $Properties[$Property].GetType().Name - if ($PropertyType -in $AllowedTypes) { - $PropertyToSet = if ($PropertyType -eq 'Boolean') { $Properties[$Property].ToString().ToLower() } else { $Properties[$Property] } + if ($Property -in $EnumProperties) { + $SetProperty.Add("$([int]$Properties[$Property])") + $x++ + } elseif ($PropertyType -in $AllowedTypes) { + $PropertyToSet = if ($PropertyType -eq 'Boolean') { $Properties[$Property].ToString().ToLower() } else { [System.Security.SecurityElement]::Escape([string]$Properties[$Property]) } $SetProperty.Add("$PropertyToSet") $x++ } diff --git a/Modules/CIPPCore/Public/Set-CIPPSensitiveInfoType.ps1 b/Modules/CIPPCore/Public/Set-CIPPSensitiveInfoType.ps1 index 5a86bc1674567..6860bb48a8c15 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSensitiveInfoType.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSensitiveInfoType.ps1 @@ -4,8 +4,14 @@ function Set-CIPPSensitiveInfoType { Deploy or update a single custom Sensitive Information Type in a tenant from a template object. .DESCRIPTION Single source of truth for SIT deployment, shared by the HTTP deploy endpoint and the standard. - Supports simple mode (Pattern → backend synthesizes rule pack XML) and advanced mode (caller- - supplied FileDataBase64 rule pack). Microsoft built-in SITs are skipped. + Imports a custom SIT *rule package* (regex/keyword based, Type=Entity) via + New-/Set-DlpSensitiveInformationTypeRulePackage. Supports simple mode (Pattern -> backend + synthesizes the rule pack XML) and advanced mode (caller-supplied FileDataBase64 rule pack, + e.g. captured from an existing SIT). Microsoft built-in SITs are skipped. + + IMPORTANT: this uses the rule-*package* cmdlets, NOT New-/Set-DlpSensitiveInformationType - the + latter is a document-fingerprint primitive that stores -FileData as a fingerprint and discards + the regex. The rule pack XML must use the 2011 'mce' schema and be UTF-16 encoded. .FUNCTIONALITY Internal #> @@ -19,53 +25,63 @@ function Set-CIPPSensitiveInfoType { $Name = $Template.Name - # Build FileData byte array from either advanced (FileDataBase64) or simple (Pattern) mode - $FileDataBytes = $null + # Resolve the rule pack XML from advanced (FileDataBase64) or simple (Pattern) mode. + $XmlString = $null if ($Template.FileDataBase64) { try { - $FileDataBytes = [System.Convert]::FromBase64String($Template.FileDataBase64) + $RawBytes = [System.Convert]::FromBase64String($Template.FileDataBase64) } catch { - $msg = "SIT '$Name' has invalid FileDataBase64 ($($_.Exception.Message)) — skipping in $TenantFilter." + $msg = "SIT '$Name' has invalid FileDataBase64 ($($_.Exception.Message)) - skipping in $TenantFilter." Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $msg -sev Error return $msg } + # Captured/synthesized packs are UTF-16; fall back to UTF-8 if that doesn't look like the XML. + $XmlString = [System.Text.Encoding]::Unicode.GetString($RawBytes) + if ($XmlString -notmatch ' $true + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $false)] + [AllowNull()] + [AllowEmptyString()] + [string]$SiteName = $env:WEBSITE_SITE_NAME + ) + + return [bool](Get-CippOffloadSuffix -SiteName $SiteName) +} diff --git a/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 b/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 index 9ed724c78ed65..be6aeed06a6ad 100644 --- a/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 +++ b/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 @@ -14,6 +14,7 @@ function Get-CIPPSchedulerBlockedCommands { # Token & authentication functions - would exfiltrate access/refresh tokens 'Get-GraphToken' 'Get-GraphTokenFromCert' + 'New-CIPPCertificateAssertion' 'Get-ClassicAPIToken' 'Get-CIPPAzIdentityToken' 'Get-CIPPAuthentication' @@ -42,6 +43,10 @@ function Get-CIPPSchedulerBlockedCommands { 'Get-CippKeyVaultSecret' 'Set-CippKeyVaultSecret' 'Remove-CippKeyVaultSecret' + 'Get-CIPPSAMCertificate' + 'New-CIPPSAMCertificate' + 'Set-CIPPSAMCertificate' + 'Update-CIPPSAMCertificate' 'Get-ExtensionAPIKey' 'Set-ExtensionAPIKey' 'Remove-ExtensionAPIKey' diff --git a/Modules/CIPPCore/Public/Update-CIPPSAMCertificate.ps1 b/Modules/CIPPCore/Public/Update-CIPPSAMCertificate.ps1 new file mode 100644 index 0000000000000..8fb1cec650878 --- /dev/null +++ b/Modules/CIPPCore/Public/Update-CIPPSAMCertificate.ps1 @@ -0,0 +1,161 @@ +function Update-CIPPSAMCertificate { + <# + .SYNOPSIS + Creates or renews the SAM app certificate + + .DESCRIPTION + Checks the stored SAM certificate and renews it when missing (first-run bootstrap), + within the renewal threshold of expiry, or when the stored certificate is not registered + on the SAM app (drift from a previously failed run). Renewal generates a new self-signed + certificate, registers it on the app registration via a full keyCredentials PATCH + (addKey requires a proof-of-possession token signed by an existing key, which is + impossible at bootstrap), then stores the PFX via Set-CIPPSAMCertificate. Still-valid + existing key credentials are kept during rotation so in-flight assertions keep working; + expired ones are pruned in the same PATCH. If storage fails, the new credential is + rolled back off the app registration so renewal is retried on the next run. + + .PARAMETER RenewalThresholdDays + Renew when the stored certificate expires within this many days. Defaults to 30. + + .PARAMETER Force + Renew regardless of the stored certificate's expiry. + + .PARAMETER ApplicationId + The app registration (client) id to manage the certificate for. Defaults to the SAM app. + + .PARAMETER Headers + Optional pre-built authorization headers for the Graph calls (e.g. the delegated token + during the setup wizard, before the app's own credentials are usable). + + .EXAMPLE + Update-CIPPSAMCertificate + + .EXAMPLE + Update-CIPPSAMCertificate -Force + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $false)] + [int]$RenewalThresholdDays = 30, + + [switch]$Force, + + [Parameter(Mandatory = $false)] + [string]$ApplicationId, + + [Parameter(Mandatory = $false)] + $Headers + ) + + $AppId = if ($ApplicationId) { $ApplicationId } else { $env:ApplicationID } + $AppRegistration = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$AppId')?`$select=id,keyCredentials" -NoAuthCheck $true -AsApp $true -Headers $Headers -ErrorAction Stop + + $Stored = $null + try { + $Stored = Get-CIPPSAMCertificate -SkipCache -ErrorAction Stop + } catch { + Write-Warning "Could not retrieve stored SAM certificate: $($_.Exception.Message)" + } + + $RenewalReason = if ($Force) { + 'Forced renewal requested' + } elseif ($null -eq $Stored) { + 'No stored certificate found, creating initial certificate' + } elseif ($Stored.NotAfter -lt (Get-Date).AddDays($RenewalThresholdDays).ToUniversalTime()) { + "Stored certificate expires $($Stored.NotAfter), within the $RenewalThresholdDays day renewal threshold" + } else { + # Drift check: the stored certificate should be registered on the app. If it is not, + # a previous run failed between storage and Graph (or its rollback failed) - self-heal. + # Graph returns customKeyIdentifier as the hex thumbprint string, but tolerate the + # base64-encoded thumbprint bytes form as well. + $Identifiers = @($AppRegistration.keyCredentials.customKeyIdentifier) + if ($Identifiers -notcontains $Stored.Thumbprint -and $Identifiers -notcontains [Convert]::ToBase64String([Convert]::FromHexString($Stored.Thumbprint))) { + 'Stored certificate is not registered on the app registration, re-registering' + } else { + $null + } + } + + if (-not $RenewalReason) { + Write-Information "SAM certificate is valid until $($Stored.NotAfter) and registered on the app. No renewal needed." + return [PSCustomObject]@{ + Renewed = $false + Thumbprint = $Stored.Thumbprint + NotAfter = $Stored.NotAfter + } + } + + if (-not $PSCmdlet.ShouldProcess($AppId, "Renew SAM certificate: $RenewalReason")) { + return + } + + Write-Information "Renewing SAM certificate for $AppId. Reason: $RenewalReason" + + # Ensure the CIPP exemption policy covers key credential restrictions (asymmetricKeyLifetime / + # trustedCertificateAuthority would otherwise block registering a 1 year self-signed certificate) + try { + $AppPolicyStatus = Update-AppManagementPolicy -ApplicationId $AppId -Headers $Headers + if ($AppPolicyStatus.PolicyAction) { Write-Information $AppPolicyStatus.PolicyAction } + } catch { + Write-Warning "Error updating app management policy $($_.Exception.Message)." + } + + $NewCert = New-CIPPSAMCertificate + + # Keep still-valid credentials (rotation overlap) and prune expired ones in the same PATCH. + # Existing credentials are sent back with their keyId and null key material, which Graph + # preserves; only the appended entry carries new key material. + $Now = (Get-Date).ToUniversalTime() + $KeyCredentials = [System.Collections.Generic.List[object]]::new() + foreach ($Credential in $AppRegistration.keyCredentials) { + if ($Credential.endDateTime -ge $Now) { + $KeyCredentials.Add($Credential) + } else { + Write-Information "Pruning expired key credential $($Credential.keyId) (expired $($Credential.endDateTime))" + } + } + # Record which instance issued the certificate: machine name for local dev, site name in Azure + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $InstanceName = [System.Environment]::MachineName + } else { + $InstanceName = $env:WEBSITE_SITE_NAME + } + $KeyCredentials.Add(@{ + type = 'AsymmetricX509Cert' + usage = 'Verify' + key = $NewCert.PublicKeyBase64 + displayName = "CIPP-SAM Certificate ($InstanceName)" + }) + + $PatchBody = @{ keyCredentials = $KeyCredentials } | ConvertTo-Json -Compress -Depth 10 + New-GraphPOSTRequest -type PATCH -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)" -Body $PatchBody -NoAuthCheck $true -AsApp $true -Headers $Headers -ErrorAction Stop + Write-Information "Registered new SAM certificate $($NewCert.Thumbprint) on application $AppId" + + try { + $StoreResult = Set-CIPPSAMCertificate -PfxBase64 $NewCert.PfxBase64 -ErrorAction Stop + } catch { + # The new certificate is registered on the app but not retrievable where CIPP reads it, + # and as the newest credential it would suppress renewal on the next run. Roll it back + # off the app registration so state stays consistent and renewal is retried. + Write-LogMessage -API 'SAMCertificate' -message "Failed to store new SAM certificate for $AppId. Rolling back the registered key credential, see Log Data for details." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) + try { + $RollbackCredentials = $KeyCredentials | Where-Object { $_.key -ne $NewCert.PublicKeyBase64 } + $RollbackBody = @{ keyCredentials = @($RollbackCredentials) } | ConvertTo-Json -Compress -Depth 10 + New-GraphPOSTRequest -type PATCH -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)" -Body $RollbackBody -NoAuthCheck $true -AsApp $true -Headers $Headers -ErrorAction Stop + Write-Information "Rolled back unstored SAM certificate $($NewCert.Thumbprint) from application $AppId" + } catch { + # Rollback failed - the drift check on the next run will force a fresh renewal + Write-LogMessage -API 'SAMCertificate' -message "Failed to roll back unstored SAM certificate $($NewCert.Thumbprint) for $AppId, see Log Data for details. Renewal will retry on the next run." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) + } + throw + } + + Write-LogMessage -API 'SAMCertificate' -message "SAM certificate renewed for $AppId. Thumbprint: $($NewCert.Thumbprint), expires: $($NewCert.NotAfter), storage mode: $($StoreResult.StorageMode). Reason: $RenewalReason" -sev 'Info' + + return [PSCustomObject]@{ + Renewed = $true + Thumbprint = $NewCert.Thumbprint + NotAfter = $NewCert.NotAfter + StorageMode = $StoreResult.StorageMode + } +} diff --git a/Modules/CIPPCore/Public/Update-CIPPSAMCertificateEnvCache.ps1 b/Modules/CIPPCore/Public/Update-CIPPSAMCertificateEnvCache.ps1 new file mode 100644 index 0000000000000..60edc22bb2109 --- /dev/null +++ b/Modules/CIPPCore/Public/Update-CIPPSAMCertificateEnvCache.ps1 @@ -0,0 +1,30 @@ +function Update-CIPPSAMCertificateEnvCache { + <# + .SYNOPSIS + Refreshes the in-process SAM certificate caches after a store + + .DESCRIPTION + Updates the preloaded environment variable and invalidates the script-scope certificate + cache so the current runspace immediately serves the newly stored certificate. Other + runspaces pick it up when their 1 hour memory cache expires or on restart; the previous + certificate remains valid on the app registration during that window. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [string]$PfxBase64 + ) + + if ($Name -eq 'SAMCertificate') { + $env:SAMCertificate = $PfxBase64 + } + if ($script:SAMCertificateCache) { + $script:SAMCertificateCache.Remove($Name) + } +} diff --git a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 index 161463bcd458b..3943899658d9b 100644 --- a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 +++ b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 @@ -124,6 +124,38 @@ function Invoke-CippWebhookProcessing { $AuditLogLink = '{0}/tenant/administration/audit-logs/log?logId={1}&tenantFilter={2}' -f $CIPPURL, $LogId, $Tenant.defaultDomainName $GenerateEmail = New-CIPPAlertTemplate -format 'html' -data $Data -ActionResults $ActionResults -CIPPURL $CIPPURL -Tenant $Tenant.defaultDomainName -AuditLogLink $AuditLogLink -AlertComment $AlertComment -CustomSubject $Data.CIPPCustomSubject + # Derive the affected end-user from the audit record so PSA tickets can be linked to the + # right HaloPSA contact when HaloPSA.LinkTicketsToUsers is enabled. The upstream GUID mapper + # has already attached CIPP-prefixed properties (e.g. CIPPObjectId) holding the resolved UPN + # for any property that contained a user's Object ID; the raw property usually still holds + # the original GUID, which we can use directly as the AzureOID for the Halo lookup. + $AffectedUser = $null + $GuidRegex = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + $UserCandidates = @( + @{ Raw = 'ObjectId'; Mapped = 'CIPPObjectId' } + @{ Raw = 'UserId'; Mapped = 'CIPPUserId' } + @{ Raw = 'Userkey'; Mapped = 'CIPPUserkey' } + ) + foreach ($Candidate in $UserCandidates) { + $RawValue = $Data.$($Candidate.Raw) + $MappedValue = $Data.$($Candidate.Mapped) + if (-not $RawValue -and -not $MappedValue) { continue } + + $UPN = $null; $OID = $null + if ($MappedValue -is [string] -and $MappedValue -match '@') { $UPN = $MappedValue } + elseif ($RawValue -is [string] -and $RawValue -match '@') { $UPN = $RawValue } + + if ($RawValue -is [string] -and $RawValue -match $GuidRegex) { $OID = $RawValue } + + if ($UPN -or $OID) { + $AffectedUser = [pscustomobject]@{ + UPN = $UPN + AzureOID = $OID + } + break + } + } + Write-Host 'Going to create the content' foreach ($action in $ActionList ) { switch ($action) { @@ -145,6 +177,9 @@ function Invoke-CippWebhookProcessing { HTMLContent = $GenerateEmail.htmlcontent TenantFilter = $TenantFilter } + if ($AffectedUser) { + $CIPPAlert.AffectedUser = $AffectedUser + } Send-CIPPAlert @CIPPAlert } 'generateWebhook' { diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 new file mode 100644 index 0000000000000..8a729938833ae --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 @@ -0,0 +1,125 @@ +function Push-AuditLogDownloadV2 { + <# + .SYNOPSIS + Per-tenant audit-log download activity (V2). Polls created searches, downloads succeeded ones + to CacheWebhooks, and advances the AuditLogCoverage ledger. + .DESCRIPTION + For the tenant's ledger rows in state 'Created' (and due): + * bulk-poll Graph search status + * succeeded -> download records to CacheWebhooks, mark Downloaded (+ RecordCount) + * failed -> re-plan a fresh search (State = Planned, clear SearchId); dead-letter at cap + * running/notStarted -> leave Created; if stuck > 4h, re-plan + * download error -> increment Attempts + backoff; dead-letter at cap (NOT terminal) + Returns a summary the PostExecution (AuditLogProcessV2) uses to decide whether to process. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.TenantFilter + $MaxAttempts = 6 + $StuckHours = 4 + $Downloaded = 0 + + try { + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $Now = (Get-Date).ToUniversalTime() + $Rows = @(Get-CIPPAzDataTableEntity @Ledger -Filter "PartitionKey eq '$TenantFilter' and State eq 'Created'") + $Rows = $Rows | Where-Object { -not $_.NextAttemptUtc -or ([datetimeoffset]$_.NextAttemptUtc).UtcDateTime -le $Now } + $Rows = @($Rows) + if ($Rows.Count -eq 0) { + return @{ TenantFilter = $TenantFilter; Success = $true; Downloaded = 0 } + } + + # Bulk-poll Graph search status for this tenant's created searches + $Requests = foreach ($Row in $Rows) { + if ($Row.SearchId) { @{ id = [string]$Row.SearchId; url = "security/auditLog/queries/$($Row.SearchId)"; method = 'GET' } } + } + $Requests = @($Requests) + $StatusById = @{} + if ($Requests.Count -gt 0) { + $Responses = New-GraphBulkRequest -Requests $Requests -AsApp $true -TenantId $TenantFilter + foreach ($Response in $Responses) { + if ($Response.body -and $Response.body.id) { $StatusById[[string]$Response.body.id] = [string]$Response.body.status } + } + } + + $CacheTable = Get-CippTable -TableName 'CacheWebhooks' + + foreach ($Row in $Rows) { + $SearchId = [string]$Row.SearchId + $Status = $StatusById[$SearchId] + $CreatedAgeHours = if ($Row.CreatedUtc) { ($Now - ([datetimeoffset]$Row.CreatedUtc).UtcDateTime).TotalHours } else { 999 } + + if ($Status -eq 'succeeded') { + try { + $Results = @(Get-CippAuditLogSearchResults -TenantFilter $TenantFilter -QueryId $SearchId) + foreach ($SearchResult in $Results) { + Add-CIPPAzDataTableEntity @CacheTable -Entity @{ + RowKey = [string]$SearchResult.id + PartitionKey = [string]$TenantFilter + SearchId = $SearchId + JSON = [string]($SearchResult | ConvertTo-Json -Depth 10 -Compress) + CippProcessing = $false + CippProcessingStarted = '' + } -Force + } + $Downloaded += $Results.Count + # Empty windows have nothing to process - mark them Processed directly so they + # don't sit at Downloaded forever. Windows with records go to Downloaded and are + # advanced to Processed by Push-AuditLogTenantProcessV2 once their rows are drained. + $DownloadState = if ($Results.Count -eq 0) { 'Processed' } else { 'Downloaded' } + $LedgerUpdate = @{ + PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = $DownloadState + RecordCount = [int]$Results.Count; DownloadedUtc = $Now; Attempts = 0 + SearchStatus = 'succeeded'; LastPolledUtc = $Now + } + if ($DownloadState -eq 'Processed') { + $LedgerUpdate.ProcessedUtc = $Now + $LedgerUpdate.MatchedCount = 0 + } + Add-CIPPAzDataTableEntity @Ledger -Entity $LedgerUpdate -OperationType UpsertMerge + Write-Information "AuditLogV2: downloaded $($Results.Count) record(s) for $TenantFilter window $($Row.RowKey)" + } catch { + $Attempts = [int]$Row.Attempts + 1 + $RetryTotal = [int]$Row.RetryCount + 1 + if ($Attempts -ge $MaxAttempts) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'DeadLetter'; Attempts = $Attempts; RetryCount = $RetryTotal; LastError = [string]$_.Exception.Message; LastErrorUtc = $Now } -OperationType UpsertMerge + } else { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; Attempts = $Attempts; RetryCount = $RetryTotal; NextAttemptUtc = (Get-CippAuditLogNextAttempt -Attempts $Attempts); LastError = [string]$_.Exception.Message; LastErrorUtc = $Now } -OperationType UpsertMerge + } + Write-Information "AuditLogV2: download error for $TenantFilter window $($Row.RowKey): $($_.Exception.Message)" + } + } elseif ($Status -eq 'failed') { + $Attempts = [int]$Row.Attempts + 1 + $RetryTotal = [int]$Row.RetryCount + 1 + if ($Attempts -ge $MaxAttempts) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'DeadLetter'; Attempts = $Attempts; RetryCount = $RetryTotal; SearchStatus = 'failed'; LastPolledUtc = $Now; LastError = 'Graph search failed'; LastErrorUtc = $Now } -OperationType UpsertMerge + } else { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned'; SearchId = ''; Attempts = $Attempts; RetryCount = $RetryTotal; SearchStatus = ''; LastPolledUtc = $Now; NextAttemptUtc = (Get-CippAuditLogNextAttempt -Attempts $Attempts); LastError = 'Graph search failed; re-planning'; LastErrorUtc = $Now } -OperationType UpsertMerge + } + } elseif ($Status -in @('running', 'notStarted')) { + if ($CreatedAgeHours -ge $StuckHours) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned'; SearchId = ''; SearchStatus = ''; LastPolledUtc = $Now; RetryCount = ([int]$Row.RetryCount + 1); LastError = 'Search stuck; re-planning'; LastErrorUtc = $Now } -OperationType UpsertMerge + } else { + # Not ready yet: leave Created, but persist the live Graph search status so a pending + # window shows WHY it is still in-flight (e.g. 'running') rather than looking stuck. + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; SearchStatus = [string]$Status; LastPolledUtc = $Now } -OperationType UpsertMerge + } + } else { + # Unknown / search no longer present on Graph + if ($CreatedAgeHours -ge $StuckHours) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned'; SearchId = ''; SearchStatus = ''; LastPolledUtc = $Now; RetryCount = ([int]$Row.RetryCount + 1); LastError = 'Search not found; re-planning'; LastErrorUtc = $Now } -OperationType UpsertMerge + } else { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; SearchStatus = $(if ($Status) { [string]$Status } else { 'unknown' }); LastPolledUtc = $Now } -OperationType UpsertMerge + } + } + } + + return @{ TenantFilter = $TenantFilter; Success = $true; Downloaded = $Downloaded } + } catch { + Write-Information ('Push-AuditLogDownloadV2 error for {0}: {1}' -f $TenantFilter, $_.Exception.Message) + return @{ TenantFilter = $TenantFilter; Success = $false; Downloaded = $Downloaded; Error = $_.Exception.Message } + } +} diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 new file mode 100644 index 0000000000000..7c52ccf09c766 --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 @@ -0,0 +1,53 @@ +function Push-AuditLogProcessV2 { + <# + .SYNOPSIS + PostExecution step of the V2 ingestion orchestrator. If the per-tenant download succeeded, + enqueues a per-tenant processing orchestrator (post-exec style). + .DESCRIPTION + Receives the download orchestrator's aggregated results ($Item.Results) and the tenant filter + ($Item.Parameters.TenantFilter). When at least one record was downloaded, it starts a + per-tenant processing orchestrator whose QueueFunction (AuditLogProcessingBatchV2) pages that + tenant's CacheWebhooks rows into batches handled by the existing AuditLogTenantProcess engine. + If nothing was downloaded, processing is skipped. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + try { + $TenantFilter = $Item.Parameters.TenantFilter + if (-not $TenantFilter) { + $TenantFilter = (@($Item.Results) | Where-Object { $_.TenantFilter } | Select-Object -First 1).TenantFilter + } + if (-not $TenantFilter) { + Write-Information 'AuditLogProcessV2: no tenant filter resolved; skipping' + return @{ Success = $false } + } + + # Fire processing whenever the tenant has rows pending in the cache - records just downloaded + # this cycle OR rows left behind by an earlier crash. Not gated on the download count, so a + # crashed/partial processing run is retried on the next cycle. The batch builder is the + # authoritative gate (claims claimable rows; returns nothing if there's truly no work). + $CacheTable = Get-CippTable -TableName 'CacheWebhooks' + $Pending = @(Get-CIPPAzDataTableEntity @CacheTable -Filter "PartitionKey eq '$TenantFilter'" -Property @('PartitionKey', 'RowKey')) + if ($Pending.Count -eq 0) { + Write-Information "AuditLogProcessV2: no pending cache rows for $TenantFilter; nothing to process" + return @{ Success = $true; Processed = $false } + } + + Write-Information "AuditLogProcessV2: enqueueing processing for $TenantFilter ($($Pending.Count) pending cache row(s))" + $InputObject = [PSCustomObject]@{ + OrchestratorName = "AuditLogProcessV2-$TenantFilter" + QueueFunction = [PSCustomObject]@{ + FunctionName = 'AuditLogProcessingBatchV2' + Parameters = @{ TenantFilter = $TenantFilter } + } + SkipLog = $true + } + $InstanceId = Start-CIPPOrchestrator -InputObject $InputObject + return @{ Success = $true; Processed = $true; InstanceId = $InstanceId } + } catch { + Write-Information ('Push-AuditLogProcessV2 error: {0}' -f $_.Exception.Message) + return @{ Success = $false; Error = $_.Exception.Message } + } +} diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 new file mode 100644 index 0000000000000..fad2047ad5c9d --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 @@ -0,0 +1,66 @@ +function Push-AuditLogProcessingBatchV2 { + <# + .SYNOPSIS + QueueFunction for the per-tenant V2 processing orchestrator. Builds processing batches from a + single tenant's CacheWebhooks rows. + .DESCRIPTION + Tenant-scoped variant of Push-AuditLogProcessingBatch. Pages the CacheWebhooks rows for the + tenant supplied via the QueueFunction Parameters, claims unclaimed (or stale > 2h) rows by + stamping CippProcessing = true, and returns 500-row batch items routed to the + AuditLogTenantProcessV2 activity (which runs Test-CIPPAuditLogRules and advances the ledger). + Scoping to one tenant avoids cross-tenant scans and claim races when many tenants process + concurrently. The 2h stale window lets a crashed processing run be re-claimed and retried. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.Parameters.TenantFilter ?? $Item.TenantFilter + if (-not $TenantFilter) { + Write-Information 'AuditLogProcessingBatchV2: no tenant filter; nothing to process' + return @() + } + + $WebhookCacheTable = Get-CippTable -TableName 'CacheWebhooks' + $AllBatchItems = [System.Collections.Generic.List[object]]::new() + $NowUtc = (Get-Date).ToUniversalTime() + $StaleThreshold = $NowUtc.AddHours(-2) + + $Rows = @(Get-CIPPAzDataTableEntity @WebhookCacheTable -Filter "PartitionKey eq '$TenantFilter'" -Property @('PartitionKey', 'RowKey', 'ETag', 'Timestamp', 'CippProcessing')) + $Claimable = @($Rows | Where-Object { + -not $_.CippProcessing -or ($_.Timestamp -and $_.Timestamp.UtcDateTime -lt $StaleThreshold) + }) + if ($Claimable.Count -eq 0) { + Write-Information "AuditLogProcessingBatchV2: no claimable rows for $TenantFilter" + return @() + } + + $RowIds = @($Claimable.RowKey) + foreach ($Row in $Claimable) { + Add-CIPPAzDataTableEntity @WebhookCacheTable -Entity ([PSCustomObject]@{ + PartitionKey = $TenantFilter + RowKey = $Row.RowKey + CippProcessing = $true + }) -OperationType UpsertMerge + } + + for ($i = 0; $i -lt $RowIds.Count; $i += 500) { + $BatchRowIds = $RowIds[$i..([Math]::Min($i + 499, $RowIds.Count - 1))] + $AllBatchItems.Add([PSCustomObject]@{ + TenantFilter = $TenantFilter + RowIds = $BatchRowIds + FunctionName = 'AuditLogTenantProcessV2' + }) + } + + if ($AllBatchItems.Count -gt 0) { + $ProcessQueue = New-CippQueueEntry -Name "Audit Logs Process V2 - $TenantFilter" -Reference 'AuditLogsProcessV2' -TotalTasks $RowIds.Count + foreach ($BatchItem in $AllBatchItems) { + $BatchItem | Add-Member -MemberType NoteProperty -Name QueueId -Value $ProcessQueue.RowKey -Force + } + Write-Information "AuditLogProcessingBatchV2: $($AllBatchItems.Count) batch item(s) across $($RowIds.Count) row(s) for $TenantFilter" + } + + return $AllBatchItems.ToArray() +} diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1 new file mode 100644 index 0000000000000..70085030edaaf --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1 @@ -0,0 +1,141 @@ +function Push-AuditLogSearchCreationV2 { + <# + .SYNOPSIS + Per-tenant audit-log search creation activity (V2). Seeds owed regular (35-min) and 12-hour + reconciliation windows into the AuditLogCoverage ledger, then creates Graph searches for the + due ones - oldest first, capped per cycle, with manual throttle handling. + .DESCRIPTION + 1. Seed owed regular windows (Get-CippAuditLogPlannedWindows) and reconciliation windows + (Get-CippAuditLogReconciliationWindows) as Planned ledger rows. + 2. Take the oldest <= MaxPerCycle (6) due Planned windows (regular + reconciliation combined) + and create a Graph search for each, with auto-retry DISABLED (New-CippAuditLogSearchV2 -> + maxRetries 1). + 3. Throttling: the createSearch 429 is a per-tenant cap of ~10 concurrent searches, not a rate + limit, so on a 429 we stop and defer the current window AND all remaining queued windows to + the next cycle (no Attempts increment - a cap is not a failure, so it never dead-letters). + Other transient errors (UnknownError, 5xx, gateway) retry the individual window with backoff + and dead-letter at MaxAttempts. AuditingDisabled caches the tenant for 24h and stops. + + Capping at 6/cycle keeps us well under the ~10 concurrent-search ceiling (they complete within + the cycle and free slots). Oldest-first means gaps/backlog/reconciliation drain before the + freshest window; in steady state only the one new window is due, so latency is unaffected. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.TenantFilter + $TenantId = $Item.TenantId + $MaxPerCycle = 6 + $MaxAttempts = 8 + + try { + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $Rows = @(Get-CIPPAzDataTableEntity @Ledger -Filter "PartitionKey eq '$TenantFilter'") + $Now = (Get-Date).ToUniversalTime() + + # 1) Seed owed regular + reconciliation windows as Planned. + foreach ($Window in (Get-CippAuditLogPlannedWindows -ExistingRows $Rows -Now $Now)) { + $Entity = @{ + PartitionKey = [string]$TenantFilter; RowKey = [string]$Window.RowKey; TenantId = [string]$TenantId + WindowStart = [datetime]$Window.WindowStart; WindowEnd = [datetime]$Window.WindowEnd + State = 'Planned'; Type = 'Window'; Attempts = 0; RetryCount = 0; ThrottleCount = 0; SearchId = ''; LastError = '' + } + Add-CIPPAzDataTableEntity @Ledger -Entity $Entity -Force + $Rows += [pscustomobject]$Entity + } + foreach ($Window in (Get-CippAuditLogReconciliationWindows -ExistingRows $Rows -Now $Now)) { + $Entity = @{ + PartitionKey = [string]$TenantFilter; RowKey = [string]$Window.RowKey; TenantId = [string]$TenantId + WindowStart = [datetime]$Window.WindowStart; WindowEnd = [datetime]$Window.WindowEnd + State = 'Planned'; Type = 'Reconciliation'; Attempts = 0; RetryCount = 0; ThrottleCount = 0; SearchId = ''; LastError = '' + } + Add-CIPPAzDataTableEntity @Ledger -Entity $Entity -Force + $Rows += [pscustomobject]$Entity + } + + # 2) Build the create batch: the freshest regular window FIRST (so live events alert + # fast even during a backlog), then the oldest of whatever remains - regular + + # reconciliation - to drain gaps before they age out. Reconciliation windows are + # never "current"; they flow through the oldest-first backfill slots unchanged. + $Due = @($Rows | Where-Object { + $_.State -eq 'Planned' -and (-not $_.NextAttemptUtc -or ([datetimeoffset]$_.NextAttemptUtc).UtcDateTime -le $Now) + } | Sort-Object @{ Expression = { ([datetimeoffset]$_.WindowStart).UtcDateTime } }) + if ($Due.Count -eq 0) { + Write-Information "AuditLogV2: no due windows for $TenantFilter" + return $true + } + # Newest regular (14-digit RowKey) window = the live period; reconciliation (RECON-*) is never current. + $CurrentWindow = @($Due | Where-Object { [string]$_.RowKey -match '^\d{14}$' } | Select-Object -Last 1) + if ($CurrentWindow.Count -gt 0) { + $CurrentKey = [string]$CurrentWindow[0].RowKey + $Backfill = @($Due | Where-Object { [string]$_.RowKey -ne $CurrentKey } | Select-Object -First ($MaxPerCycle - 1)) + $Batch = @($CurrentWindow[0]) + $Backfill + } else { + # Only reconciliation windows are due: oldest-first, capped. + $Batch = @($Due | Select-Object -First $MaxPerCycle) + } + + # 3) Create searches (no auto-retry). On 429, defer current + remaining to next cycle. + $Bail = $false + foreach ($Row in $Batch) { + if ($Bail) { + # Deferred (not attempted): just set NextAttemptUtc, leave Attempts/State as Planned. + Add-CIPPAzDataTableEntity @Ledger -Entity @{ + PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned' + NextAttemptUtc = (Get-CippAuditLogNextAttempt -Attempts 1) + ThrottleCount = ([int]$Row.ThrottleCount + 1); LastError = 'Deferred: tenant search cap (429)'; LastErrorUtc = $Now + } -OperationType UpsertMerge + continue + } + + $Start = ([datetimeoffset]$Row.WindowStart).UtcDateTime + $End = ([datetimeoffset]$Row.WindowEnd).UtcDateTime + $Result = New-CippAuditLogSearchV2 -TenantFilter $TenantFilter -StartTime $Start -EndTime $End + $Attempts = [int]$Row.Attempts + 1 + + if ($Result.Outcome -eq 'Created' -and $Result.Id) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ + PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Created' + SearchId = [string]$Result.Id; Attempts = 0; CreatedUtc = $Now + SearchStatus = [string]$Result.Status; LastPolledUtc = $Now + } -OperationType UpsertMerge + Write-Information "AuditLogV2: created search for $TenantFilter window $($Row.RowKey)" + } elseif ($Result.Throttled) { + # 429 = tenant cap full. Defer this window (no Attempts bump - a cap isn't a failure) and bail. + $Bail = $true + Add-CIPPAzDataTableEntity @Ledger -Entity @{ + PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned' + NextAttemptUtc = (Get-CippAuditLogNextAttempt -Attempts 1) + ThrottleCount = ([int]$Row.ThrottleCount + 1); LastError = 'Tenant search cap (429)'; LastErrorUtc = $Now + } -OperationType UpsertMerge + Write-Information "AuditLogV2: 429 for $TenantFilter - deferring this + remaining windows to next cycle" + } elseif ($Result.Outcome -eq 'AuditingDisabled') { + $Bail = $true + try { + $AuditDisabledTable = Get-CIPPTable -TableName 'AuditLogDisabledTenants' + Add-CIPPAzDataTableEntity @AuditDisabledTable -Entity @{ + PartitionKey = 'AuditDisabledTenant'; RowKey = [string]$TenantFilter; TenantFilter = [string]$TenantFilter + Status = 'AuditingDisabledTenant'; ExpiresAtUnix = [int64]([datetimeoffset]::UtcNow.AddHours(24).ToUnixTimeSeconds()) + } -Force + } catch {} + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Skipped'; LastError = 'AuditingDisabledTenant'; LastErrorUtc = $Now } -OperationType UpsertMerge + Write-Information "AuditLogV2: auditing disabled for $TenantFilter; skipping" + } else { + # Other transient: retry this window next cycle; dead-letter at cap. + $RetryTotal = [int]$Row.RetryCount + 1 + if ($Attempts -ge $MaxAttempts) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'DeadLetter'; Attempts = $Attempts; RetryCount = $RetryTotal; LastError = [string]$Result.Message; LastErrorUtc = $Now } -OperationType UpsertMerge + } else { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned'; Attempts = $Attempts; RetryCount = $RetryTotal; NextAttemptUtc = (Get-CippAuditLogNextAttempt -Attempts $Attempts); LastError = [string]$Result.Message; LastErrorUtc = $Now } -OperationType UpsertMerge + } + } + } + return $true + } catch { + Write-Information ('Push-AuditLogSearchCreationV2 error for {0}: {1}' -f $TenantFilter, $_.Exception.Message) + Write-Information $_.InvocationInfo.PositionMessage + return $false + } +} diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 new file mode 100644 index 0000000000000..e04ec02ca7c5e --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 @@ -0,0 +1,104 @@ +function Push-AuditLogTenantProcessV2 { + <# + .SYNOPSIS + Per-batch audit-log processing activity (V2). Processes a batch of cached rows via the + existing Test-CIPPAuditLogRules engine, then advances the AuditLogCoverage ledger to + 'Processed' for any SearchId whose rows are now fully drained from the cache. + .DESCRIPTION + Same processing as the V1 Push-AuditLogTenantProcess (reads the RowIds from CacheWebhooks + and runs Test-CIPPAuditLogRules, which removes processed rows). Additionally: + * captures the distinct SearchIds represented by this batch's rows + * after processing, for each of those SearchIds with zero remaining CacheWebhooks rows, + marks the matching ledger window State = 'Processed' (ProcessedUtc + MatchedCount) + Because the mark is gated on "no rows left for this SearchId", a search split across + multiple 500-row batches is only marked Processed when its final batch completes. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.TenantFilter + $RowIds = $Item.RowIds + + try { + $CacheWebhooksTable = Get-CippTable -TableName 'CacheWebhooks' + $SearchIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + $Rows = foreach ($RowId in $RowIds) { + $CacheEntity = Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$RowId'" + if ($CacheEntity) { + if ($CacheEntity.SearchId) { [void]$SearchIds.Add([string]$CacheEntity.SearchId) } + $CacheEntity.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + } + } + + if ($Rows.Count -eq 0) { + Write-Information "AuditLogV2: no rows found in cache for the provided row IDs ($TenantFilter)" + return $false + } + + Write-Information "AuditLogV2: processing $($Rows.Count) row(s) for $TenantFilter" + $Result = Test-CIPPAuditLogRules -TenantFilter $TenantFilter -Rows $Rows + $MatchedLogs = [int]($Result.MatchedLogs ?? 0) + + # Advance the ledger to Processed for any SearchId now fully drained from the cache. + if ($SearchIds.Count -gt 0) { + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $SingleSearch = ($SearchIds.Count -eq 1) + $Now = (Get-Date).ToUniversalTime() + foreach ($SearchId in $SearchIds) { + $Remaining = @(Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and SearchId eq '$SearchId'" -Property PartitionKey, RowKey) + if ($Remaining.Count -gt 0) { continue } + + $LedgerRows = @(Get-CIPPAzDataTableEntity @Ledger -Filter "PartitionKey eq '$TenantFilter' and SearchId eq '$SearchId'") + foreach ($LedgerRow in $LedgerRows) { + $Update = @{ + PartitionKey = $TenantFilter + RowKey = $LedgerRow.RowKey + State = 'Processed' + ProcessedUtc = $Now + } + # Only attribute matched count when this batch was a single search (unambiguous). + if ($SingleSearch) { $Update.MatchedCount = $MatchedLogs } + Add-CIPPAzDataTableEntity @Ledger -Entity $Update -OperationType UpsertMerge + Write-Information "AuditLogV2: marked window $($LedgerRow.RowKey) Processed for $TenantFilter (search $SearchId)" + } + } + } + + # Sweep orphaned Downloaded windows. Once this batch's rows are processed, re-scan every + # window left at 'Downloaded' for the tenant and cross-check it against the cache by SearchId. + # If no CacheWebhooks rows remain for that search, the records were already processed - often + # under an OVERLAPPING window's search, because CacheWebhooks is keyed by record id, so a 5-min + # window overlap (or a legacy 60-min window sharing record ids) overwrites the SearchId and the + # per-batch marking above never sees this window's id. Mark it Processed. Windows whose search + # still has cache rows are left as-is; they get picked up on the next process round. + try { + $SweepLedger = Get-CippTable -TableName 'AuditLogCoverage' + $SweepNow = (Get-Date).ToUniversalTime() + $DownloadedRows = @(Get-CIPPAzDataTableEntity @SweepLedger -Filter "PartitionKey eq '$TenantFilter' and State eq 'Downloaded'") + foreach ($DownRow in $DownloadedRows) { + $Sid = [string]$DownRow.SearchId + if (-not $Sid) { continue } + $Remaining = @(Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and SearchId eq '$Sid'" -Property PartitionKey, RowKey) + if ($Remaining.Count -gt 0) { continue } + Add-CIPPAzDataTableEntity @SweepLedger -Entity @{ + PartitionKey = $TenantFilter + RowKey = $DownRow.RowKey + State = 'Processed' + ProcessedUtc = $SweepNow + MatchedCount = 0 + } -OperationType UpsertMerge + Write-Information "AuditLogV2: swept window $($DownRow.RowKey) to Processed for $TenantFilter (search $Sid drained, no cache rows)" + } + } catch { + Write-Information ('Push-AuditLogTenantProcessV2 sweep error for {0}: {1}' -f $TenantFilter, $_.Exception.Message) + } + + return $true + } catch { + Write-Information ('Push-AuditLogTenantProcessV2: Error {0} line {1} - {2}' -f $_.InvocationInfo.ScriptName, $_.InvocationInfo.ScriptLineNumber, $_.Exception.Message) + return $false + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheApps.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheApps.ps1 index 56b65c6c07d1c..ac1b1d7aeceb7 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheApps.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheApps.ps1 @@ -19,10 +19,8 @@ function Set-CIPPDBCacheApps { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching applications' -sev Debug - $Apps = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/applications?$top=999&expand=owners' -tenantid $TenantFilter - if (!$Apps) { $Apps = @() } - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Apps' -Data $Apps -AddCount - $Apps = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/applications?$top=999&expand=owners' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Apps' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached applications successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheBitlockerKeys.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheBitlockerKeys.ps1 index 71b70fd9a2508..55cf9e78815af 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheBitlockerKeys.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheBitlockerKeys.ps1 @@ -19,10 +19,8 @@ function Set-CIPPDBCacheBitlockerKeys { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching BitLocker recovery keys' -sev Debug - $BitlockerKeys = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys' -tenantid $TenantFilter - if (!$BitlockerKeys) { $BitlockerKeys = @() } - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'BitlockerKeys' -Data $BitlockerKeys -AddCount - $BitlockerKeys = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'BitlockerKeys' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached BitLocker recovery keys successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 index 96afe6b7eab90..bb2297345f6eb 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 @@ -24,7 +24,7 @@ function Set-CIPPDBCacheCsExternalAccessPolicy { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams External Access Policy' -sev Debug - $ExternalAccess = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsExternalAccessPolicy' -CmdParams @{ Identity = 'Global' } + $ExternalAccess = New-TeamsRequestV2 -TenantFilter $TenantFilter -Type 'ExternalAccessPolicy' -Action Get -Identity 'Global' if ($ExternalAccess) { $Data = @($ExternalAccess) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 index 66c2a171aecad..470a0fd3e3f7c 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 @@ -24,7 +24,7 @@ function Set-CIPPDBCacheCsTeamsAppPermissionPolicy { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams App Permission Policies' -sev Debug - $AppPermissionPolicies = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsTeamsAppPermissionPolicy' + $AppPermissionPolicies = New-TeamsRequestV2 -TenantFilter $TenantFilter -Type 'TeamsAppPermissionPolicy' -Action Get -ListAll if ($AppPermissionPolicies) { $Data = @($AppPermissionPolicies) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 index 1db2ec626fe98..64233843eae51 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 @@ -25,7 +25,7 @@ function Set-CIPPDBCacheCsTeamsClientConfiguration { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams Client Configuration' -sev Debug - $ClientConfig = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsTeamsClientConfiguration' -CmdParams @{ Identity = 'Global' } + $ClientConfig = New-TeamsRequestV2 -TenantFilter $TenantFilter -Type 'TeamsClientConfiguration' -Action Get -Identity 'Global' if ($ClientConfig) { $Data = @($ClientConfig) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 index 5a67acdb1ddb0..e7e166ae7cb17 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 @@ -24,7 +24,7 @@ function Set-CIPPDBCacheCsTeamsMeetingPolicy { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams Meeting Policy' -sev Debug - $MeetingPolicy = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsTeamsMeetingPolicy' -CmdParams @{ Identity = 'Global' } + $MeetingPolicy = New-TeamsRequestV2 -TenantFilter $TenantFilter -Type 'TeamsMeetingPolicy' -Action Get -Identity 'Global' if ($MeetingPolicy) { $Data = @($MeetingPolicy) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingConfiguration.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingConfiguration.ps1 new file mode 100644 index 0000000000000..bff91cce2f2f6 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingConfiguration.ps1 @@ -0,0 +1,40 @@ +function Set-CIPPDBCacheCsTeamsMessagingConfiguration { + <# + .SYNOPSIS + Caches the Teams Messaging Configuration (Global) + + .DESCRIPTION + Calls the Teams ConfigAPI (TeamsMessagingConfiguration) via New-TeamsRequestV2 and + writes the result into the CippReportingDB under Type 'CsTeamsMessagingConfiguration'. + Holds the org-wide message-safety settings (FileTypeCheck, UrlReputationCheck, + ContentBasedPhishingCheck, ReportIncorrectSecurityDetections, etc.). + + .PARAMETER TenantFilter + The tenant to cache the messaging configuration for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams Messaging Configuration' -sev Debug + + $MessagingConfig = New-TeamsRequestV2 -TenantFilter $TenantFilter -Type 'TeamsMessagingConfiguration' -Action Get -Identity 'Global' + + if ($MessagingConfig) { + $Data = @($MessagingConfig) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsMessagingConfiguration' -Data $Data -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Teams Messaging Configuration' -sev Debug + } + $MessagingConfig = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Teams Messaging Configuration: $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 index 2fc1ef784c239..2fb1b95196d65 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 @@ -25,7 +25,7 @@ function Set-CIPPDBCacheCsTeamsMessagingPolicy { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams Messaging Policy' -sev Debug - $MessagingPolicy = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsTeamsMessagingPolicy' -CmdParams @{ Identity = 'Global' } + $MessagingPolicy = New-TeamsRequestV2 -TenantFilter $TenantFilter -Type 'TeamsMessagingPolicy' -Action Get -Identity 'Global' if ($MessagingPolicy) { $Data = @($MessagingPolicy) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 index 47d09881239e5..88cd4788f1000 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 @@ -25,7 +25,7 @@ function Set-CIPPDBCacheCsTenantFederationConfiguration { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams Tenant Federation Configuration' -sev Debug - $Federation = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsTenantFederationConfiguration' -CmdParams @{ Identity = 'Global' } + $Federation = New-TeamsRequestV2 -TenantFilter $TenantFilter -Type 'TenantFederationConfiguration' -Action Get -Identity 'Global' if ($Federation) { $Data = @($Federation) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 new file mode 100644 index 0000000000000..f6cf4850f65fb --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 @@ -0,0 +1,135 @@ +function Set-CIPPDBCacheDefenderCVEs { + <# + .SYNOPSIS + Caches all vulnerabilities devices for a tenant + + .PARAMETER TenantFilter + The tenant to cache vulnerabilities for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $AllVulns = Get-DefenderTvmRaw -TenantId $TenantFilter -MaxPages 0 + + if (-not $AllVulns) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No vulnerability data returned from Defender TVM" -sev 'Warning' + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Retrieved $($AllVulns.Count) CVE records from Defender TVM" -sev 'Debug' + try{ + # Initialize a tracker for this tenant session + $CveAggregator = @{} + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Aggregator Failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + try{ + # Group the raw TVM records into unified CVE buckets + foreach ($Vuln in $AllVulns) { + $CveId = $Vuln.cveId + try{ + if (-not $CveAggregator.ContainsKey($CveId)) { + # Establish global CVE & software properties for this specific tenant + $CveAggregator[$CveId] = @{ + cveId = $CveId + customerId = $TenantFilter + softwareVendor = $Vuln.softwareVendor ?? '' + softwareName = $Vuln.softwareName ?? '' + vulnerabilitySeverityLevel = $Vuln.vulnerabilitySeverityLevel ?? '' + recommendedSecurityUpdate = $Vuln.recommendedSecurityUpdate ?? '' + recommendedSecurityUpdateUrl = $Vuln.recommendedSecurityUpdateUrl ?? '' + exploitabilityLevel = $Vuln.exploitabilityLevel ?? '' + + # Arrays to collect device metadata efficiently + AffectedDevices = [System.Collections.Generic.List[object]]::new() + } + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to establish global: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + try{ + # Extract properties specific to this device instance + $DevicePayload = @{ + deviceId = ($Vuln.deviceId -join ',') ?? '' + deviceName = ($Vuln.deviceName -join ',') ?? '' + osVersion = $Vuln.osVersion ?? '' + softwareVersion = ($Vuln.softwareVersion -join ',') ?? '' + diskPaths = if ($Vuln.diskPaths) { $Vuln.diskPaths -join ';' } else { '' } + registryPaths = if ($Vuln.registryPaths) { $Vuln.registryPaths -join ';' } else { '' } + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to extract: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + # Append to our tracking list + [void]$CveAggregator[$CveId].AffectedDevices.Add($DevicePayload) + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Allover Build: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + + $Entities = [System.Collections.Generic.List[object]]::new() + + foreach ($CveKey in $CveAggregator.Keys) { + $CveData = $CveAggregator[$CveKey] + + # Flatten or convert device info arrays into a compact, compressed JSON string + $CompactDeviceJson = $CveData.AffectedDevices | ConvertTo-Json -Compress + + [void]$Entities.Add(@{ + PartitionKey = $CveKey + RowKey = $TenantFilter # RowKey becomes just the Tenant, ensuring 1 row per CVE per Tenant + customerId = $TenantFilter + cveId = $CveKey + softwareVendor = $CveData.softwareVendor + softwareName = $CveData.softwareName + vulnerabilitySeverityLevel = $CveData.vulnerabilitySeverityLevel + recommendedSecurityUpdate = $CveData.recommendedSecurityUpdate + recommendedSecurityUpdateUrl = $CveData.recommendedSecurityUpdateUrl + exploitabilityLevel = $CveData.exploitabilityLevel + + # Meta aggregation counts + deviceCount = $CveData.AffectedDevices.Count + + # All individual device variations compressed safely inside a single field + deviceDetailsJson = $CompactDeviceJson + + lastUpdated = [string]$(Get-Date (Get-Date).ToUniversalTime() -UFormat '+%Y-%m-%dT%H:%M:%S.000Z') + }) + } + + if ($Entities.Count -eq 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No valid CVE records to cache" -sev 'Warning' + return + } + + $SuccessCount = 0 + $FailCount = 0 + + $UniqueCves = ($Entities | Select-Object -ExpandProperty cveId -Unique).Count + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $UniqueCves CVEs" -sev 'Info' + $Entities | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DefenderCVEs' -AddCount + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "CVE Cache failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "CVE Cache Refresh failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + throw + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDetectedApps.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDetectedApps.ps1 index 09c22d47b137d..158b818122cda 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDetectedApps.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDetectedApps.ps1 @@ -24,8 +24,9 @@ function Set-CIPPDBCacheDetectedApps { $JobRow = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" if (-not $JobRow) { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No $ReportName job submitted - skipping detected apps cache" -sev Info - return + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No $ReportName export job pending - submitting a new one" -sev Info + $null = New-CIPPIntuneReportExportJob -TenantFilter $TenantFilter -ReportName $ReportName + $JobRow = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" } $JobId = $JobRow.JobId @@ -35,26 +36,30 @@ function Set-CIPPDBCacheDetectedApps { return } - try { - $Job = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$JobId" -tenantid $TenantFilter - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId not retrievable: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage - Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue - return - } - switch ($Job.status) { - 'completed' { } - 'failed' { + $Deadline = [datetime]::UtcNow.AddMinutes(4) + while ($true) { + try { + $Job = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$JobId" -tenantid $TenantFilter + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId not retrievable: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + return + } + + if ($Job.status -eq 'completed') { break } + if ($Job.status -eq 'failed') { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId failed" -sev Error Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue return } - default { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId still '$($Job.status)' - skipping" -sev Info + if ([datetime]::UtcNow -ge $Deadline) { + # Keep the job row so the next cache run can consume the result + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId still '$($Job.status)' after waiting - the result will be cached on the next run" -sev Info return } + Start-Sleep -Seconds 20 } if (-not $Job.url) { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceSettings.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceSettings.ps1 index 79dd4c4cff821..a9be98143e58f 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceSettings.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceSettings.ps1 @@ -19,9 +19,8 @@ function Set-CIPPDBCacheDeviceSettings { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching device settings' -sev Debug - $DeviceSettings = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/directory/deviceLocalCredentials' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DeviceSettings' -Data @($DeviceSettings) -AddCount - $DeviceSettings = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/directory/deviceLocalCredentials' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DeviceSettings' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached device settings successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDevices.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDevices.ps1 index cb2bec81698f2..a51fd7ce1b03b 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDevices.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDevices.ps1 @@ -19,10 +19,8 @@ function Set-CIPPDBCacheDevices { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Azure AD devices' -sev Debug - $Devices = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/devices?$top=999&$select=id,displayName,operatingSystem,operatingSystemVersion,trustType,accountEnabled,approximateLastSignInDateTime' -tenantid $TenantFilter - if (!$Devices) { $Devices = @() } - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Devices' -Data $Devices -AddCount - $Devices = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/devices?$top=999&$select=id,displayName,operatingSystem,operatingSystemVersion,trustType,accountEnabled,approximateLastSignInDateTime' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Devices' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Azure AD devices successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDirectoryRecommendations.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDirectoryRecommendations.ps1 index 75074c9ab3b48..3a191fb6a10c3 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDirectoryRecommendations.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDirectoryRecommendations.ps1 @@ -19,9 +19,8 @@ function Set-CIPPDBCacheDirectoryRecommendations { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching directory recommendations' -sev Debug - $Recommendations = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/directory/recommendations?$top=999' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DirectoryRecommendations' -Data $Recommendations -AddCount - $Recommendations = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/directory/recommendations?$top=999' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DirectoryRecommendations' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached directory recommendations successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDomains.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDomains.ps1 index 0a67432e77b8b..1dbe4a8964dc7 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDomains.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDomains.ps1 @@ -18,9 +18,8 @@ function Set-CIPPDBCacheDomains { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching domains' -sev Debug - $Domains = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/domains' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Domains' -Data @($Domains) -AddCount - $Domains = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/domains' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Domains' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached domains successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGuests.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGuests.ps1 index 7a25235e6946d..42fadf461acd9 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGuests.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGuests.ps1 @@ -19,10 +19,8 @@ function Set-CIPPDBCacheGuests { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching guest users' -sev Debug - $Guests = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=userType eq 'Guest'&`$expand=sponsors&`$top=999" -tenantid $TenantFilter - if (!$Guests) { $Guests = @() } - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Guests' -Data $Guests -AddCount - $Guests = $null + New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=userType eq 'Guest'&`$expand=sponsors&`$top=999" -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Guests' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached guest users successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppInstallStatus.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppInstallStatus.ps1 index 329145fd4b641..8a72b2aaf8116 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppInstallStatus.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppInstallStatus.ps1 @@ -30,8 +30,11 @@ function Set-CIPPDBCacheIntuneAppInstallStatus { $JobRow = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" if (-not $JobRow) { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No $ReportName job submitted - skipping app install status cache" -sev Info - return + # No pending job - the nightly submission was already consumed by a previous cache run + # or never ran. Submit a new export job now so forced cache runs are self-sufficient. + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No $ReportName export job pending - submitting a new one" -sev Info + $null = New-CIPPIntuneReportExportJob -TenantFilter $TenantFilter -ReportName $ReportName + $JobRow = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" } $JobId = $JobRow.JobId @@ -41,26 +44,32 @@ function Set-CIPPDBCacheIntuneAppInstallStatus { return } - try { - $Job = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$JobId" -tenantid $TenantFilter - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId not retrievable: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage - Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue - return - } + # Poll the export job until it completes. Jobs submitted by the nightly orchestrator are + # long since completed and pass on the first check; freshly self-submitted jobs get a + # bounded wait so a forced run still returns fresh data. + $Deadline = [datetime]::UtcNow.AddMinutes(4) + while ($true) { + try { + $Job = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$JobId" -tenantid $TenantFilter + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId not retrievable: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + return + } - switch ($Job.status) { - 'completed' { } - 'failed' { + if ($Job.status -eq 'completed') { break } + if ($Job.status -eq 'failed') { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId failed" -sev Error Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue return } - default { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId still '$($Job.status)' - skipping" -sev Info + if ([datetime]::UtcNow -ge $Deadline) { + # Keep the job row so the next cache run can consume the result + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId still '$($Job.status)' after waiting - the result will be cached on the next run" -sev Info return } + Start-Sleep -Seconds 20 } if (-not $Job.url) { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxUsage.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxUsage.ps1 index 1488af0eda4c0..f523adf31fd16 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxUsage.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxUsage.ps1 @@ -19,9 +19,8 @@ function Set-CIPPDBCacheMailboxUsage { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching mailbox usage' -sev Debug - $MailboxUsage = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/reports/getMailboxUsageDetail(period='D7')?`$format=application%2fjson" -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxUsage' -Data $MailboxUsage -AddCount - $MailboxUsage = $null + New-GraphGetRequest -uri "https://graph.microsoft.com/beta/reports/getMailboxUsageDetail(period='D7')?`$format=application%2fjson" -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxUsage' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached mailbox usage successfully' -sev Debug } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDevices.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDevices.ps1 index 5c462e1d91e31..27032d5d4a4e2 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDevices.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDevices.ps1 @@ -18,10 +18,8 @@ function Set-CIPPDBCacheManagedDevices { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching managed devices' -sev Debug - $ManagedDevices = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$top=999' -tenantid $TenantFilter - if (!$ManagedDevices) { $ManagedDevices = @() } - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ManagedDevices' -Data $ManagedDevices -AddCount - $ManagedDevices = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$top=999' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ManagedDevices' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached managed devices successfully' -sev Debug } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOfficeActivations.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOfficeActivations.ps1 index 272f1fc317d65..c3592e6f259b1 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOfficeActivations.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOfficeActivations.ps1 @@ -19,9 +19,8 @@ function Set-CIPPDBCacheOfficeActivations { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Office activations' -sev Debug - $Activations = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/reports/getOffice365ActivationsUserDetail?`$format=application%2fjson" -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OfficeActivations' -Data $Activations -AddCount - $Activations = $null + New-GraphGetRequest -uri "https://graph.microsoft.com/beta/reports/getOffice365ActivationsUserDetail?`$format=application%2fjson" -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OfficeActivations' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Office activations successfully' -sev Debug } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOrganization.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOrganization.ps1 index 80b4dea03bf43..af291016ea5fb 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOrganization.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOrganization.ps1 @@ -19,9 +19,8 @@ function Set-CIPPDBCacheOrganization { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching organization data' -sev Debug - $Organization = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/organization' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Organization' -Data $Organization -AddCount - $Organization = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/organization' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Organization' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached organization data successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 index 04b802332c703..ca5aab39a4bf0 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 @@ -18,9 +18,8 @@ function Set-CIPPDBCacheRoleAssignmentScheduleInstances { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching role assignment schedule instances' -sev Debug - $RoleAssignmentScheduleInstances = New-GraphGetRequest -Uri 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleInstances' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleAssignmentScheduleInstances' -Data @($RoleAssignmentScheduleInstances) -AddCount - $RoleAssignmentScheduleInstances = $null + New-GraphGetRequest -Uri 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleInstances' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleAssignmentScheduleInstances' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached role assignment schedule instances successfully' -sev Debug } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 index 3a3b4208ced57..e730bab436fc5 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 @@ -18,9 +18,8 @@ function Set-CIPPDBCacheRoleEligibilitySchedules { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching role eligibility schedules' -sev Debug - $RoleEligibilitySchedules = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleEligibilitySchedules' -Data @($RoleEligibilitySchedules) -AddCount - $RoleEligibilitySchedules = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleEligibilitySchedules' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached role eligibility schedules successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 index 6d88d55f0259a..168e8aeedbf3b 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 @@ -18,9 +18,8 @@ function Set-CIPPDBCacheRoleManagementPolicies { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching role management policies' -sev Debug - $RoleManagementPolicies = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/roleManagementPolicies' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleManagementPolicies' -Data @($RoleManagementPolicies) -AddCount - $RoleManagementPolicies = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/roleManagementPolicies' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleManagementPolicies' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached role management policies successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheServicePrincipals.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheServicePrincipals.ps1 index 166b6cac7fd63..c36e84f9137ce 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheServicePrincipals.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheServicePrincipals.ps1 @@ -19,9 +19,8 @@ function Set-CIPPDBCacheServicePrincipals { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching service principals' -sev Debug - $ServicePrincipals = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/servicePrincipals' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ServicePrincipals' -Data $ServicePrincipals -AddCount - $ServicePrincipals = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/servicePrincipals' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ServicePrincipals' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached service principals successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 new file mode 100644 index 0000000000000..0516291c1b9f8 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 @@ -0,0 +1,90 @@ +function Set-CIPPDBCacheSharePointSharingLinks { + <# + .SYNOPSIS + Fans out SharePoint & OneDrive sharing link collection, one activity per site. + + .DESCRIPTION + Enumerates every site in the tenant (SharePoint sites and OneDrive personal sites) and the + tenant's verified domains, then starts a child orchestration with one activity per site + (Push-DBCacheSharePointSiteSharingLinks). Each site activity scans its own drives for shared + items and returns the sharing-link rows; a single PostExecution + (Push-StoreSharePointSharingLinks) aggregates all sites and writes the SharePointSharingLinks + cache once. Scanning all sites inline in one activity buffers the whole tenant's file tree and + OOM-kills the worker on large tenants - per-site fan-out bounds memory and runtime per activity. + + .PARAMETER TenantFilter + The tenant to cache sharing links for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Starting SharePoint/OneDrive sharing link collection (per-site fan-out)' -sev Debug + + # Verified domains, used by each site activity to tell internal from external recipients. + $InternalDomains = [System.Collections.Generic.List[string]]::new() + try { + $Domains = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/domains?`$select=id,isVerified" -tenantid $TenantFilter -asapp $true + foreach ($Domain in ($Domains | Where-Object { $_.isVerified })) { $InternalDomains.Add([string]$Domain.id) } + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Could not list verified domains for sharing link classification: $($_.Exception.Message)" -sev Warning + } + + # All sites, including OneDrive personal sites. Cheap - just the site list, no drive scan here. + $Sites = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/getAllSites?`$select=id,displayName,name,webUrl,isPersonalSite&`$top=999" -tenantid $TenantFilter -asapp $true) + + if ($Sites.Count -eq 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No sites found; writing empty SharePointSharingLinks cache' -sev Debug + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -Data @() -AddCount + return + } + + $Batch = foreach ($Site in $Sites) { + [PSCustomObject]@{ + FunctionName = 'DBCacheSharePointSiteSharingLinks' + TenantFilter = $TenantFilter + SiteId = $Site.id + SiteName = $Site.displayName ?? $Site.name + SiteUrl = $Site.webUrl + IsPersonalSite = [bool]$Site.isPersonalSite + InternalDomains = @($InternalDomains) + QueueId = $QueueId + QueueName = "Sharing Links - $($Site.webUrl)" + } + } + + # Track the per-site activities against the same queue counter. + if ($QueueId) { + try { + Update-CippQueueEntry -RowKey $QueueId -TotalTasks $Sites.Count -IncrementTotalTasks + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Could not update queue $QueueId with sharing-link tasks: $($_.Exception.Message)" -sev Warning + } + } + + $InputObject = [PSCustomObject]@{ + Batch = @($Batch) + OrchestratorName = "SharePointSharingLinks_$TenantFilter" + SkipLog = $true + PostExecution = @{ + FunctionName = 'StoreSharePointSharingLinks' + Parameters = @{ + TenantFilter = $TenantFilter + } + } + } + + $null = Start-CIPPOrchestrator -InputObject $InputObject + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Started sharing link collection across $($Sites.Count) sites" -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to start SharePoint sharing link collection: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheUsers.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheUsers.ps1 index e78271e0650f7..14eb2bef641d1 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheUsers.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheUsers.ps1 @@ -87,7 +87,7 @@ function Set-CIPPDBCacheUsers { } # Stream users directly from Graph API to batch processor - New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$top=$Top&`$select=$Select&`$count=true" -ComplexFilter -tenantid $TenantFilter | + New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$top=$Top&`$select=$Select&`$count=true" -ComplexFilter -tenantid $TenantFilter -Stream | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Users' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached users successfully' -sev Debug diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 index 222e7abed29d8..867ce9a67cb0d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 @@ -14,7 +14,12 @@ function Invoke-ExecCippFunction { $BlockList = @( 'Get-GraphToken' 'Get-GraphTokenFromCert' + 'New-CIPPCertificateAssertion' 'Get-ClassicAPIToken' + 'Get-CIPPSAMCertificate' + 'New-CIPPSAMCertificate' + 'Set-CIPPSAMCertificate' + 'Update-CIPPSAMCertificate' ) $Function = $Request.Body.FunctionName diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 index bd5cd4d8e0112..7beffb2959103 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 @@ -12,7 +12,7 @@ function Invoke-ExecEditTemplate { try { $Table = Get-CippTable -tablename 'templates' $guid = $request.Body.id ? $request.Body.id : $request.Body.GUID - $JSON = ConvertTo-Json -Compress -Depth 100 -InputObject ($request.Body | Select-Object * -ExcludeProperty GUID) + $JSON = ConvertTo-Json -Compress -Depth 100 -InputObject ($request.Body | Select-Object * -ExcludeProperty GUID, source, isSynced, package) $Type = $request.Query.Type ?? $Request.Body.Type if ($Type -eq 'IntuneTemplate') { @@ -63,13 +63,14 @@ function Invoke-ExecEditTemplate { } Set-CIPPIntuneTemplate @IntuneTemplate } else { - $Table.Force = $true - Add-CIPPAzDataTableEntity @Table -Entity @{ + $Entity = @{ JSON = "$JSON" RowKey = "$GUID" PartitionKey = "$Type" GUID = "$GUID" + SHA = '' } + Add-CIPPAzDataTableEntity @Table -Entity $Entity -OperationType 'UpsertMerge' Write-LogMessage -headers $Request.Headers -API $APINAME -message "Edited template $($Request.Body.name) with GUID $GUID" -Sev 'Debug' } $body = [pscustomobject]@{ 'Results' = 'Successfully saved the template' } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSAMCertificate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSAMCertificate.ps1 new file mode 100644 index 0000000000000..7be0de1667a54 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSAMCertificate.ps1 @@ -0,0 +1,75 @@ +function Invoke-ExecSAMCertificate { + <# + .SYNOPSIS + Get SAM certificate status or trigger a renewal + .DESCRIPTION + Returns status information about the SAM app certificate (thumbprint, validity, + registration state) or forces a renewal via Update-CIPPSAMCertificate. Never + returns private key material. + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.AppSettings.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + try { + $Action = $Request.Body.Action ?? $Request.Query.Action ?? 'Get' + + switch ($Action) { + 'Get' { + $Stored = Get-CIPPSAMCertificate -SkipCache -ErrorAction SilentlyContinue + if ($null -eq $Stored) { + $Body = @{ + Configured = $false + Results = 'No SAM certificate found. One will be created automatically by the weekly token update, or use the Renew action to create it now.' + } + } else { + $RegisteredOnApp = $false + try { + $AppRegistration = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$($env:ApplicationID)')?`$select=id,keyCredentials" -NoAuthCheck $true -AsApp $true -ErrorAction Stop + # Graph returns customKeyIdentifier as the hex thumbprint string, but tolerate base64 as well + $Identifiers = @($AppRegistration.keyCredentials.customKeyIdentifier) + $RegisteredOnApp = $Identifiers -contains $Stored.Thumbprint -or $Identifiers -contains [Convert]::ToBase64String([Convert]::FromHexString($Stored.Thumbprint)) + } catch { + Write-Warning "Could not check app registration key credentials: $($_.Exception.Message)" + } + $Body = @{ + Configured = $true + Thumbprint = $Stored.Thumbprint + NotBefore = $Stored.NotBefore + NotAfter = $Stored.NotAfter + DaysRemaining = [math]::Floor(($Stored.NotAfter - (Get-Date).ToUniversalTime()).TotalDays) + RegisteredOnApp = $RegisteredOnApp + } + } + $StatusCode = [HttpStatusCode]::OK + } + 'Renew' { + $Result = Update-CIPPSAMCertificate -Force -ErrorAction Stop + $Body = @{ + Results = "SAM certificate renewed. Thumbprint: $($Result.Thumbprint), expires: $($Result.NotAfter)" + Thumbprint = $Result.Thumbprint + NotAfter = $Result.NotAfter + StorageMode = $Result.StorageMode + } + $StatusCode = [HttpStatusCode]::OK + } + default { + throw "Invalid action: $Action. Valid actions are 'Get' or 'Renew'" + } + } + } catch { + Write-LogMessage -API 'ExecSAMCertificate' -message "Failed to process SAM certificate request: $($_.Exception.Message)" -sev 'Error' -LogData (Get-CippException -Exception $_) + $StatusCode = [HttpStatusCode]::BadRequest + $Body = @{ + Results = "Failed to process SAM certificate request: $($_.Exception.Message)" + } + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 new file mode 100644 index 0000000000000..a961058be27fe --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 @@ -0,0 +1,83 @@ +function Invoke-ListAlertResults { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + CIPP.Alert.Read + .DESCRIPTION + Lists the currently-active fired alert items for a tenant, read from the + AlertLastRun table. AlertLastRun stores the items produced by the most recent + run of each scripted alert (Get-CIPPAlert*) after snoozed items have already + been filtered out, so this returns the active (non-snoozed) instances. Each + item is returned with a content preview/hash (matching the snooze format) and + the raw alert item so the frontend can snooze it via ExecSnoozeAlert. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + try { + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'tenantFilter is required.' } + }) + } + + $Table = Get-CIPPTable -tablename 'AlertLastRun' + # AlertLastRun: PartitionKey = run date (yyyyMMdd), RowKey = "{tenant}-{cmdlet}" + $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter -Type String + $Rows = Get-CIPPAzDataTableEntity @Table -Filter "Tenant eq '$SafeTenant'" + + # Keep only the most recent run (highest date partition) per alert. RowKey is + # "{tenant}-{cmdlet}", uniquely identifying the alert for this tenant. Write-AlertTrace + # only writes a new row when the data changes, so the latest row is the current state. + $LatestByAlert = @{} + foreach ($Row in @($Rows)) { + $Key = $Row.RowKey + $Existing = $LatestByAlert[$Key] + if (-not $Existing -or [string]$Row.PartitionKey -gt [string]$Existing.PartitionKey) { + $LatestByAlert[$Key] = $Row + } + } + + $Results = [System.Collections.Generic.List[object]]::new() + foreach ($Row in $LatestByAlert.Values) { + if ([string]::IsNullOrWhiteSpace($Row.LogData)) { continue } + try { + $Items = $Row.LogData | ConvertFrom-Json -ErrorAction Stop + } catch { + Write-Information "Failed to parse AlertLastRun LogData for $($Row.RowKey): $($_.Exception.Message)" + continue + } + + foreach ($Item in @($Items)) { + if ($null -eq $Item) { continue } + $Hash = Get-AlertContentHash -AlertItem $Item + $Results.Add([PSCustomObject]@{ + CmdletName = $Row.CmdletName + AlertComment = $Row.AlertComment + Tenant = $Row.Tenant + LastRun = $Row.PartitionKey + ContentHash = $Hash.ContentHash + ContentPreview = $Hash.ContentPreview + AlertItem = $Item + }) + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Results) + }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Failed to list alert results: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ Results = "Failed to list alert results: $($ErrorMessage.NormalizedError)" } + }) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 index d4d924f206839..8c1ebf12ca5da 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 @@ -24,6 +24,18 @@ function Invoke-ListFeatureFlags { elseIf ($Flag.Id -eq 'AppInsights') { $Flag.Enabled = $false } + elseIf ($Flag.Id -eq 'FunctionOffloading') { + $Flag.Enabled = $false + } + } + } + + # Hosted instances hide the backend settings page (Azure resource URLs) + if ($env:CIPP_HOSTED -eq 'true') { + foreach ($Flag in $FeatureFlags) { + if ($Flag.Id -eq 'BackendSettings') { + $Flag.Enabled = $false + } } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 index b4e897b7999a4..e0841c6eeced1 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 @@ -41,6 +41,7 @@ function Invoke-ListSnoozedAlerts { RowKey = $_.RowKey CmdletName = $_.PartitionKey Tenant = $_.Tenant + ContentHash = $_.ContentHash ContentPreview = $_.ContentPreview SnoozedBy = $_.SnoozedBy SnoozedAt = $_.SnoozedAt diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 index f3443d1471c64..bb5101bde039f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 @@ -7,11 +7,6 @@ function Invoke-AddScheduledItem { #> [CmdletBinding()] param($Request, $TriggerMetadata) - if ($null -eq $Request.Query.hidden) { - $hidden = $false - } else { - $hidden = $true - } $DisallowDuplicateName = $Request.Query.DisallowDuplicateName ?? $Request.Body.DisallowDuplicateName @@ -25,14 +20,17 @@ function Invoke-AddScheduledItem { $ExistingTask = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) } - if ($ExistingTask -and $Request.Body.RunNow -eq $true) { - $RerunParams = @{ - TenantFilter = $ExistingTask.Tenant - Type = 'ScheduledTask' - API = $Request.Body.RowKey - Clear = $true + if ($null -eq $Request.Query.hidden) { + if ($ExistingTask -and $null -ne $ExistingTask.Hidden) { + $hidden = [bool]$ExistingTask.Hidden + } else { + $hidden = $false } - $null = Test-CIPPRerun @RerunParams + } else { + $hidden = $true + } + + if ($ExistingTask -and $Request.Body.RunNow -eq $true) { # Clear ExecutedTime so the one-time task rerun guard in Push-ExecScheduledCommand does not block re-execution $null = Update-AzDataTableEntity -Force @Table -Entity @{ PartitionKey = $ExistingTask.PartitionKey diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackupReplicationConfig.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackupReplicationConfig.ps1 new file mode 100644 index 0000000000000..1d71ffc2be0cd --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackupReplicationConfig.ps1 @@ -0,0 +1,115 @@ +function Invoke-ExecBackupReplicationConfig { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.AppSettings.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Table = Get-CIPPTable -TableName Config + $Scopes = @('Core', 'Tenant') + + # Returns whether a SAS URL secret currently exists for the given scope, without ever exposing it. + function Get-ReplicationSecretIsSet { + param([string]$Scope) + try { + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' + $Secret = (Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'BackupReplication$Scope' and RowKey eq 'BackupReplication$Scope'").SASUrl + if ([string]::IsNullOrWhiteSpace($Secret)) { + return $null + } + return "SentToKeyVault" + } + else { + $Secret = Get-CippKeyVaultSecret -Name "BackupReplication$Scope" -AsPlainText + if ([string]::IsNullOrWhiteSpace($Secret)) { + return $null + } + return "SentToKeyVault" + } + } catch { + return $null + } + } + + $results = try { + if ($Request.Query.List) { + $Output = @{} + foreach ($Scope in $Scopes) { + $Config = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'BackupReplication' and RowKey eq '$Scope'" + $Output[$Scope] = @{ + Enabled = [bool]($Config.Enabled) + IsSet = Get-ReplicationSecretIsSet -Scope $Scope + } + } + [pscustomobject]$Output + } else { + $BackupType = $Request.Body.BackupType + if ($BackupType -notin $Scopes) { + throw "BackupType must be one of: $($Scopes -join ', ')" + } + + $SASUrl = $Request.Body.SASUrl + $Enabled = if ($null -ne $Request.Body.Enabled) { [bool]$Request.Body.Enabled } else { $true } + + # Only update the stored secret when a real new value is supplied (the UI sends the + # 'SentToKeyVault' sentinel when the existing, masked secret is left untouched). + if (-not [string]::IsNullOrWhiteSpace($SASUrl) -and $SASUrl -ne 'SentToKeyVault') { + $ParsedUri = $SASUrl -as [uri] + if (-not $ParsedUri -or $ParsedUri.Query -notmatch 'sig=') { + throw 'SAS URL must contain a SAS token (sig=...)' + } + + # Confirm the SAS actually grants write+create by writing and removing a tiny probe blob. + $guid = [guid]::NewGuid().ToString() + $UrlParts = $SASUrl -split '\?', 2 + $BaseUrl = $UrlParts[0].TrimEnd('/') + $ProbeUrl = "$BaseUrl/.cipp-replication-test-$guid`?$($UrlParts[1])" + try { + $null = Invoke-CIPPRestMethod -Uri $ProbeUrl -Method 'PUT' -Body "cipp-replication-test-$guid" -ContentType 'text/plain' -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } + try { $null = Invoke-CIPPRestMethod -Uri $ProbeUrl -Method 'DELETE' -Headers @{} } catch { } + } catch { + $ProbeError = Get-CippException -Exception $_ + throw "SAS URL validation failed (could not write to the container): $($ProbeError.NormalizedError)" + } + + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' + $Secret = [PSCustomObject]@{ + 'PartitionKey' = "BackupReplication$BackupType" + 'RowKey' = "BackupReplication$BackupType" + 'SASUrl' = $SASUrl + } + Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force | Out-Null + } + else { + Set-CippKeyVaultSecret -Name "BackupReplication$BackupType" -SecretValue (ConvertTo-SecureString -String $SASUrl -AsPlainText -Force) | Out-Null + } + } + + $Config = @{ + 'PartitionKey' = 'BackupReplication' + 'RowKey' = $BackupType + 'Enabled' = $Enabled + } + Add-CIPPAzDataTableEntity @Table -Entity $Config -Force | Out-Null + + Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -message "Updated $BackupType backup replication settings (Enabled: $Enabled)" -Sev 'Info' + "Successfully updated $BackupType backup replication settings" + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -message "Failed to update backup replication configuration: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + "Failed to update configuration: $($ErrorMessage.NormalizedError)" + } + + $body = [pscustomobject]@{'Results' = $Results } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $body + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecOffloadFunctions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecOffloadFunctions.ps1 index 8326ceacbfb49..a2081374dffc9 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecOffloadFunctions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecOffloadFunctions.ps1 @@ -16,7 +16,7 @@ function Invoke-ExecOffloadFunctions { $VersionTable = Get-CippTable -tablename 'Version' $Version = Get-CIPPAzDataTableEntity @VersionTable -Filter "RowKey ne 'Version'" $MainVersion = $Version | Where-Object { $_.RowKey -eq $env:WEBSITE_SITE_NAME } - $OffloadVersions = $Version | Where-Object { $_.RowKey -match '-' } + $OffloadVersions = $Version | Where-Object { Test-CippOffloadFunctionApp -SiteName $_.RowKey } $Alerts = [System.Collections.Generic.List[string]]::new() @@ -35,7 +35,7 @@ function Invoke-ExecOffloadFunctions { } } - $VersionTable = $Version | Select-Object @{n = 'Name'; e = { $_.RowKey } }, @{n = 'Version'; e = { $_.Version } }, @{n = 'Default'; e = { $_.RowKey -notmatch '-' } } + $VersionTable = $Version | Select-Object @{n = 'Name'; e = { $_.RowKey } }, @{n = 'Version'; e = { $_.Version } }, @{n = 'Default'; e = { -not (Test-CippOffloadFunctionApp -SiteName $_.RowKey) } } $CurrentState = if (!$CurrentState) { [PSCustomObject]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCombinedSetup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCombinedSetup.ps1 index 154edd7c73af1..a6e6622499adc 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCombinedSetup.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCombinedSetup.ps1 @@ -56,7 +56,7 @@ function Invoke-ExecCombinedSetup { $Results.add($notificationResults) } if ($Request.Body.selectedOption -eq 'Manual') { - $KV = $env:WEBSITE_DEPLOYMENT_ID + $KV = Get-CippKeyVaultName if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 index c1a8350cbc19b..b51d8c26a76fc 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 @@ -9,7 +9,7 @@ function Invoke-ExecCreateSAMApp { [CmdletBinding()] param($Request, $TriggerMetadata) - $KV = $env:WEBSITE_DEPLOYMENT_ID + $KV = Get-CippKeyVaultName try { $Token = $Request.body @@ -107,6 +107,19 @@ function Invoke-ExecCreateSAMApp { # Reload credentials into env vars $null = Get-CIPPAuthentication + # Create the SAM certificate alongside the secret so certificate auth is available + # immediately. Uses the wizard's delegated token because the app secret was created + # seconds ago and may not have propagated yet. Non-fatal: the weekly token update + # timer creates the certificate if this fails. + try { + $CertResult = Update-CIPPSAMCertificate -ApplicationId $AppId.appId -Headers @{ authorization = "Bearer $($Token.access_token)" } + if ($CertResult.Renewed) { + Write-Information "SAM certificate created. Thumbprint: $($CertResult.Thumbprint), expires: $($CertResult.NotAfter), storage mode: $($CertResult.StorageMode)" + } + } catch { + Write-Warning "Failed to create SAM certificate during setup, the weekly token update will create it: $($_.Exception.Message)" + } + $Results = @{'message' = "Successfully $state the application registration. The application ID is $($AppId.appid). You may continue to the next step."; severity = 'success' } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSAMSetup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSAMSetup.ps1 deleted file mode 100644 index 76fe86b3af932..0000000000000 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSAMSetup.ps1 +++ /dev/null @@ -1,236 +0,0 @@ -function Invoke-ExecSAMSetup { - <# - .FUNCTIONALITY - Entrypoint,AnyTenant - .ROLE - CIPP.AppSettings.ReadWrite - .LEGACY - This function is a legacy function that was used to set up the CIPP application in Azure AD. It is not used in the current version of CIPP, look at Invoke-ExecCreateSAMApp for the new version. - #> - [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')] - [CmdletBinding()] - param($Request, $TriggerMetadata) - - return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = @{ message = 'This endpoint is no longer used. Please use the CreateSAMApp endpoint instead.' } - }) - - if ($Request.Query.error) { - Add-Type -AssemblyName System.Web - return ([HttpResponseContext]@{ - ContentType = 'text/html' - StatusCode = [HttpStatusCode]::Forbidden - Body = Get-normalizedError -Message [System.Web.HttpUtility]::UrlDecode($Request.Query.error_description) - }) - exit - } - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' - $Secret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" - if (!$Secret) { - $Secret = [PSCustomObject]@{ - 'PartitionKey' = 'Secret' - 'RowKey' = 'Secret' - 'TenantId' = '' - 'RefreshToken' = '' - 'ApplicationId' = '' - 'ApplicationSecret' = '' - } - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force - } - } - if (!$env:SetFromProfile) { - Write-Information "We're reloading from KV" - Get-CIPPAuthentication - } - - $KV = $env:WEBSITE_DEPLOYMENT_ID - $Table = Get-CIPPTable -TableName SAMWizard - $Rows = Get-CIPPAzDataTableEntity @Table | Where-Object -Property Timestamp -GT (Get-Date).AddMinutes(-10) - - try { - if ($Request.Query.count -lt 1 ) { $Results = 'No authentication code found. Please go back to the wizard.' } - - if ($Request.Body.setkeys) { - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - if ($Request.Body.TenantId) { $Secret.TenantId = $Request.Body.tenantid } - if ($Request.Body.RefreshToken) { $Secret.RefreshToken = $Request.Body.RefreshToken } - if ($Request.Body.applicationid) { $Secret.ApplicationId = $Request.Body.ApplicationId } - if ($Request.Body.ApplicationSecret) { $Secret.ApplicationSecret = $Request.Body.ApplicationSecret } - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force - } else { - if ($Request.Body.tenantid) { Set-CippKeyVaultSecret -VaultName $kv -Name 'tenantid' -SecretValue (ConvertTo-SecureString -String $Request.Body.tenantid -AsPlainText -Force) } - if ($Request.Body.RefreshToken) { Set-CippKeyVaultSecret -VaultName $kv -Name 'RefreshToken' -SecretValue (ConvertTo-SecureString -String $Request.Body.RefreshToken -AsPlainText -Force) } - if ($Request.Body.applicationid) { Set-CippKeyVaultSecret -VaultName $kv -Name 'applicationid' -SecretValue (ConvertTo-SecureString -String $Request.Body.applicationid -AsPlainText -Force) } - if ($Request.Body.applicationsecret) { Set-CippKeyVaultSecret -VaultName $kv -Name 'applicationsecret' -SecretValue (ConvertTo-SecureString -String $Request.Body.applicationsecret -AsPlainText -Force) } - } - - $Results = @{ Results = 'The keys have been replaced. Please perform a permissions check.' } - } - if ($Request.Query.error -eq 'invalid_client') { $Results = 'Client ID was not found in Azure. Try waiting 10 seconds to try again, if you have gotten this error after 5 minutes, please restart the process.' } - if ($Request.Query.code) { - try { - $TenantId = $Rows.tenantid - if (!$TenantId -or $TenantId -eq 'NotStarted') { $TenantId = $env:TenantID } - $AppID = $Rows.appid - if (!$AppID -or $AppID -eq 'NotStarted') { $appid = $env:ApplicationID } - $URL = ($Request.headers.'x-ms-original-url').split('?') | Select-Object -First 1 - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $clientsecret = $Secret.ApplicationSecret - } else { - $clientsecret = Get-CippKeyVaultSecret -VaultName $kv -Name 'ApplicationSecret' -AsPlainText - } - if (!$clientsecret) { $clientsecret = $env:ApplicationSecret } - Write-Information "client_id=$appid&scope=https://graph.microsoft.com/.default+offline_access+openid+profile&code=$($Request.Query.code)&grant_type=authorization_code&redirect_uri=$($url)&client_secret=$clientsecret" #-Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" - $RefreshToken = Invoke-RestMethod -Method POST -Body "client_id=$appid&scope=https://graph.microsoft.com/.default+offline_access+openid+profile&code=$($Request.Query.code)&grant_type=authorization_code&redirect_uri=$($url)&client_secret=$clientsecret" -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -ContentType 'application/x-www-form-urlencoded' - - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $Secret.RefreshToken = $RefreshToken.refresh_token - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force - } else { - Set-CippKeyVaultSecret -VaultName $kv -Name 'RefreshToken' -SecretValue (ConvertTo-SecureString -String $RefreshToken.refresh_token -AsPlainText -Force) - } - - $Results = 'Authentication is now complete. You may now close this window.' - try { - $SetupPhase = $rows.validated = $true - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - } catch { - #no need. - } - } catch { - $Results = "Authentication failed. $($_.Exception.message)" - } - } - if ($Request.Query.CreateSAM) { - $Rows = @{ - RowKey = 'setup' - PartitionKey = 'setup' - validated = $false - SamSetup = 'NotStarted' - partnersetup = $true - appid = 'NotStarted' - tenantid = 'NotStarted' - } - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - $Rows = Get-CIPPAzDataTableEntity @Table | Where-Object -Property Timestamp -GT (Get-Date).AddMinutes(-10) - $step = 1 - $DeviceLogon = New-DeviceLogin -clientid '1b730954-1685-4b74-9bfd-dac224a7b894' -Scope 'https://graph.microsoft.com/.default' -FirstLogon - $SetupPhase = $rows.SamSetup = [string]($DeviceLogon | ConvertTo-Json) - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - $Results = @{ code = $($DeviceLogon.user_code); message = "Your code is $($DeviceLogon.user_code). Enter the code" ; step = $step; url = $DeviceLogon.verification_uri } - } - if ($Request.Query.CheckSetupProcess -and $Request.Query.step -eq 1) { - $SAMSetup = $Rows.SamSetup | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($SamSetup.token_type -eq 'Bearer') { - #sleeping for 10 seconds to allow the token to be created. - Start-Sleep 10 - #nulling the token to force a recheck. - $step = 2 - } - $Token = (New-DeviceLogin -clientid '1b730954-1685-4b74-9bfd-dac224a7b894' -Scope 'https://graph.microsoft.com/.default' -device_code $SAMSetup.device_code) - Write-Information "Token is $($token | ConvertTo-Json)" - if ($Token.access_token) { - $step = 2 - $rows.SamSetup = [string]($Token | ConvertTo-Json) - $URL = ($Request.headers.'x-ms-original-url').split('?') | Select-Object -First 1 - $PartnerSetup = $true - $TenantId = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/organization' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method GET -ContentType 'application/json').value.id - $SetupPhase = $rows.tenantid = [string]($TenantId) - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - if ($PartnerSetup) { - #$app = Get-Content '.\Cache_SAMSetup\SAMManifest.json' | ConvertFrom-Json - $SamManifestFile = Get-Item (Join-Path $env:CIPPRootPath 'Config\SAMManifest.json') - $app = Get-Content $SamManifestFile.FullName | ConvertFrom-Json - - $App.web.redirectUris = @($App.web.redirectUris + $URL) - $app = $app | ConvertTo-Json -Depth 15 - $AppId = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/applications' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body $app -ContentType 'application/json') - $rows.appid = [string]($AppId.appId) - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - $attempt = 0 - do { - try { - try { - $SPNDefender = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/servicePrincipals' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body "{ `"appId`": `"fc780465-2017-40d4-a0c5-307022471b92`" }" -ContentType 'application/json') - } catch { - Write-Information "didn't deploy spn for defender, probably already there." - } - try { - $SPNTeams = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/servicePrincipals' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body "{ `"appId`": `"48ac35b8-9aa8-4d74-927d-1f4a14a0b239`" }" -ContentType 'application/json') - } catch { - Write-Information "didn't deploy spn for Teams, probably already there." - } - try { - $SPNO365Manage = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/servicePrincipals' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body "{ `"appId`": `"c5393580-f805-4401-95e8-94b7a6ef2fc2`" }" -ContentType 'application/json') - } catch { - Write-Information "didn't deploy spn for O365 Management, probably already there." - } - try { - $SPNPartnerCenter = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/servicePrincipals' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body "{ `"appId`": `"fa3d9a0c-3fb0-42cc-9193-47c7ecd2edbd`" }" -ContentType 'application/json') - } catch { - Write-Information "didn't deploy spn for PartnerCenter, probably already there." - } - $SPN = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/servicePrincipals' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body "{ `"appId`": `"$($AppId.appId)`" }" -ContentType 'application/json') - Start-Sleep 3 - $attempt ++ - } catch { - $attempt ++ - } - } until ($attempt -gt 5) - } - $AppPassword = (Invoke-RestMethod "https://graph.microsoft.com/v1.0/applications/$($AppId.id)/addPassword" -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body '{"passwordCredential":{"displayName":"CIPPInstall"}}' -ContentType 'application/json').secretText - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $Secret.TenantId = $TenantId - $Secret.ApplicationId = $AppId.appId - $Secret.ApplicationSecret = $AppPassword - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force - Write-Information ($Secret | ConvertTo-Json -Depth 5) - } else { - Set-CippKeyVaultSecret -VaultName $kv -Name 'tenantid' -SecretValue (ConvertTo-SecureString -String $TenantId -AsPlainText -Force) - Set-CippKeyVaultSecret -VaultName $kv -Name 'applicationid' -SecretValue (ConvertTo-SecureString -String $Appid.appId -AsPlainText -Force) - Set-CippKeyVaultSecret -VaultName $kv -Name 'applicationsecret' -SecretValue (ConvertTo-SecureString -String $AppPassword -AsPlainText -Force) - } - $Results = @{'message' = 'Created application. Waiting 30 seconds for Azure propagation'; step = $step } - } else { - $step = 1 - $Results = @{ code = $($SAMSetup.user_code); message = "Your code is $($SAMSetup.user_code). Enter the code " ; step = $step; url = $SAMSetup.verification_uri } - } - - } - switch ($Request.Query.step) { - 2 { - $step = 2 - $TenantId = $Rows.tenantid - $AppID = $rows.appid - $PartnerSetup = $true - $SetupPhase = $rows.SamSetup = [string]($FirstLogonRefreshtoken | ConvertTo-Json) - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - $URL = ($Request.headers.'x-ms-original-url').split('?') | Select-Object -First 1 - $Validated = $Rows.validated - if ($Validated) { $step = 3 } - $Results = @{ appId = $AppID; message = 'Give the next approval by clicking ' ; step = $step; url = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/authorize?scope=https://graph.microsoft.com/.default+offline_access+openid+profile&response_type=code&client_id=$($appid)&redirect_uri=$($url)" } - } - 3 { - $step = 4 - $Results = @{'message' = 'Received token.'; step = $step } - } - 4 { - Remove-AzDataTableEntity -Force @Table -Entity $Rows - $step = 5 - $Results = @{'message' = 'setup completed.'; step = $step - } - } - } - - } catch { - $Results = [pscustomobject]@{'Results' = "Failed. $($_.InvocationInfo.ScriptLineNumber): $($_.Exception.message)" ; step = $step } - } - - return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = $Results - }) - -} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 index f693c9d6648a2..b6114a265aaad 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 @@ -193,8 +193,7 @@ function Invoke-ExecSSOSetup { # Best-effort: stash TenantID in KV if missing (was previously inline) if (-not ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true')) { - $KV = $env:WEBSITE_DEPLOYMENT_ID - $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } + $VaultName = Get-CippKeyVaultName if ($VaultName -and $env:TenantID) { $ExistingTenantId = $null try { $ExistingTenantId = Get-CippKeyVaultSecret -VaultName $VaultName -Name 'TenantID' -AsPlainText -ErrorAction Stop } catch { } @@ -513,8 +512,7 @@ function Invoke-ExecSSOSetup { $DevSecret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'SSO'" -ErrorAction SilentlyContinue $ExistingAppId = $DevSecret.SSOAppId } else { - $KV = $env:WEBSITE_DEPLOYMENT_ID - $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } + $VaultName = Get-CippKeyVaultName if ($VaultName) { try { $ExistingAppId = Get-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOAppId' -AsPlainText -ErrorAction Stop } catch { } } @@ -593,9 +591,78 @@ function Invoke-ExecSSOSetup { } } + 'ManualConfigure' { + # Manually set the SSO AppId / client secret / multi-tenant flag directly in Key Vault. + # Used to rotate the secret by hand or repoint EasyAuth at a different app registration + # without the automated Create/Repair flow. Credentials are read from KV at startup, + # so the instance must be restarted for the change to take effect. + try { + $AppId = $Request.Body.appId + $AppSecret = $Request.Body.appSecret + $MultiTenant = [bool]($Request.Body.multiTenant) + + # Validate AppId — must be a GUID + $ParsedGuid = [System.Guid]::Empty + if ([string]::IsNullOrWhiteSpace($AppId) -or -not [System.Guid]::TryParse($AppId.Trim(), [ref]$ParsedGuid)) { + $StatusCode = [HttpStatusCode]::BadRequest + $Body = @{ Results = 'A valid Application (client) ID is required.' } + break + } + $AppId = $ParsedGuid.ToString() + + if ([string]::IsNullOrWhiteSpace($AppSecret)) { + $StatusCode = [HttpStatusCode]::BadRequest + $Body = @{ Results = 'A client secret is required.' } + break + } + + # Persist to Key Vault (or the DevSecrets table in dev mode) + Set-CIPPSSOStoredCredentials -AppId $AppId -AppSecret $AppSecret -MultiTenant $MultiTenant + + # Update the migration table so the Status page reflects the manual config. + # Clear ObjectId — it belonged to the previous app registration and would be stale + # if the AppId was changed. Repair/RotateSecret re-fetch it by AppId when missing. + $Existing = Get-CIPPAzDataTableEntity @MigrationTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'MigrationConfig'" -ErrorAction SilentlyContinue + & $SaveMigrationRow @{ + AppId = $AppId + ObjectId = '' + MultiTenant = [string]$MultiTenant + Status = 'secrets_stored' + CreatedAt = $Existing.CreatedAt ?? (Get-Date).ToUniversalTime().ToString('o') + ManualConfig = 'true' + LastError = '' + } + + Write-LogMessage -API $APIName -headers $Headers -message "SSO credentials manually configured for app $AppId (multiTenant=$MultiTenant)" -sev Info + + $IsCippNg = [bool]$env:CIPPNG + $Message = if ($IsCippNg) { + 'SSO credentials saved to Key Vault. Restart the instance to apply the new configuration.' + } else { + 'SSO credentials saved to Key Vault successfully.' + } + + $Body = @{ + Results = @{ + message = $Message + appId = $AppId + multiTenant = $MultiTenant + requiresRestart = $IsCippNg + isCippNg = $IsCippNg + severity = 'success' + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -headers $Headers -message "Manual SSO configuration failed: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + $Body = @{ Results = "Manual SSO configuration failed: $($ErrorMessage.NormalizedError)" } + } + } + default { $StatusCode = [HttpStatusCode]::BadRequest - $Body = @{ Results = "Unknown action: $Action. Use 'Status', 'Create', 'Repair', 'Recreate', 'Update', 'RotateSecret', or 'Migrate'." } + $Body = @{ Results = "Unknown action: $Action. Use 'Status', 'Create', 'Repair', 'Recreate', 'Update', 'RotateSecret', 'ManualConfigure', or 'Migrate'." } } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecTokenExchange.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecTokenExchange.ps1 index 5621576362f63..dcf2154812270 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecTokenExchange.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecTokenExchange.ps1 @@ -9,7 +9,7 @@ function Invoke-ExecTokenExchange { param($Request, $TriggerMetadata) # Get the key vault name - $KV = $env:WEBSITE_DEPLOYMENT_ID + $KV = Get-CippKeyVaultName $APIName = $Request.Params.CIPPEndpoint try { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 index 7461952cc25ca..321388cd22332 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 @@ -9,7 +9,7 @@ function Invoke-ExecUpdateRefreshToken { [CmdletBinding()] param($Request, $TriggerMetadata) - $KV = $env:WEBSITE_DEPLOYMENT_ID + $KV = Get-CippKeyVaultName try { # Handle refresh token update diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 index aa7996657fee8..478bc1aa4ef83 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 @@ -17,110 +17,27 @@ function Invoke-ExecEditMailboxPermissions { $Username = $request.body.userID $Tenantfilter = $request.body.tenantfilter if ($username -eq $null) { exit } - $userid = (New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($username)" -tenantid $Tenantfilter).id $Results = [System.Collections.ArrayList]@() - $RemoveFullAccess = ($Request.body.RemoveFullAccess).value - foreach ($RemoveUser in $RemoveFullAccess) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Remove-mailboxpermission' -cmdParams @{Identity = $userid; user = $RemoveUser; accessRights = @('FullAccess'); } - $results.add("Removed $($removeuser) from $($username) Shared Mailbox permissions") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Removed $($RemoveUser) from $($username) Shared Mailbox permission" -Sev 'Info' -tenant $TenantFilter - - # Sync cache - Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $RemoveUser -PermissionType 'FullAccess' -Action 'Remove' - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not remove mailbox permissions for $($removeuser) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not remove $($removeuser) shared mailbox permissions for $($username). Error: $($_.Exception.Message)") - } - } - $AddFullAccess = ($Request.body.AddFullAccess).value - - foreach ($UserAutomap in $AddFullAccess) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Add-MailboxPermission' -cmdParams @{Identity = $userid; user = $UserAutomap; accessRights = @('FullAccess'); automapping = $true } - $results.add( "Granted $($UserAutomap) access to $($username) Mailbox with automapping") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $($UserAutomap) access to $($username) Mailbox with automapping" -Sev 'Info' -tenant $TenantFilter - - # Sync cache - Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserAutomap -PermissionType 'FullAccess' -Action 'Add' - - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add mailbox permissions for $($UserAutomap) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add( "Could not add $($UserAutomap) shared mailbox permissions for $($username). Error: $($_.Exception.Message)") - } - } - $AddFullAccessNoAutoMap = ($Request.body.AddFullAccessNoAutoMap).value - - foreach ($UserNoAutomap in $AddFullAccessNoAutoMap) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Add-MailboxPermission' -cmdParams @{Identity = $userid; user = $UserNoAutomap; accessRights = @('FullAccess'); automapping = $false } - $results.add( "Granted $UserNoAutomap access to $($username) Mailbox without automapping") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $UserNoAutomap access to $($username) Mailbox without automapping" -Sev 'Info' -tenant $TenantFilter - - # Sync cache - Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserNoAutomap -PermissionType 'FullAccess' -Action 'Add' - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add mailbox permissions for $($UserNoAutomap) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not add $($UserNoAutomap) shared mailbox permissions for $($username). Error: $($_.Exception.Message)") - } - } - - $AddSendAS = ($Request.body.AddSendAs).value - - foreach ($UserSendAs in $AddSendAS) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Add-RecipientPermission' -cmdParams @{Identity = $userid; Trustee = $UserSendAs; accessRights = @('SendAs') } - $results.add( "Granted $UserSendAs access to $($username) with Send As permissions") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $UserSendAs access to $($username) with Send As permissions" -Sev 'Info' -tenant $TenantFilter - - # Sync cache - Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserSendAs -PermissionType 'SendAs' -Action 'Add' - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add mailbox permissions for $($UserSendAs) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not add $($UserSendAs) send-as permissions for $($username). Error: $($_.Exception.Message)") - } - } - - $RemoveSendAs = ($Request.body.RemoveSendAs).value - - foreach ($UserSendAs in $RemoveSendAs) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Remove-RecipientPermission' -cmdParams @{Identity = $userid; Trustee = $UserSendAs; accessRights = @('SendAs') } - $results.add( "Removed $UserSendAs from $($username) with Send As permissions") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Removed $UserSendAs from $($username) with Send As permissions" -Sev 'Info' -tenant $TenantFilter - - # Sync cache - Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserSendAs -PermissionType 'SendAs' -Action 'Remove' - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not remove mailbox permissions for $($UserSendAs) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not remove $($UserSendAs) send-as permissions for $($username). Error: $($_.Exception.Message)") - } - } - - $AddSendOnBehalf = ($Request.body.AddSendOnBehalf).value - - foreach ($UserSendOnBehalf in $AddSendOnBehalf) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Set-Mailbox' -cmdParams @{Identity = $userid; GrantSendonBehalfTo = @{'@odata.type' = '#Exchange.GenericHashTable'; add = $UserSendOnBehalf }; } - $results.add( "Granted $UserSendOnBehalf access to $($username) with Send On Behalf Permissions") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $UserSendOnBehalf access to $($username) with Send On Behalf Permissions" -Sev 'Info' -tenant $TenantFilter - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add send on behalf permissions for $($UserSendOnBehalf) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not add $($UserSendOnBehalf) send on behalf permissions for $($username). Error: $($_.Exception.Message)") - } - } - - $RemoveSendOnBehalf = ($Request.body.RemoveSendOnBehalf).value - - foreach ($UserSendOnBehalf in $RemoveSendOnBehalf) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Set-Mailbox' -cmdParams @{Identity = $userid; GrantSendonBehalfTo = @{'@odata.type' = '#Exchange.GenericHashTable'; remove = $UserSendOnBehalf }; } - $results.add( "Removed $UserSendOnBehalf from $($username) Send on Behalf Permissions") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Removed $UserSendOnBehalf from $($username) Send on Behalf Permissions" -Sev 'Info' -tenant $TenantFilter - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not Remove send on behalf permissions for $($UserSendOnBehalf) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not remove $($UserSendOnBehalf) send on behalf permissions for $($username). Error: $($_.Exception.Message)") + # Each request-body bucket maps to a (PermissionLevel, Action) pair. Delegate to + # Set-CIPPMailboxPermission so the EXO cmdlet mapping, logging, cache sync, and error handling + # all live in one place. The mailbox UPN is passed straight through as the identity - EXO accepts + # it, so no Graph id lookup is required. + $PermissionBuckets = @( + @{ Bucket = 'RemoveFullAccess'; PermissionLevel = 'FullAccess'; Action = 'Remove'; AutoMap = $true } + @{ Bucket = 'AddFullAccess'; PermissionLevel = 'FullAccess'; Action = 'Add'; AutoMap = $true } + @{ Bucket = 'AddFullAccessNoAutoMap'; PermissionLevel = 'FullAccess'; Action = 'Add'; AutoMap = $false } + @{ Bucket = 'AddSendAs'; PermissionLevel = 'SendAs'; Action = 'Add'; AutoMap = $true } + @{ Bucket = 'RemoveSendAs'; PermissionLevel = 'SendAs'; Action = 'Remove'; AutoMap = $true } + @{ Bucket = 'AddSendOnBehalf'; PermissionLevel = 'SendOnBehalf'; Action = 'Add'; AutoMap = $true } + @{ Bucket = 'RemoveSendOnBehalf'; PermissionLevel = 'SendOnBehalf'; Action = 'Remove'; AutoMap = $true } + ) + + foreach ($Bucket in $PermissionBuckets) { + foreach ($AccessUser in ($Request.body.($Bucket.Bucket)).value) { + $null = $Results.Add( + (Set-CIPPMailboxPermission -UserId $Username -AccessUser $AccessUser -PermissionLevel $Bucket.PermissionLevel -Action $Bucket.Action -AutoMap $Bucket.AutoMap -TenantFilter $Tenantfilter -APIName $APIName -Headers $Headers) + ) } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 index a97e3a4148255..3b1d822a3dfcc 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 @@ -1,4 +1,4 @@ -Function Invoke-ExecModifyMBPerms { +function Invoke-ExecModifyMBPerms { <# .FUNCTIONALITY Entrypoint @@ -9,41 +9,43 @@ Function Invoke-ExecModifyMBPerms { param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint - Write-LogMessage -headers $Request.Headers -API $APINAME -message 'Accessed this API' -Sev 'Debug' + $Headers = $Request.Headers + Write-LogMessage -headers $Headers -API $APIName -message 'Accessed this API' -Sev 'Debug' # Extract mailbox requests - handle all three formats $MailboxRequests = $null $Results = [System.Collections.ArrayList]::new() + $SuccessfulOps = [System.Collections.ArrayList]::new() # Direct array format - if ($request.body -is [array]) { - $MailboxRequests = $request.body + if ($Request.Body -is [array]) { + $MailboxRequests = $Request.Body } # Bulk format with mailboxRequests property - elseif ($request.body.mailboxRequests) { - $MailboxRequests = $request.body.mailboxRequests + elseif ($Request.Body.mailboxRequests) { + $MailboxRequests = $Request.Body.mailboxRequests } # Legacy single mailbox format - elseif ($request.body.userID -and $request.body.permissions) { + elseif ($Request.Body.userID -and $Request.Body.permissions) { $MailboxRequests = @([PSCustomObject]@{ - userID = $request.body.userID - tenantFilter = $request.body.tenantFilter - permissions = $request.body.permissions - }) + userID = $Request.Body.userID + tenantFilter = $Request.Body.tenantFilter + permissions = $Request.Body.permissions + }) } if (-not $MailboxRequests -or $MailboxRequests.Count -eq 0) { - Write-LogMessage -headers $Request.Headers -API $APINAME -message 'No mailbox requests provided' -Sev 'Error' - $body = [pscustomobject]@{'Results' = @("No mailbox requests provided") } + Write-LogMessage -headers $Headers -API $APIName -message 'No mailbox requests provided' -Sev 'Error' + $body = [pscustomobject]@{'Results' = @('No mailbox requests provided') } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::BadRequest - Body = $Body - }) + StatusCode = [HttpStatusCode]::BadRequest + Body = $Body + }) return } - $TenantFilter = $Request.body.tenantFilter - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Processing permission changes for $($MailboxRequests.Count) mailboxes" -Sev 'Info' -tenant $TenantFilter + $TenantFilter = $Request.Body.tenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Processing permission changes for $($MailboxRequests.Count) mailboxes" -Sev 'Info' -tenant $TenantFilter # Build cmdlet array for processing $CmdletArray = [System.Collections.ArrayList]::new() @@ -51,12 +53,16 @@ Function Invoke-ExecModifyMBPerms { $GuidToMetadataMap = @{} # Map GUIDs to our metadata $UserLookupCache = @{} + # Permission levels Set-CIPPMailboxPermission understands (its ValidateSet). Levels outside this + # set are silently skipped, matching the behaviour of the inline switch this used to carry. + $SupportedPermissionLevels = @('FullAccess', 'SendAs', 'SendOnBehalf', 'ReadPermission', 'ExternalAccount', 'DeleteItem', 'ChangePermission', 'ChangeOwner') + foreach ($MailboxRequest in $MailboxRequests) { $Username = $MailboxRequest.userID $Permissions = $MailboxRequest.permissions if ([string]::IsNullOrEmpty($Username)) { - $null = $Results.Add("Skipped mailbox with missing userID") + $null = $Results.Add('Skipped mailbox with missing userID') continue } @@ -65,18 +71,16 @@ Function Invoke-ExecModifyMBPerms { try { $UserObject = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($Username)" -tenantid $TenantFilter $UserLookupCache[$Username] = $UserObject.userPrincipalName - } - catch { + } catch { try { $UserObject = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=userPrincipalName eq '$Username'" -tenantid $TenantFilter if ($UserObject.value -and $UserObject.value.Count -gt 0) { $UserLookupCache[$Username] = $UserObject.value[0].userPrincipalName } else { - throw "User not found" + throw 'User not found' } - } - catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Could not find user $($Username)" -Sev 'Error' -tenant $TenantFilter + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Could not find user $($Username)" -Sev 'Error' -tenant $TenantFilter $null = $Results.Add("Could not find user $($Username)") continue } @@ -99,7 +103,7 @@ Function Invoke-ExecModifyMBPerms { $AutoMap = if ($Permission.PSObject.Properties.Name -contains 'AutoMap') { $Permission.AutoMap } else { $true } # Handle multiple permission levels - $PermissionLevelArray = if ($PermissionLevels -like "*,*") { + $PermissionLevelArray = if ($PermissionLevels -like '*,*') { $PermissionLevels -split ',' | ForEach-Object { $_.Trim() } } else { @($PermissionLevels.Trim()) @@ -125,160 +129,37 @@ Function Invoke-ExecModifyMBPerms { foreach ($TargetUser in $TargetUsers) { foreach ($PermissionLevel in $PermissionLevelArray) { - # Create cmdlet parameters based on permission type and action - $CmdletParams = @{} - $CmdletName = "" - $ExpectedResult = "" - - switch ($PermissionLevel) { - 'FullAccess' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('FullAccess') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) from $($Username) FullAccess permissions" - } else { - $CmdletName = 'Add-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('FullAccess') - automapping = $AutoMap - Confirm = $false - } - $ExpectedResult = "Granted $($TargetUser) FullAccess to $($Username) with automapping $($AutoMap)" - } - } - 'SendAs' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-RecipientPermission' - $CmdletParams = @{ - Identity = $UserId - Trustee = $TargetUser - accessRights = @('SendAs') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) SendAs permissions from $($Username)" - } else { - $CmdletName = 'Add-RecipientPermission' - $CmdletParams = @{ - Identity = $UserId - Trustee = $TargetUser - accessRights = @('SendAs') - Confirm = $false - } - $ExpectedResult = "Granted $($TargetUser) SendAs permissions to $($Username)" - } - } - 'SendOnBehalf' { - $CmdletName = 'Set-Mailbox' - if ($Modification -eq 'Remove') { - $CmdletParams = @{ - Identity = $UserId - GrantSendonBehalfTo = @{ - '@odata.type' = '#Exchange.GenericHashTable' - remove = $TargetUser - } - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) SendOnBehalf permissions from $($Username)" - } else { - $CmdletParams = @{ - Identity = $UserId - GrantSendonBehalfTo = @{ - '@odata.type' = '#Exchange.GenericHashTable' - add = $TargetUser - } - Confirm = $false - } - $ExpectedResult = "Granted $($TargetUser) SendOnBehalf permissions to $($Username)" - } - } - 'ReadPermission' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('ReadPermission') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) ReadPermission from $($Username)" - } - } - 'ExternalAccount' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('ExternalAccount') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) ExternalAccount permissions from $($Username)" - } - } - 'DeleteItem' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('DeleteItem') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) DeleteItem permissions from $($Username)" - } - } - 'ChangePermission' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('ChangePermission') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) ChangePermission from $($Username)" - } - } - 'ChangeOwner' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('ChangeOwner') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) ChangeOwner permissions from $($Username)" - } - } - } + # Build the EXO cmdlet for this change via Set-CIPPMailboxPermission's + # -AsCmdletObject mode - the single source of truth for the permission-level -> + # cmdlet/parameter mapping. It returns @{ CmdletName; Parameters; ExpectedResult } + # for supported combinations, or a plain string for unsupported ones (e.g. an Add + # on a remove-only level), which we skip. The bulk machinery below is unchanged. + if ($PermissionLevel -notin $SupportedPermissionLevels) { continue } + $Action = if ($Modification -eq 'Remove') { 'Remove' } else { 'Add' } + + $Mapping = Set-CIPPMailboxPermission -UserId $UserId -AccessUser $TargetUser -PermissionLevel $PermissionLevel -Action $Action -AutoMap $AutoMap -TenantFilter $TenantFilter -AsCmdletObject - if ($CmdletName) { + if ($Mapping -is [hashtable] -and $Mapping.CmdletName) { # Generate unique GUID for this operation $OperationGuid = [Guid]::NewGuid().ToString() $CmdletObj = @{ - CmdletInput = @{ - CmdletName = $CmdletName - Parameters = $CmdletParams + CmdletInput = @{ + CmdletName = $Mapping.CmdletName + Parameters = $Mapping.Parameters } OperationGuid = $OperationGuid # Add GUID to cmdlet object } + # Use the resolved UPN, not the raw request identifier (which may be an + # object id) - the cache sync below matches cached rows by mailbox UPN. $CmdletMetadata = [PSCustomObject]@{ - ExpectedResult = $ExpectedResult - Mailbox = $Username - TargetUser = $TargetUser - Permission = $PermissionLevel - Action = $Modification - OperationGuid = $OperationGuid + ExpectedResult = $Mapping.ExpectedResult + Mailbox = $UserId + TargetUser = $TargetUser + Permission = $PermissionLevel + Action = $Action + OperationGuid = $OperationGuid } $null = $CmdletArray.Add($CmdletObj) @@ -293,12 +174,12 @@ Function Invoke-ExecModifyMBPerms { } if ($CmdletArray.Count -eq 0) { - Write-LogMessage -headers $Request.Headers -API $APINAME -message 'No valid cmdlets to process' -sev 'Warning' -tenant $TenantFilter - $body = [pscustomobject]@{'Results' = @("No valid permission changes to process") } + Write-LogMessage -headers $Headers -API $APIName -message 'No valid cmdlets to process' -sev 'Warning' -tenant $TenantFilter + $body = [pscustomobject]@{'Results' = @('No valid permission changes to process') } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = $Body - }) + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) return } @@ -306,7 +187,7 @@ Function Invoke-ExecModifyMBPerms { if ($CmdletArray.Count -gt 1) { # Use bulk processing with GUID tracking try { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Executing bulk request with $($CmdletArray.Count) cmdlets" -Sev 'Info' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Executing bulk request with $($CmdletArray.Count) cmdlets" -Sev 'Info' -tenant $TenantFilter $BulkResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($CmdletArray) -ReturnWithCommand $true # Process bulk results using GUID mapping @@ -321,13 +202,14 @@ Function Invoke-ExecModifyMBPerms { if ($result.error) { $ErrorMessage = try { (Get-CippException -Exception $result.error).NormalizedError } catch { $result.error } $null = $Results.Add("Error processing $($metadata.Permission) for $($metadata.TargetUser) on $($metadata.Mailbox): $ErrorMessage") - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Error for operation $operationGuid`: $ErrorMessage" -Sev 'Error' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Error for operation $operationGuid`: $ErrorMessage" -Sev 'Error' -tenant $TenantFilter } else { $null = $Results.Add($metadata.ExpectedResult) - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Success for operation $operationGuid`: $($metadata.ExpectedResult)" -Sev 'Info' -tenant $TenantFilter + $null = $SuccessfulOps.Add($metadata) + Write-LogMessage -headers $Headers -API $APIName -message "Success for operation $operationGuid`: $($metadata.ExpectedResult)" -Sev 'Info' -tenant $TenantFilter } } else { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Could not map result to operation. GUID: $operationGuid, Available GUIDs: $($GuidToMetadataMap.Keys -join ', ')" -sev 'Warning' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Could not map result to operation. GUID: $operationGuid, Available GUIDs: $($GuidToMetadataMap.Keys -join ', ')" -sev 'Warning' -tenant $TenantFilter # Fallback for unmapped results if ($result.error) { @@ -344,14 +226,14 @@ Function Invoke-ExecModifyMBPerms { foreach ($CmdletMetadata in $CmdletMetadataArray) { if ($CmdletMetadata.ExpectedResult) { $null = $Results.Add($CmdletMetadata.ExpectedResult) + $null = $SuccessfulOps.Add($CmdletMetadata) } } } - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Bulk request completed successfully" -Sev 'Info' -tenant $TenantFilter - } - catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Bulk request failed, using fallback: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message 'Bulk request completed successfully' -Sev 'Info' -tenant $TenantFilter + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Bulk request failed, using fallback: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter # Fallback to individual processing for ($i = 0; $i -lt $CmdletArray.Count; $i++) { @@ -360,31 +242,43 @@ Function Invoke-ExecModifyMBPerms { try { $null = New-ExoRequest -Anchor $CmdletMetadata.Mailbox -tenantid $TenantFilter -cmdlet $CmdletObj.CmdletInput.CmdletName -cmdParams $CmdletObj.CmdletInput.Parameters $null = $Results.Add($CmdletMetadata.ExpectedResult) - } - catch { + $null = $SuccessfulOps.Add($CmdletMetadata) + } catch { $null = $Results.Add("Error processing $($CmdletMetadata.Permission) for $($CmdletMetadata.TargetUser) on $($CmdletMetadata.Mailbox): $($_.Exception.Message)") } } } - } - else { + } else { # Use individual processing for single operation $CmdletObj = $CmdletArray[0] $CmdletMetadata = $CmdletMetadataArray[0] try { $null = New-ExoRequest -Anchor $CmdletMetadata.Mailbox -tenantid $TenantFilter -cmdlet $CmdletObj.CmdletInput.CmdletName -cmdParams $CmdletObj.CmdletInput.Parameters $null = $Results.Add($CmdletMetadata.ExpectedResult) - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Executed $($CmdletMetadata.Permission) permission modification" -Sev 'Info' -tenant $TenantFilter - } - catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Permission modification failed: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter + $null = $SuccessfulOps.Add($CmdletMetadata) + Write-LogMessage -headers $Headers -API $APIName -message "Executed $($CmdletMetadata.Permission) permission modification" -Sev 'Info' -tenant $TenantFilter + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Permission modification failed: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter $null = $Results.Add("Error processing $($CmdletMetadata.Permission) for $($CmdletMetadata.TargetUser) on $($CmdletMetadata.Mailbox): $($_.Exception.Message)") } } + # Keep the reporting DB cache in step with what actually changed. The bulk path bypasses + # Set-CIPPMailboxPermission's own execute-mode sync (-AsCmdletObject returns early), so + # without this the cached permission report goes stale after every bulk change. + foreach ($Op in $SuccessfulOps) { + if ($Op.Permission -in @('FullAccess', 'SendAs', 'SendOnBehalf')) { + try { + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $Op.Mailbox -User $Op.TargetUser -PermissionType $Op.Permission -Action $Op.Action + } catch { + Write-Information "Cache sync warning: $($_.Exception.Message)" + } + } + } + $body = [pscustomobject]@{'Results' = @($Results) } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = $Body - }) + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListmailboxPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListmailboxPermissions.ps1 index 37468a9c1b209..18099cd59af71 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListmailboxPermissions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListmailboxPermissions.ps1 @@ -14,6 +14,7 @@ function Invoke-ListmailboxPermissions { $UserID = $Request.Query.userId $UseReportDB = $Request.Query.UseReportDB $ByUser = $Request.Query.ByUser + $User = $Request.Query.User try { # If UseReportDB is specified and no specific UserID, retrieve from report database @@ -28,6 +29,11 @@ function Invoke-ListmailboxPermissions { } try { $GraphRequest = Get-CIPPMailboxPermissionReport @ReportParams + if ($User -and $ByUser -eq 'true') { + # Only the user-grouped report has a top-level User property; in the + # mailbox-grouped shape this filter would empty the whole result set. + $GraphRequest = @($GraphRequest | Where-Object { $_.User -eq $User }) + } $StatusCode = [HttpStatusCode]::OK } catch { $StatusCode = [HttpStatusCode]::InternalServerError diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListSharedMailboxAccountEnabled.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListSharedMailboxAccountEnabled.ps1 index 6daf756987619..55d8f547ed001 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListSharedMailboxAccountEnabled.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListSharedMailboxAccountEnabled.ps1 @@ -6,15 +6,33 @@ function Invoke-ListSharedMailboxAccountEnabled { Exchange.Mailbox.Read .DESCRIPTION Lists shared mailboxes that have direct sign-in enabled (account not disabled), which is a security concern. + Supports UseReportDB=true to read cached data from the reporting database (required for AllTenants). #> [CmdletBinding()] param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint $TenantFilter = $Request.Query.tenantFilter + $UseReportDB = $Request.Query.UseReportDB # Get Shared Mailbox Stuff try { + # If UseReportDB is specified, retrieve from the report database (cached Mailboxes + Users join) + if ($UseReportDB -eq 'true') { + try { + $GraphRequest = Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $TenantFilter -ErrorAction Stop + $StatusCode = [HttpStatusCode]::OK + } catch { + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = $_.Exception.Message + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) + } + $SharedMailboxList = (New-GraphGetRequest -uri "https://outlook.office365.com/adminapi/beta/$($TenantFilter)/Mailbox?`$filter=RecipientTypeDetails eq 'SharedMailbox'" -Tenantid $TenantFilter -scope ExchangeOnline) $AllUsersInfo = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/users?$select=id,userPrincipalName,accountEnabled,displayName,givenName,surname,onPremisesSyncEnabled,assignedLicenses' -tenantid $TenantFilter $SharedMailboxDetails = foreach ($SharedMailbox in $SharedMailboxList) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 index 9ccfa72a5efb1..8c638985853e1 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 @@ -14,6 +14,7 @@ Function Invoke-EditRoomMailbox { $Results = [System.Collections.Generic.List[Object]]::new() $MailboxObject = $Request.Body + $DefaultCalendarPermission = $MailboxObject.DefaultCalendarPermission.value ?? $MailboxObject.DefaultCalendarPermission # First update the mailbox properties $UpdateMailboxParams = @{ @@ -59,8 +60,9 @@ Function Invoke-EditRoomMailbox { $CalendarProperties = @( 'AllowConflicts', 'AllowRecurringMeetings', 'BookingWindowInDays', 'MaximumDurationInMinutes', 'ProcessExternalMeetingMessages', 'EnforceCapacity', - 'ForwardRequestsToDelegates', 'ScheduleOnlyDuringWorkHours ', 'AutomateProcessing', - 'AddOrganizerToSubject', 'DeleteSubject', 'RemoveCanceledMeetings' + 'ForwardRequestsToDelegates', 'ScheduleOnlyDuringWorkHours', 'AutomateProcessing', + 'AddOrganizerToSubject', 'DeleteComments', 'DeleteSubject', 'RemovePrivateProperty', + 'RemoveCanceledMeetings', 'RemoveOldMeetingMessages' ) foreach ($prop in $CalendarProperties) { @@ -98,6 +100,30 @@ Function Invoke-EditRoomMailbox { $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-MailboxCalendarConfiguration' -cmdParams $UpdateCalendarConfigParams -Anchor $MailboxObject.roomId $Results.Add("Successfully updated room: $($MailboxObject.DisplayName) (Calendar Configuration)") + if (![string]::IsNullOrWhiteSpace($DefaultCalendarPermission)) { + $CalendarFolder = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-MailboxFolderStatistics' -cmdParams @{ + Identity = $MailboxObject.roomId + FolderScope = 'Calendar' + } -Anchor $MailboxObject.roomId | Where-Object { $_.FolderType -eq 'Calendar' } | Select-Object -First 1 -ExcludeProperty *@odata.type* + + $CalendarFolderIdentity = if ($CalendarFolder -and $CalendarFolder.FolderId) { + "$($MailboxObject.roomId):$($CalendarFolder.FolderId)" + } elseif ($CalendarFolder -and $CalendarFolder.Name) { + "$($MailboxObject.roomId):\$($CalendarFolder.Name)" + } else { + "$($MailboxObject.roomId):\Calendar" + } + + $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-MailboxFolderPermission' -cmdParams @{ + Identity = $CalendarFolderIdentity + User = 'Default' + AccessRights = $DefaultCalendarPermission + } -Anchor $MailboxObject.roomId + + Sync-CIPPCalendarPermissionCache -TenantFilter $Tenant -MailboxIdentity $MailboxObject.roomId -FolderName 'Calendar' -User 'Default' -Permissions $DefaultCalendarPermission -Action 'Add' + $Results.Add("Successfully updated room: $($MailboxObject.DisplayName) (Default Calendar Permission)") + } + Write-LogMessage -headers $Request.Headers -API $APIName -tenant $Tenant -message "Updated room $($MailboxObject.DisplayName)" -Sev 'Info' $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 index 127b5d4006fc1..b7ee98fb8848e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 @@ -30,6 +30,32 @@ function Invoke-ListRooms { # Get-MailboxCalendarConfiguration requires anchor to the room mailbox $CalendarConfigurationProperties = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxCalendarConfiguration' -cmdParams @{ Identity = $RoomId } -Anchor $RoomId | Select-Object -ExcludeProperty *@odata.type* + $DefaultCalendarPermission = $null + + try { + $CalendarFolder = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxFolderStatistics' -cmdParams @{ + Identity = $RoomId + FolderScope = 'Calendar' + } -Anchor $RoomId | Where-Object { $_.FolderType -eq 'Calendar' } | Select-Object -First 1 -ExcludeProperty *@odata.type* + + if ($CalendarFolder) { + $CalendarFolderIdentity = if ($CalendarFolder.FolderId) { "$($RoomId):$($CalendarFolder.FolderId)" } else { "$($RoomId):\$($CalendarFolder.Name)" } + $CalendarFolderPermissions = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxFolderPermission' -cmdParams @{ + Identity = $CalendarFolderIdentity + } -Anchor $RoomId -UseSystemMailbox $true | Select-Object -ExcludeProperty *@odata.type* + $DefaultCalendarPermissionEntry = $CalendarFolderPermissions | Where-Object { $_.User -eq 'Default' } | Select-Object -First 1 + + if ($DefaultCalendarPermissionEntry) { + $DefaultCalendarPermission = if ($DefaultCalendarPermissionEntry.AccessRights -is [array]) { + $DefaultCalendarPermissionEntry.AccessRights -join ',' + } else { + $DefaultCalendarPermissionEntry.AccessRights + } + } + } + } catch { + Write-Warning "Could not retrieve default calendar permission for room $RoomId. $($_.Exception.Message)" + } if ($RoomMailbox -and $PlaceDetails -and $CalendarProperties -and $CalendarConfigurationProperties) { $GraphRequest = @( @@ -81,8 +107,12 @@ function Invoke-ListRooms { ScheduleOnlyDuringWorkHours = $CalendarProperties.ScheduleOnlyDuringWorkHours AutomateProcessing = $CalendarProperties.AutomateProcessing AddOrganizerToSubject = $CalendarProperties.AddOrganizerToSubject + DeleteComments = $CalendarProperties.DeleteComments DeleteSubject = $CalendarProperties.DeleteSubject + RemovePrivateProperty = $CalendarProperties.RemovePrivateProperty RemoveCanceledMeetings = $CalendarProperties.RemoveCanceledMeetings + RemoveOldMeetingMessages = $CalendarProperties.RemoveOldMeetingMessages + DefaultCalendarPermission = $DefaultCalendarPermission # Calendar Configuration Properties WorkDays = if ([string]::IsNullOrWhiteSpace($CalendarConfigurationProperties.WorkDays)) { $null } else { $CalendarConfigurationProperties.WorkDays } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 index c01f5d98d6d32..f998de70ff36e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 @@ -1,7 +1,7 @@ Function Invoke-AddSpamFilter { <# .FUNCTIONALITY - Entrypoint + Entrypoint,AnyTenant .ROLE Exchange.SpamFilter.ReadWrite #> diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 index 2c703956fad93..cf34067491c1c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 @@ -34,57 +34,15 @@ function Invoke-AddMSPApp { $SuccessCount = 0 $ErrorCount = 0 $Results = foreach ($Tenant in $Tenants) { - $InstallParams = [PSCustomObject]$RMMApp.params - switch ($RmmName) { - 'datto' { - Write-Host 'Processing Datto installation' - $DattoUrl = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.DattoURL) - $DattoGuid = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.DattoGUID."$($Tenant.customerId)") - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -URL $DattoUrl -GUID $DattoGuid" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' - } - 'ninja' { - Write-Host 'Processing Ninja installation' - $NinjaPackage = ConvertTo-CIPPSafePwshArg -Value ([string]$RMMApp.PackageName) - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -InstallParam $NinjaPackage" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' - } - 'Huntress' { - $HuntressOrgKey = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.Orgkey."$($Tenant.customerId)") - $HuntressAccountKey = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.AccountKey) - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -OrgKey $HuntressOrgKey -acctkey $HuntressAccountKey" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Uninstall' - } - 'syncro' { - $SyncroUrl = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.ClientURL."$($Tenant.customerId)") - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -URL $SyncroUrl" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' - } - 'NCentral' { - $NCentralPackage = ConvertTo-CIPPSafePwshArg -Value ([string]$RMMApp.PackageName) - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -InstallParam $NCentralPackage" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' - } - 'automate' { - $AutomateServer = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.Server) - $AutomateInstallerToken = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.InstallerToken."$($Tenant.customerId)") - $AutomateLocationId = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.LocationID."$($Tenant.customerId)") - $installCommandLine = "c:\windows\sysnative\windowspowershell\v1.0\powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Server $AutomateServer -InstallerToken $AutomateInstallerToken -LocationID $AutomateLocationId" - $uninstallCommandLine = "c:\windows\sysnative\windowspowershell\v1.0\powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1 -Server $AutomateServer" - $DetectionScript = (Get-Content 'AddMSPApp\automate.detection.ps1' -Raw) -replace '##SERVER##', $InstallParams.Server - $intuneBody.detectionRules[0].scriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($DetectionScript)) - } - 'cwcommand' { - $CwClientUrl = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.ClientURL."$($Tenant.customerId)") - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Url $CwClientUrl" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' - } - default { - throw "Unknown MSP app type '$RmmName'" - } + # Build the install/uninstall command lines for this tenant. Get-CIPPMSPAppInstallCommand + # resolves each param whether it is a per-tenant keyed value (interactive deploy) or a + # flat value / %CIPP variable% (Application Template deploy). + $CommandResult = Get-CIPPMSPAppInstallCommand -RmmName $RmmName -Params $RMMApp.params -Tenant $Tenant -PackageName $RMMApp.PackageName + $intuneBody.installCommandLine = $CommandResult.InstallCommandLine + $intuneBody.UninstallCommandLine = $CommandResult.UninstallCommandLine + if ($CommandResult.DetectionScriptContent) { + $intuneBody.detectionRules[0].scriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CommandResult.DetectionScriptContent)) } - $intuneBody.installCommandLine = $installCommandLine - $intuneBody.UninstallCommandLine = $uninstallCommandLine try { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 index 84b12b647b16a..1f6e1fcb6edef 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 @@ -24,6 +24,9 @@ function Invoke-ExecAssignApp { $AssignmentFilterName = $Request.Body.AssignmentFilterName $AssignmentFilterType = $Request.Body.AssignmentFilterType $ExcludeGroup = $Request.Body.excludeGroup + $ExcludeGroupIdsRaw = $Request.Body.ExcludeGroupIds + $ExcludeGroupNamesRaw = $Request.Body.ExcludeGroupNames + $AssignmentDirection = $Request.Body.assignmentDirection $Intent = if ([string]::IsNullOrWhiteSpace($Intent)) { 'Required' } else { $Intent } @@ -36,6 +39,17 @@ function Invoke-ExecAssignApp { } } + # assignmentDirection is sent only by the Custom Group action and switches that request to + # direction-scoped Replace (preserve the other direction + All Users/All Devices broad targets). + if (-not [string]::IsNullOrWhiteSpace($AssignmentDirection)) { + $AssignmentDirection = $AssignmentDirection.ToLower() + if ($AssignmentDirection -notin @('include', 'exclude')) { + $AssignmentDirection = $null + } + } else { + $AssignmentDirection = $null + } + function Get-StandardizedAssignmentList { param($InputObject) @@ -53,9 +67,25 @@ function Invoke-ExecAssignApp { $GroupNames = Get-StandardizedAssignmentList -InputObject $GroupNamesRaw $GroupIds = Get-StandardizedAssignmentList -InputObject $GroupIdsRaw + $ExcludeGroupIds = Get-StandardizedAssignmentList -InputObject $ExcludeGroupIdsRaw + $ExcludeGroupNames = Get-StandardizedAssignmentList -InputObject $ExcludeGroupNamesRaw + + # 'Clear all exclusions' is a Custom Group Exclude + Replace request with no groups selected. + $IsClearExclusions = ($AssignmentDirection -eq 'exclude') -and ($AssignmentMode -eq 'replace') + + if (-not $AssignTo -and $GroupIds.Count -eq 0 -and $GroupNames.Count -eq 0 -and $ExcludeGroupIds.Count -eq 0 -and [string]::IsNullOrWhiteSpace($ExcludeGroup) -and -not $IsClearExclusions) { + throw 'No assignment target provided. Supply AssignTo, GroupNames, GroupIds, or an exclude group.' + } - if (-not $AssignTo -and $GroupIds.Count -eq 0 -and $GroupNames.Count -eq 0) { - throw 'No assignment target provided. Supply AssignTo, GroupNames, or GroupIds.' + # Safety net for legacy/API callers (no assignmentDirection): an exclude-only request in + # 'replace' mode would post just the exclusion target and wipe every existing assignment, so + # force preserve. The Custom Group action sends assignmentDirection and uses direction-scoped + # Replace instead, so it is exempt. + $IsExcludeOnly = (-not $AssignTo -and $GroupIds.Count -eq 0 -and $GroupNames.Count -eq 0) -and + ($ExcludeGroupIds.Count -gt 0 -or -not [string]::IsNullOrWhiteSpace($ExcludeGroup)) + if ($IsExcludeOnly -and $AssignmentMode -eq 'replace' -and -not $AssignmentDirection) { + Write-Information 'Exclude-only assignment requested; forcing append mode to preserve existing assignments.' + $AssignmentMode = 'append' } # Try to get the application type if not provided. Mostly just useful for ppl using the API that dont know the application type. @@ -78,7 +108,8 @@ function Invoke-ExecAssignApp { } elseif ($GroupIds.Count -gt 0) { "GroupIds: $($GroupIds -join ',')" } else { - 'CustomGroupAssignment' + # Exclude-only assignment: no include target, so the helper must not try to resolve one + $null } $setParams = @{ @@ -110,6 +141,18 @@ function Invoke-ExecAssignApp { $setParams.ExcludeGroup = $ExcludeGroup } + if ($ExcludeGroupIds.Count -gt 0) { + $setParams.ExcludeGroupIds = $ExcludeGroupIds + } + + if ($ExcludeGroupNames.Count -gt 0) { + $setParams.ExcludeGroupNames = $ExcludeGroupNames + } + + if ($AssignmentDirection) { + $setParams.AssignmentDirection = $AssignmentDirection + } + try { $Result = Set-CIPPAssignedApplication @setParams $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 index f2d0dc06d28cb..9a401a15a400f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 @@ -67,7 +67,7 @@ function Invoke-ListApps { } '#microsoft.graph.exclusionGroupAssignmentTarget' { $groupName = ($Groups | Where-Object { $_.id -eq $target.groupId }).displayName - if ($groupName) { $AppExclude.Add($groupName) } + if ($groupName) { $AppExclude.Add("$groupName$intentSuffix") } } } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAPDevice.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAPDevice.ps1 index 56dc7c0bda121..ee7a42396de3a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAPDevice.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAPDevice.ps1 @@ -47,27 +47,54 @@ function Invoke-AddAPDevice { $Amount++ Start-Sleep 1 $NewStatus = New-GraphGetRequest -uri "https://api.partnercenter.microsoft.com/v1/$($GraphRequest.Location)" -scope 'https://api.partnercenter.microsoft.com/user_impersonation' - } until ($NewStatus.status -eq 'finished' -or $Amount -eq 4) - if ($NewStatus.status -ne 'finished') { throw 'Could not retrieve status of import - This job might still be running. Check the autopilot device list in 10 minutes for the latest status.' } - Write-LogMessage -headers $Request.Headers -API $APIName -tenant $($Request.body.TenantFilter.value) -message "Created Autopilot devices group. Group ID is $GroupName" -Sev 'Info' + } until ($NewStatus.status -in @('finished', 'finished_with_errors') -or $Amount -eq 4) + if ($NewStatus.status -notin @('finished', 'finished_with_errors')) { throw 'Could not retrieve status of import - This job might still be running. Check the autopilot device list in 10 minutes for the latest status.' } + Write-LogMessage -headers $Headers -API $APIName -tenant $($Request.body.TenantFilter.value) -message "Created Autopilot devices group. Group ID is $GroupName" -Sev 'Info' - [PSCustomObject]@{ - Status = 'Import Job Completed' - Devices = @($NewStatus.devicesStatus) + # DEBUG: dump the raw status so we can inspect what Partner Center returns per device. + Write-Host "RAW NewStatus: $($NewStatus | ConvertTo-Json -Depth 10)" + + # Build one result per device (DeviceUploadDetails) so the frontend renders a + # single bar each, instead of flattening raw device fields into many stray bars. + $Index = 0 + $DeviceResults = foreach ($Device in @($NewStatus.devicesStatus)) { + $Index++ + # Hash-only uploads return no serial/productKey/deviceId; fall back to a number. + $DeviceId = $Device.serialNumber ?? $Device.productKey ?? $Device.deviceId + $Label = $DeviceId ?? "Device $Index" + $IsError = $Device.status -match 'error' + $Text = "$($Label): $($Device.status)" + if ($IsError -and $Device.errorDescription) { + $Text += " - $($Device.errorCode) $($Device.errorDescription)" + } + # Log each device with the input data that was submitted for it (matched by position). + $InputDevice = @($rawDevices)[$Index - 1] + Write-LogMessage -headers $Headers -API $APIName -tenant $($Request.Body.TenantFilter.value) -message "Autopilot import - $Text" -Sev $(if ($IsError) { 'Error' } else { 'Info' }) -LogData $InputDevice + [PSCustomObject]@{ + resultText = $Text + state = if ($IsError) { 'error' } else { 'success' } + copyField = $DeviceId + details = $Device + } + } + if (-not $DeviceResults) { + $DeviceResults = [PSCustomObject]@{ resultText = "Import job '$($NewStatus.status)' for group $GroupName"; state = 'success' } } $StatusCode = [HttpStatusCode]::OK + # Emit as the try block's value so the outer `$Result = try {...}` captures it. + $DeviceResults } catch { $ErrorMessage = Get-CippException -Exception $_ $StatusCode = [HttpStatusCode]::InternalServerError [PSCustomObject]@{ - Status = "$($Request.Body.TenantFilter.value): Failed to create autopilot devices. $($ErrorMessage.NormalizedError)" - Devices = @() + resultText = "$($Request.Body.TenantFilter.value): Failed to create autopilot devices. $($ErrorMessage.NormalizedError)" + state = 'error' } Write-LogMessage -headers $Headers -API $APIName -tenant $($Request.Body.TenantFilter.value) -message "Failed to create autopilot devices. $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage } return ([HttpResponseContext]@{ StatusCode = $StatusCode - Body = @{'Results' = $Result } + Body = @{'Results' = @($Result) } }) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 new file mode 100644 index 0000000000000..060ac28b2d690 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 @@ -0,0 +1,113 @@ +function Invoke-ExecAddCippCveException { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.Alert.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + try { + $CveId = [string]$Request.Body.cveId + $ExceptionType = [string]$Request.Body.exceptionType + $ApplyTo = [string]$Request.Body.applyTo + $Justification = [string]$Request.Body.justification + $ExpiryDate = if ($Request.Body.expiryDate) { [string]$Request.Body.expiryDate } else { $null } + + if (-not $CveId -or -not $ExceptionType -or -not $ApplyTo -or -not $Justification) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: cveId, exceptionType, applyTo, and justification are required' } + } + } + + $CveExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' + $CveCacheTable = Get-CIPPTable -TableName 'CveCache' + + # Load all existing exceptions for this CVE + $AllCveExceptions = Get-CIPPAzDataTableEntity @CveExceptionsTable -Filter "PartitionKey eq '$CveId'" + + $TenantsToUpdate = switch ($ApplyTo) { + 'CurrentTenant' { + if (-not $TenantFilter -or $TenantFilter -eq 'AllTenants') { + throw "Current tenant must be selected to use 'Current Tenant Only' option" + } + @($TenantFilter) + } + 'AllAffected' { + $RawCveData = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'DefenderCVEs' | Where-Object { $_.RowKey -ne 'DefenderCVEs-Count' } + $AffectedEntries = $RawCveData.Data | ConvertFrom-Json | -Filter "PartitionKey eq '$CveId'" + @($AffectedEntries | Select-Object -ExpandProperty customerId -Unique) + } + 'Global' { + @('ALL') + } + default { + throw "Invalid applyTo value: $ApplyTo" + } + } + + $Username = $Headers.'x-ms-client-principal-name' + $CurrentDate = (Get-Date).ToUniversalTime().ToString('o') + $ReadableDate = (Get-Date).ToString() + + $ExceptionsAdded = [System.Collections.Generic.List[string]]::new() + $ExceptionsUpdated = [System.Collections.Generic.List[string]]::new() + + # Build all exception entities in memory, track add vs update + $ExceptionEntities = foreach ($TenantId in $TenantsToUpdate) { + $ExistingException = $AllCveExceptions | Where-Object { $_.RowKey -eq $TenantId } + + if ($ExistingException) { + [void]$ExceptionsUpdated.Add($TenantId) + } else { + [void]$ExceptionsAdded.Add($TenantId) + } + + @{ + PartitionKey = [string]$CveId + RowKey = [string]$TenantId + cveId = [string]$CveId + customerId = [string]$TenantId + exceptionType = [string]$ExceptionType + exceptionComment = [string]$Justification + exceptionCreatedBy = [string]$Username + exceptionCreatedDate = [string]$CurrentDate + exceptionReadableDate = [string]$ReadableDate + exceptionExpiry = $ExpiryDate ?? '' + source = 'CIPP' + } + } + + # Write all exception entities in one batch + if (@($ExceptionEntities).Count -gt 0) { + Add-CIPPAzDataTableEntity @CveExceptionsTable -Entity @($ExceptionEntities) -Force + } + + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Added/updated CVE exception for $CveId across $($TenantsToUpdate.Count) tenant(s)" -sev 'Info' + + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ + Results = "Successfully applied exception to CVE $CveId" + TenantsAffected = $TenantsToUpdate.Count + ExceptionsAdded = $ExceptionsAdded.Count + ExceptionsUpdated = $ExceptionsUpdated.Count + Details = "Added: $($ExceptionsAdded -join ', '), Updated: $($ExceptionsUpdated -join ', ')" + } + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Failed to add CVE exception: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ Results = "Failed to add exception: $($ErrorMessage.NormalizedError)" } + } + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 index ce22cb30d03f5..722a5f37cdc04 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 @@ -18,9 +18,12 @@ function Invoke-ExecAssignPolicy { $AssignTo = $Request.Body.AssignTo $PlatformType = $Request.Body.platformType $ExcludeGroup = $Request.Body.excludeGroup + $ExcludeGroupIdsRaw = $Request.Body.ExcludeGroupIds + $ExcludeGroupNamesRaw = $Request.Body.ExcludeGroupNames $GroupIdsRaw = $Request.Body.GroupIds $GroupNamesRaw = $Request.Body.GroupNames $AssignmentMode = $Request.Body.assignmentMode + $AssignmentDirection = $Request.Body.assignmentDirection $AssignmentFilterName = $Request.Body.AssignmentFilterName $AssignmentFilterType = $Request.Body.AssignmentFilterType @@ -41,6 +44,8 @@ function Invoke-ExecAssignPolicy { $GroupIds = Get-StandardizedList -InputObject $GroupIdsRaw $GroupNames = Get-StandardizedList -InputObject $GroupNamesRaw + $ExcludeGroupIds = Get-StandardizedList -InputObject $ExcludeGroupIdsRaw + $ExcludeGroupNames = Get-StandardizedList -InputObject $ExcludeGroupNamesRaw # Validate and default AssignmentMode if ([string]::IsNullOrWhiteSpace($AssignmentMode)) { @@ -49,8 +54,31 @@ function Invoke-ExecAssignPolicy { $AssignTo = if ($AssignTo -ne 'on') { $AssignTo } + # assignmentDirection is sent only by the Custom Group action and switches that request to + # direction-scoped Replace (preserve the other direction + All Users/All Devices broad targets). + if (-not [string]::IsNullOrWhiteSpace($AssignmentDirection)) { + $AssignmentDirection = $AssignmentDirection.ToLower() + if ($AssignmentDirection -notin @('include', 'exclude')) { + $AssignmentDirection = $null + } + } else { + $AssignmentDirection = $null + } + + # 'Clear all exclusions' is a Custom Group Exclude + Replace request with no groups selected. + $IsClearExclusions = ($AssignmentDirection -eq 'exclude') -and ($AssignmentMode -eq 'replace') + + # Safety net for legacy/API callers (no assignmentDirection): an exclude-only request in + # 'replace' mode would post just the exclusion target and wipe every existing assignment. The + # Custom Group action sends assignmentDirection and uses direction-scoped Replace instead. + $IsExcludeOnly = (-not $AssignTo -and @($GroupIds).Count -eq 0 -and @($GroupNames).Count -eq 0) -and + (@($ExcludeGroupIds).Count -gt 0 -or -not [string]::IsNullOrWhiteSpace($ExcludeGroup)) + if ($IsExcludeOnly -and $AssignmentMode -eq 'replace' -and -not $AssignmentDirection) { + $AssignmentMode = 'append' + } + $Results = try { - if ($AssignTo -or @($GroupIds).Count -gt 0) { + if ($AssignTo -or @($GroupIds).Count -gt 0 -or @($ExcludeGroupIds).Count -gt 0 -or -not [string]::IsNullOrWhiteSpace($ExcludeGroup) -or $IsClearExclusions) { $params = @{ PolicyId = $ID TenantFilter = $TenantFilter @@ -76,6 +104,18 @@ function Invoke-ExecAssignPolicy { $params.ExcludeGroup = $ExcludeGroup } + if (@($ExcludeGroupIds).Count -gt 0) { + $params.ExcludeGroupIds = @($ExcludeGroupIds) + } + + if (@($ExcludeGroupNames).Count -gt 0) { + $params.ExcludeGroupNames = @($ExcludeGroupNames) + } + + if ($AssignmentDirection) { + $params.AssignmentDirection = $AssignmentDirection + } + if (-not [string]::IsNullOrWhiteSpace($AssignmentFilterName)) { $params.AssignmentFilterName = $AssignmentFilterName } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 index 26f924e6e1958..a74d26c71b0a5 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 @@ -21,6 +21,7 @@ function Invoke-ExecCompareIntunePolicy { 'WindowsFeatureUpdateProfiles' = 'windowsFeatureUpdateProfiles' 'windowsQualityUpdatePolicies' = 'windowsQualityUpdatePolicies' 'windowsQualityUpdateProfiles' = 'windowsQualityUpdateProfiles' + 'Intents' = 'Intents' } try { @@ -125,6 +126,7 @@ function Invoke-ExecCompareIntunePolicy { '*configurationPolicies*' { 'Catalog' } '*managedAppPolicies*' { 'AppProtection' } '*deviceAppManagement*' { 'AppProtection' } + '*intents*' { 'Intents' } default { 'Unknown' } } $PolicyObj = $ParsedJson diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 index 76de8b21de39c..8b3123ea19d14 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 @@ -38,6 +38,15 @@ function Invoke-ExecDeviceAction { Write-Host "ActionBody: $ActionBody" break } + 'createDeviceLogCollectionRequest' { + $ActionBody = @{ + templateType = @{ + '@odata.type' = '#microsoft.graph.deviceLogCollectionRequest' + templateType = 'predefined' + } + } | ConvertTo-Json -Compress -Depth 5 + break + } default { $ActionBody = $Request.Body | ConvertTo-Json -Compress } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecRemoveCippCveException.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecRemoveCippCveException.ps1 new file mode 100644 index 0000000000000..a263d81176ac0 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecRemoveCippCveException.ps1 @@ -0,0 +1,77 @@ +function Invoke-ExecRemoveCippCveException { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.Alert.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + try { + $CveId = $Request.Query.cveId ?? $Request.Body.cveId + $RemoveScope = $Request.Query.removeScope ?? $Request.Body.removeScope + + if (-not $CveId) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: cveId is required' } + } + } + + $CveExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' + + # Load all exceptions for this CVE + $AllCveExceptions = Get-CIPPAzDataTableEntity @CveExceptionsTable -Filter "PartitionKey eq '$CveId'" + + $ExceptionsToRemove = switch ($RemoveScope) { + 'CurrentTenant' { + if (-not $TenantFilter -or $TenantFilter -eq 'AllTenants') { + throw 'Current tenant must be selected' + } + @($TenantFilter) + } + 'AllAffected' { + @($AllCveExceptions | Where-Object { $_.RowKey -ne 'ALL' } | Select-Object -ExpandProperty RowKey) + } + 'Global' { + @('ALL') + } + default { + if ($TenantFilter -and $TenantFilter -ne 'AllTenants') { + @($TenantFilter) + } else { + throw 'removeScope must be specified when no tenant is selected' + } + } + } + + # Remove matched exception entities + $EntitiesToRemove = $AllCveExceptions | Where-Object { $_.RowKey -in $ExceptionsToRemove } + $RemovedCount = 0 + + foreach ($Entity in $EntitiesToRemove) { + Remove-AzDataTableEntity @CveExceptionsTable -Entity $Entity -Force + $RemovedCount++ + } + + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Removed $RemovedCount CVE exception(s) for $CveId" -sev 'Info' + + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ Results = "Successfully removed $RemovedCount exception(s) for CVE $CveId" } + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Failed to remove CVE exception: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ Results = "Failed to remove exception: $($ErrorMessage.NormalizedError)" } + } + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 new file mode 100644 index 0000000000000..f563471d2d3ec --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 @@ -0,0 +1,187 @@ +function Invoke-ListCVEManagement { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Endpoint.Security.Read + #> + + [CmdletBinding()] + param($Request, $TriggerMetadata) + # Interact with query parameters or the body of the request. + $TenantFilter = $Request.Query.tenantFilter + $UseReportDB = $Request.Query.UseReportDB + + if ($UseReportDB -eq 'true') { + try { + $GraphRequest = Get-CIPPCVEReport -TenantFilter $TenantFilter -ErrorAction Stop + $StatusCode = [HttpStatusCode]::OK + $SortedCves = $GraphRequest + Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message 'running cve report' -sev 'info' + } catch { + Write-Host "Error retrieving CVEs from report database: $($_.Exception.Message)" + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = $_.Exception.Message + Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message 'Error retrieving' -sev 'info' + } + } else { + try { + Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message 'retrieving CVEs' -sev 'info' + $GraphRequest = get-DefenderCVEs -TenantFilter $TenantFilter + + # Retrieve Exceptions from Exception database + $CveExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' + $AllExceptions = Get-CIPPAzDataTableEntity @CveExceptionsTable + $ExceptionsByCve = @{} + + # Retrieve CVEs from database + $RawCveItems = $GraphRequest + $AllCachedCves = $RawCveData + + $TenantList = Get-Tenants | Where-Object defaultDomainName -EQ $TenantFilter + + if ($RawCveItems.Count -eq 0) { + return @() + } + + foreach ($Ex in $AllExceptions) { + if ($TenantList.defaultDomainName -contains $Ex.customerId -or $Ex.customerId -eq 'ALL') { + if (-not $ExceptionsByCve.ContainsKey($Ex.cveId)) { + $ExceptionsByCve[$Ex.cveId] = [System.Collections.Generic.List[object]]::new() + } + + [void]$ExceptionsByCve[$Ex.cveId].Add([PSCustomObject]@{ + cveId = $Ex.cveId + customerId = $Ex.customerId + exceptionType = $Ex.exceptionType + exceptionSource = $Ex.exceptionSource + exceptionComment = $Ex.exceptionComment + exceptionCreatedBy = $Ex.exceptionCreatedBy + exceptionDate = $Ex.exceptionReadableDate + exceptionExpiry = $Ex.exceptionExpiry + }) + } + } + + # Merge all results + $CveMasterTable = @{} + + foreach ($Item in $RawCveItems) { + $CveId = $Item.PartitionKey + + if (-not $CveMasterTable.ContainsKey($CveId)) { + $CveMasterTable[$CveId] = @{ + cveId = $CveId + vulnerabilitySeverityLevel = $Item.vulnerabilitySeverityLevel + exploitabilityLevel = $Item.exploitabilityLevel + softwareName = $Item.softwareName + softwareVendor = $Item.softwareVendor + softwareVersion = $Item.softwareVersion + lastUpdated = $Item.lastUpdated + TotalDeviceCount = 0 + AffectedTenantsList = [System.Collections.Generic.List[object]]::new() + AffectedDevicesList = [System.Collections.Generic.List[object]]::new() + DiskPathList = [System.Collections.Generic.List[object]]::new() + RegistryPathList = [System.Collections.Generic.List[object]]::new() + ExceptionMatchCount = 0 + TotalTenantGroupCount = 0 + ExceptionSources = [System.Collections.Generic.HashSet[string]]::new() + } + } + + $CveGroup = $CveMasterTable[$CveId] + $CveGroup.TotalTenantGroupCount++ + + [void]$CveGroup.AffectedTenantsList.Add(@{ customerId = $Item.customerId }) + + # Unpack the device JSON details from the row + if ($Item.deviceDetailsJson) { + $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique + foreach ($Dev in $Devices) { + [void]$CveGroup.AffectedDevicesList.Add(@{ deviceName = $Dev.deviceName }) + if ($Dev.registryPaths) { + [void]$CveGroup.RegistryPathList.Add(@{ deviceName = $Dev.deviceName + registryPaths = $Dev.registryPaths + }) + } + if ($Dev.diskPaths) { + [void]$CveGroup.DiskPathList.Add(@{ deviceName = $Dev.deviceName + diskPaths = $Dev.diskPaths + }) + } + $CveGroup.TotalDeviceCount ++ + } + } + } + + # Combine filtered results + $SortedCves = [System.Collections.Generic.List[PSCustomObject]]::new() + + foreach ($CveKey in $CveMasterTable.Keys) { + $Target = $CveMasterTable[$CveKey] + $ExceptionStatus = 'None' + $HasException = $false + $Exceptions = @{} + $ExceptionType = '' + $ExceptionComment = '' + $ExceptionCreatedBy = '' + $ExceptionDate = '' + $ExceptionExpiry = '' + + if ($ExceptionsByCve.ContainsKey($CveKey)) { + $Exceptions = @($ExceptionsByCve[$CveKey]) + $HasException = $true + $ExceptionStatus = if ($Exceptions.customerId -contains 'ALL') { 'All' } else { 'Partial' } + $ExceptionType = @{ customerId = $Exceptions.customerId + exceptionType = $Exceptions.exceptionType + } + $ExceptionComment = @{ customerId = $Exceptions.customerId + exceptionComment = $Exceptions.exceptionComment + } + $ExceptionCreatedBy = @{ customerId = $Exceptions.customerId + exceptionCreatedBy = $Exceptions.exceptionCreatedBy + } + $ExceptionDate = @{ customerId = $Exceptions.customerId + exceptionDate = $Exceptions.exceptionDate + } + $ExceptionExpiry = @{ customerId = $Exceptions.customerId + exceptionExpiry = $Exceptions.exceptionExpiry + } + } + + [void]$SortedCves.Add([PSCustomObject]@{ + cveId = $Target.cveId + vulnerabilitySeverityLevel = $Target.vulnerabilitySeverityLevel + exploitabilityLevel = $Target.exploitabilityLevel + softwareName = $Target.softwareName + softwareVendor = $Target.softwareVendor + softwareVersion = $Target.softwareVersion + deviceCount = $Target.TotalDeviceCount + tenantCount = $Target.TotalTenantGroupCount + registryPaths = $Target.RegistryPathList + diskPaths = $Target.DiskPathList + exceptionStatus = $ExceptionStatus + hasException = $HasException + affectedTenants = $Target.AffectedTenantsList + affectedDevices = $Target.AffectedDevicesList + exceptionType = $ExceptionType + exceptionComment = $ExceptionComment + exceptionCreatedBy = $ExceptionCreatedBy + exceptionDate = $ExceptionDate + exceptionExpiry = $ExceptionExpiry + cacheTimeStamp = $Target.lastUpdated + }) + $StatusCode = [HttpStatusCode]::OK + } + + } catch { + Write-Host "Error retrieving CVEs: $($_.Exception.Message)" + $StatusCode = [HttpStatusCode]::InternalServerError + $SortedCves = $_.Exception.Message + } + } + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($SortedCves | Sort-Object -Property cveId) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 index 0354734b23238..94e1bf627f379 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 @@ -1,4 +1,3 @@ - function Invoke-ListIntunePolicy { <# .FUNCTIONALITY @@ -216,6 +215,16 @@ function Invoke-ListIntunePolicy { method = 'GET' url = "/deviceManagement/configurationPolicies?`$expand=assignments&`$top=1000" } + @{ + id = 'deviceCompliancePolicies' + method = 'GET' + url = "/deviceManagement/deviceCompliancePolicies?`$expand=assignments&`$top=1000" + } + @{ + id = 'Intents' + method = 'GET' + url = "/deviceManagement/intents?`$top=1000" + } ) $BulkResults = New-GraphBulkRequest -Requests $BulkRequests -tenantid $TenantFilter @@ -226,7 +235,8 @@ function Invoke-ListIntunePolicy { $GraphRequest = $BulkResults | Where-Object { $_.id -ne 'Groups' } | ForEach-Object { $URLName = $_.Id $_.body.Value | ForEach-Object { - $policyTypeName = switch -Wildcard ($_.'assignments@odata.context') { + $AssignmentContext = $_.'assignments@odata.context' + $policyTypeName = switch -Wildcard ($AssignmentContext) { '*microsoft.graph.windowsIdentityProtectionConfiguration*' { 'Identity Protection' } '*microsoft.graph.windows10EndpointProtectionConfiguration*' { 'Endpoint Protection' } '*microsoft.graph.windows10CustomConfiguration*' { 'Custom' } @@ -244,7 +254,18 @@ function Invoke-ListIntunePolicy { '*iosUpdateConfiguration*' { 'iOS Update Configuration' } '*windowsDriverUpdateProfiles*' { 'Driver Update' } '*configurationPolicies*' { 'Device Configuration' } - default { $_.'assignments@odata.context' } + '*deviceCompliancePolicies*' { 'Compliance Policy' } + '*intents*' { 'Endpoint Security' } + default { $null } + } + # Fall back to the request type when the assignment context does not identify the policy + # (e.g. Intents are listed without expanding assignments). + if ([string]::IsNullOrWhiteSpace($policyTypeName)) { + $policyTypeName = switch ($URLName) { + 'deviceCompliancePolicies' { 'Compliance Policy' } + 'Intents' { 'Endpoint Security' } + default { $AssignmentContext } + } } $Assignments = $_.assignments.target | Select-Object -Property '@odata.type', groupId $PolicyAssignment = [System.Collections.Generic.List[string]]::new() diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 index 8d17d0aa0118e..d5c985745043f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 @@ -53,6 +53,8 @@ function Invoke-AddGroupTemplate { allowExternal = $Request.Body.allowExternal username = $Request.Body.username # Can contain variables like @%tenantfilter% licenses = $Request.Body.licenses + aliases = $Request.Body.aliases # One per line, variables allowed + hideFromGAL = $Request.Body.hideFromGAL GUID = $GUID } | ConvertTo-Json -Depth 10 $Table = Get-CippTable -tablename 'templates' diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroupTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroupTemplates.ps1 index 4a4373733049c..9413c7da609d5 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroupTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroupTemplates.ps1 @@ -48,6 +48,8 @@ function Invoke-ListGroupTemplates { allowExternal = $data.allowExternal username = $data.username licenses = $data.licenses + aliases = $data.aliases + hideFromGAL = $data.hideFromGAL GUID = $_.RowKey source = $_.Source isSynced = (![string]::IsNullOrEmpty($_.SHA)) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 index 04bb3c7a4fad5..dd9793aa4c126 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 @@ -73,13 +73,13 @@ function Invoke-AddUserDefaults { # Contact fields $MobilePhone = $Request.Body.mobilePhone - $BusinessPhones = if ($null -ne $Request.Body.businessPhones) { - if ($Request.Body.businessPhones -is [array]) { $Request.Body.businessPhones[0] } else { $Request.Body.businessPhones } - } elseif ($null -ne $Request.Body.'businessPhones[0]') { - $Request.Body.'businessPhones[0]' - } else { - $null - } + $BusinessPhones = @( + if ($null -ne $Request.Body.businessPhones) { + $Request.Body.businessPhones | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + } elseif ($null -ne $Request.Body.'businessPhones[0]') { + $Request.Body.'businessPhones[0]' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + } + ) $OtherMails = $Request.Body.otherMails # User relations diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 index e5f9f1fc482c6..c9576f8b7fd4b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 @@ -19,243 +19,52 @@ function Invoke-EditUser { StatusCode = [HttpStatusCode]::BadRequest Body = $Body }) - return } - $Results = [System.Collections.Generic.List[object]]::new() - $licenses = ($UserObj.licenses).value - $Aliases = if ($UserObj.AddedAliases) { ($UserObj.AddedAliases) -split '\s' } - $AddToGroups = $Request.Body.AddToGroups - $RemoveFromGroups = $Request.Body.RemoveFromGroups - - #Edit the user - try { - Write-Host "$([boolean]$UserObj.MustChangePass)" - $UserPrincipalName = "$($UserObj.username)@$($UserObj.Domain ? $UserObj.Domain : $UserObj.primDomain.value)" - $normalizedOtherMails = @( - @($UserObj.otherMails) | ForEach-Object { - if ($null -ne $_) { - [string]$_ -split ',' + if ($UserObj.Scheduled.Enabled) { + try { + $TaskBody = [pscustomobject]@{ + TenantFilter = $UserObj.tenantFilter + Name = "Edit user: $($UserObj.DisplayName)" + Command = @{ + value = 'Set-CIPPUser' + label = 'Set-CIPPUser' } - } | ForEach-Object { - $_.Trim() - } | Where-Object { - -not [string]::IsNullOrWhiteSpace($_) - } - ) - $BodyToship = [pscustomobject] @{ - 'givenName' = $UserObj.givenName - 'surname' = $UserObj.surname - 'displayName' = $UserObj.displayName - 'department' = $UserObj.department - 'mailNickname' = $UserObj.username ? $UserObj.username : $UserObj.mailNickname - 'userPrincipalName' = $UserPrincipalName - 'usageLocation' = $UserObj.usageLocation.value ? $UserObj.usageLocation.value : $UserObj.usageLocation - 'jobTitle' = $UserObj.jobTitle - 'mobilePhone' = $UserObj.mobilePhone - 'streetAddress' = $UserObj.streetAddress - 'city' = $UserObj.city - 'state' = $UserObj.state - 'postalCode' = $UserObj.postalCode - 'country' = $UserObj.country - 'companyName' = $UserObj.companyName - 'businessPhones' = $UserObj.businessPhones ? @($UserObj.businessPhones) : @() - 'otherMails' = $normalizedOtherMails - 'passwordProfile' = @{ - 'forceChangePasswordNextSignIn' = [bool]$UserObj.MustChangePass - } - } | ForEach-Object { - $NonEmptyProperties = $_.PSObject.Properties | - Where-Object { -not [string]::IsNullOrWhiteSpace($_.Value) } | - Select-Object -ExpandProperty Name - $_ | Select-Object -Property $NonEmptyProperties - } - if ($UserObj.defaultAttributes) { - $UserObj.defaultAttributes | Get-Member -MemberType NoteProperty | ForEach-Object { - if (-not [string]::IsNullOrWhiteSpace($UserObj.defaultAttributes.$($_.Name).value)) { - Write-Host "Editing user and adding $($_.Name) with value $($UserObj.defaultAttributes.$($_.Name).value)" - $BodyToShip | Add-Member -NotePropertyName $_.Name -NotePropertyValue $UserObj.defaultAttributes.$($_.Name).value -Force + Parameters = [pscustomobject]@{ UserObj = $UserObj } + ScheduledTime = $UserObj.Scheduled.date + Reference = $UserObj.reference ?? $null + PostExecution = @{ + Webhook = [bool]$Request.Body.PostExecution.Webhook + Email = [bool]$Request.Body.PostExecution.Email + PSA = [bool]$Request.Body.PostExecution.PSA } } - } - if ($UserObj.customData) { - $UserObj.customData | Get-Member -MemberType NoteProperty | ForEach-Object { - if (-not [string]::IsNullOrWhiteSpace($UserObj.customData.$($_.Name))) { - Write-Host "Editing user and adding custom data $($_.Name) with value $($UserObj.customData.$($_.Name))" - $BodyToShip | Add-Member -NotePropertyName $_.Name -NotePropertyValue $UserObj.customData.$($_.Name) -Force - } + Add-CIPPScheduledTask -Task $TaskBody -hidden $false -DisallowDuplicateName $true -Headers $Headers + $body = [pscustomobject]@{ + 'Results' = @("Successfully created scheduled task to edit user $($UserObj.DisplayName)") } - } - $bodyToShip = ConvertTo-Json -Depth 10 -InputObject $BodyToship -Compress - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type PATCH -body $BodyToship -verbose - $Results.Add( 'Success. The user has been edited.' ) - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "Edited user $($UserObj.DisplayName) with id $($UserObj.id)" -Sev Info - if ($UserObj.password) { - $passwordProfile = [pscustomobject]@{'passwordProfile' = @{ 'password' = $UserObj.password; 'forceChangePasswordNextSignIn' = [boolean]$UserObj.MustChangePass } } | ConvertTo-Json - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type PATCH -body $PasswordProfile -Verbose - $Results.Add("Success. The password has been set to $($UserObj.password)") - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "Reset $($UserObj.DisplayName)'s Password" -Sev Info - } - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "User edit API failed. $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage - $Results.Add( "Failed to edit user. $($ErrorMessage.NormalizedError)") - } - - - #Reassign the licenses - try { - - if ($licenses -or $UserObj.removeLicenses) { - if ($UserObj.sherwebLicense.value) { - $null = Set-SherwebSubscription -Headers $Headers -TenantFilter $UserObj.tenantFilter -SKU $UserObj.sherwebLicense.value -Add 1 - $Results.Add('Added Sherweb License, scheduling assignment') - $taskObject = [PSCustomObject]@{ - TenantFilter = $UserObj.tenantFilter - Name = "Assign License: $UserPrincipalName" - Command = @{ - value = 'Set-CIPPUserLicense' - } - Parameters = [pscustomobject]@{ - UserId = $UserObj.id - APIName = 'Sherweb License Assignment' - AddLicenses = $licenses - UserPrincipalName = $UserPrincipalName - } - ScheduledTime = 0 #right now, which is in the next 15 minutes and should cover most cases. - PostExecution = @{ - Webhook = [bool]$Request.Body.PostExecution.webhook - Email = [bool]$Request.Body.PostExecution.email - PSA = [bool]$Request.Body.PostExecution.psa - } - } - Add-CIPPScheduledTask -Task $taskObject -hidden $false -Headers $Headers - } else { - $CurrentLicenses = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter - #if the list of skuIds in $CurrentLicenses.assignedLicenses is EXACTLY the same as $licenses, we don't need to do anything, but the order in both can be different. - if (($CurrentLicenses.assignedLicenses.skuId -join ',') -eq ($licenses -join ',') -and $UserObj.removeLicenses -eq $false) { - Write-Host "$($CurrentLicenses.assignedLicenses.skuId -join ',') $(($licenses -join ','))" - $Results.Add( 'Success. User license is already correct.' ) - } else { - if ($UserObj.removeLicenses) { - $licResults = Set-CIPPUserLicense -UserPrincipalName $UserPrincipalName -UserId $UserObj.id -TenantFilter $UserObj.tenantFilter -RemoveLicenses $CurrentLicenses.assignedLicenses.skuId -Headers $Headers -APIName $APIName - $Results.Add($licResults) - } else { - #Remove all objects from $CurrentLicenses.assignedLicenses.skuId that are in $licenses - $RemoveLicenses = $CurrentLicenses.assignedLicenses.skuId | Where-Object { $_ -notin $licenses } - $licResults = Set-CIPPUserLicense -UserPrincipalName $UserPrincipalName -UserId $UserObj.id -TenantFilter $UserObj.tenantFilter -RemoveLicenses $RemoveLicenses -AddLicenses $licenses -Headers $Headers -APIName $APIName - $Results.Add($licResults) - } - - } + } catch { + $body = [pscustomobject]@{ + 'Results' = @("Failed to create scheduled task to edit user $($UserObj.DisplayName): $($_.Exception.Message)") } + $StatusCode = [HttpStatusCode]::InternalServerError } - - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "License assign API failed. $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage - $Results.Add( "We've failed to assign the license. $($ErrorMessage.NormalizedError)") - Write-Warning "License assign API failed. $($_.Exception.Message)" - Write-Information $_.InvocationInfo.PositionMessage - } - - #Add Aliases, removal currently not supported. - try { - if ($Aliases) { - Write-Host ($Aliases | ConvertTo-Json) - foreach ($Alias in $Aliases) { - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type 'patch' -body "{`"mail`": `"$Alias`"}" -Verbose + } else { + try { + $EditResults = Set-CIPPUser -UserObj $UserObj -APIName $APIName -Headers $Headers + $body = [pscustomobject]@{ 'Results' = @($EditResults.Results) } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $body = [pscustomobject]@{ + 'Results' = @("Failed to edit user. $($ErrorMessage.NormalizedError)") } - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type 'patch' -body "{`"mail`": `"$UserPrincipalName`"}" -Verbose - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "Added Aliases to $($UserObj.DisplayName)" -Sev Info - $Results.Add( 'Success. Added aliases to user.') + $StatusCode = [HttpStatusCode]::InternalServerError } - - } catch { - $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to add aliases to user $($UserObj.DisplayName). Error: $($ErrorMessage.NormalizedError)" - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message $Message -Sev Error -LogData $ErrorMessage - $Results.Add($Message) - } - - if ($Request.Body.CopyFrom.value) { - $CopyFrom = Set-CIPPCopyGroupMembers -Headers $Headers -CopyFromId $Request.Body.CopyFrom.value -UserID $UserPrincipalName -TenantFilter $UserObj.tenantFilter - $Results.AddRange(@($CopyFrom)) - } - - if ($AddToGroups) { - $AddToGroups | ForEach-Object { - - $GroupType = $_.addedFields.groupType - $GroupID = $_.value - $GroupName = $_.label - Write-Host "About to add $($UserObj.userPrincipalName) to $GroupName. Group ID is: $GroupID and type is: $GroupType" - - try { - if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security') { - Write-Host 'Adding to group via Add-DistributionGroupMember' - $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } - $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Add-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true - } else { - Write-Host 'Adding to group via Graph' - $UserBody = [PSCustomObject]@{ - '@odata.id' = "https://graph.microsoft.com/beta/directoryObjects/$($UserObj.id)" - } - $UserBodyJSON = ConvertTo-Json -Compress -Depth 10 -InputObject $UserBody - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/groups/$GroupID/members/`$ref" -tenantid $UserObj.tenantFilter -type POST -body $UserBodyJSON -Verbose - } - Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message "Added $($UserObj.DisplayName) to $GroupName group" -Sev Info - $Results.Add("Success. $($UserObj.DisplayName) has been added to $GroupName") - } catch { - $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to add member $($UserObj.DisplayName) to $GroupName. Error: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message $Message -Sev Error -LogData $ErrorMessage - $Results.Add($Message) - } - } - } - - if ($RemoveFromGroups) { - $RemoveFromGroups | ForEach-Object { - - $GroupType = $_.addedFields.groupType - $GroupID = $_.value - $GroupName = $_.label - Write-Host "About to remove $($UserObj.userPrincipalName) from $GroupName. Group ID is: $GroupID and type is: $GroupType" - - try { - if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security') { - Write-Host 'Removing From group via Remove-DistributionGroupMember' - $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } - $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Remove-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true - } else { - Write-Host 'Removing From group via Graph' - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/groups/$GroupID/members/$($UserObj.id)/`$ref" -tenantid $UserObj.tenantFilter -type DELETE - } - Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message "Removed $($UserObj.DisplayName) from $GroupName group" -Sev Info - $Results.Add("Success. $($UserObj.DisplayName) has been removed from $GroupName") - } catch { - $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to remove member $($UserObj.DisplayName) from $GroupName. Error: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message $Message -Sev Error -LogData $ErrorMessage - $Results.Add($Message) - } - } - } - - if ($Request.body.setManager.value) { - $ManagerResults = Set-CIPPManager -Users $UserPrincipalName -Manager $Request.body.setManager.value -TenantFilter $UserObj.tenantFilter -Headers $Headers - $Results.Add($ManagerResults.Result) - } - - if ($Request.body.setSponsor.value) { - $SponsorResults = Set-CIPPSponsor -Users $UserPrincipalName -Sponsor $Request.body.setSponsor.value -TenantFilter $UserObj.tenantFilter -Headers $Headers - $Results.Add($SponsorResults.Result) } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = @{'Results' = @($Results) } + StatusCode = $StatusCode ? $StatusCode : [HttpStatusCode]::OK + Body = $Body }) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 index 97cebd8840b99..9a59cf7bbd983 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 @@ -241,37 +241,38 @@ function Invoke-ListUserMailboxDetails { # Build the GraphRequest object $GraphRequest = [ordered]@{ - ForwardAndDeliver = $MailboxDetailedRequest.DeliverToMailboxAndForward - ForwardingAddress = $ForwardingAddress - LitigationHold = $MailboxDetailedRequest.LitigationHoldEnabled - RetentionHold = $MailboxDetailedRequest.RetentionHoldEnabled - ComplianceTagHold = $MailboxDetailedRequest.ComplianceTagHoldApplied - InPlaceHold = $InPlaceHold - EDiscoveryHold = $EDiscoveryHold - PurviewRetentionHold = $PurviewRetentionHold - ExcludedFromOrgWideHold = $ExcludedFromOrgWideHold - HiddenFromAddressLists = $MailboxDetailedRequest.HiddenFromAddressListsEnabled - EWSEnabled = $CASRequest.EwsEnabled - MailboxMAPIEnabled = $CASRequest.MAPIEnabled - MailboxOWAEnabled = $CASRequest.OWAEnabled - MailboxImapEnabled = $CASRequest.ImapEnabled - MailboxPopEnabled = $CASRequest.PopEnabled - MailboxActiveSyncEnabled = $CASRequest.ActiveSyncEnabled - Permissions = @($ParsedPerms) - ProhibitSendQuota = $ProhibitSendQuota - ProhibitSendReceiveQuota = $ProhibitSendReceiveQuota - ItemCount = [math]::Round($StatsRequest.ItemCount, 2) - TotalItemSize = $TotalItemSize - TotalArchiveItemSize = $TotalArchiveItemSize - TotalArchiveItemCount = $TotalArchiveItemCount - BlockedForSpam = $BlockedForSpam - ArchiveMailBox = $ArchiveEnabled - AutoExpandingArchive = $AutoExpandingArchiveEnabled - AutoExpandingArchiveScope = $AutoExpandingArchiveScope - RecipientTypeDetails = $MailboxDetailedRequest.RecipientTypeDetails - Mailbox = $MailboxDetailedRequest - RetentionPolicy = $MailboxDetailedRequest.RetentionPolicy - MailboxActionsData = ($MailboxDetailedRequest | Select-Object id, ExchangeGuid, ArchiveGuid, WhenSoftDeleted, + ForwardAndDeliver = $MailboxDetailedRequest.DeliverToMailboxAndForward + ForwardingAddress = $ForwardingAddress + LitigationHold = $MailboxDetailedRequest.LitigationHoldEnabled + RetentionHold = $MailboxDetailedRequest.RetentionHoldEnabled + ComplianceTagHold = $MailboxDetailedRequest.ComplianceTagHoldApplied + InPlaceHold = $InPlaceHold + EDiscoveryHold = $EDiscoveryHold + PurviewRetentionHold = $PurviewRetentionHold + ExcludedFromOrgWideHold = $ExcludedFromOrgWideHold + HiddenFromAddressLists = $MailboxDetailedRequest.HiddenFromAddressListsEnabled + EWSEnabled = $CASRequest.EwsEnabled + MailboxMAPIEnabled = $CASRequest.MAPIEnabled + MailboxOWAEnabled = $CASRequest.OWAEnabled + MailboxImapEnabled = $CASRequest.ImapEnabled + MailboxPopEnabled = $CASRequest.PopEnabled + MailboxActiveSyncEnabled = $CASRequest.ActiveSyncEnabled + SmtpClientAuthenticationDisabled = $CASRequest.SmtpClientAuthenticationDisabled + Permissions = @($ParsedPerms) + ProhibitSendQuota = $ProhibitSendQuota + ProhibitSendReceiveQuota = $ProhibitSendReceiveQuota + ItemCount = [math]::Round($StatsRequest.ItemCount, 2) + TotalItemSize = $TotalItemSize + TotalArchiveItemSize = $TotalArchiveItemSize + TotalArchiveItemCount = $TotalArchiveItemCount + BlockedForSpam = $BlockedForSpam + ArchiveMailBox = $ArchiveEnabled + AutoExpandingArchive = $AutoExpandingArchiveEnabled + AutoExpandingArchiveScope = $AutoExpandingArchiveScope + RecipientTypeDetails = $MailboxDetailedRequest.RecipientTypeDetails + Mailbox = $MailboxDetailedRequest + RetentionPolicy = $MailboxDetailedRequest.RetentionPolicy + MailboxActionsData = ($MailboxDetailedRequest | Select-Object id, ExchangeGuid, ArchiveGuid, WhenSoftDeleted, @{ Name = 'UPN'; Expression = { $_.'UserPrincipalName' } }, @{ Name = 'displayName'; Expression = { $_.'DisplayName' } }, @{ Name = 'primarySmtpAddress'; Expression = { $_.'PrimarySMTPAddress' } }, diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 index e539f77654140..01282d8552c0c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 @@ -15,15 +15,45 @@ function Invoke-ListUserSettings { try { $Table = Get-CippTable -tablename 'UserSettings' - $UserSettings = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserSettings' and RowKey eq 'allUsers'" - if (!$UserSettings) { $UserSettings = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserSettings' and RowKey eq '$Username'" } - - try { - $UserSettings = $UserSettings.JSON | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue - } catch { - Write-Warning "Failed to convert UserSettings JSON: $($_.Exception.Message)" + $ConvertUserSettings = { + param($Entity) + if (!$Entity -or !$Entity.JSON) { return $null } + try { + return $Entity.JSON | ConvertFrom-Json -Depth 10 -ErrorAction Stop + } catch { + Write-Warning "UserSettings JSON for '$($Entity.RowKey)' had a key case collision, self-healing: $($_.Exception.Message)" + try { + $Hash = $Entity.JSON | ConvertFrom-Json -Depth 10 -AsHashtable + if ($Hash.offboardingDefaults -is [System.Collections.IDictionary]) { + $Offboarding = $Hash.offboardingDefaults + $Variants = @($Offboarding.Keys | Where-Object { $_ -ieq 'keepCopy' }) + if ($Variants.Count -gt 0) { + $CanonicalValue = if ($Offboarding.Contains('KeepCopy')) { $Offboarding['KeepCopy'] } else { $Offboarding[$Variants[0]] } + foreach ($Variant in $Variants) { $null = $Offboarding.Remove($Variant) } + $Offboarding['KeepCopy'] = $CanonicalValue + } + } + $HealedJson = $Hash | ConvertTo-Json -Depth 10 -Compress + $Healed = $HealedJson | ConvertFrom-Json -Depth 10 -ErrorAction Stop + $HealTable = Get-CippTable -tablename 'UserSettings' + $HealTable.Force = $true + Add-CIPPAzDataTableEntity @HealTable -Entity @{ + JSON = "$HealedJson" + RowKey = "$($Entity.RowKey)" + PartitionKey = "$($Entity.PartitionKey)" + } + return $Healed + } catch { + Write-Warning "Failed to self-heal UserSettings JSON for '$($Entity.RowKey)': $($_.Exception.Message)" + return $null + } + } } + $UserSettingsEntity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserSettings' and RowKey eq 'allUsers'" + if (!$UserSettingsEntity) { $UserSettingsEntity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserSettings' and RowKey eq '$Username'" } + $UserSettings = & $ConvertUserSettings $UserSettingsEntity + if (!$UserSettings) { $UserSettings = [pscustomobject]@{ direction = 'ltr' @@ -38,12 +68,27 @@ function Invoke-ListUserSettings { } } - try { - $UserSpecificSettings = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserSettings' and RowKey eq '$Username'" - $UserSpecificSettings = $UserSpecificSettings.JSON | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue - } catch { - Write-Warning "Failed to convert UserSpecificSettings JSON: $($_.Exception.Message)" + $UserSpecificEntity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserSettings' and RowKey eq '$Username'" + $UserSpecificSettings = & $ConvertUserSettings $UserSpecificEntity + + $TestOffboardingConfigured = { + param($Offboarding) + if (-not $Offboarding) { return $false } + foreach ($Property in $Offboarding.PSObject.Properties) { + if ($Property.Value -eq $true) { return $true } + } + return $false + } + + $AllUsersOffboardingConfigured = & $TestOffboardingConfigured $UserSettings.offboardingDefaults + $UserOffboardingConfigured = & $TestOffboardingConfigured $UserSpecificSettings.offboardingDefaults + + $OffboardingDefaultsSource = 'allUsers' + if (-not $AllUsersOffboardingConfigured -and $UserOffboardingConfigured) { + $UserSettings | Add-Member -MemberType NoteProperty -Name 'offboardingDefaults' -Value $UserSpecificSettings.offboardingDefaults -Force | Out-Null + $OffboardingDefaultsSource = 'user' } + $UserSettings | Add-Member -MemberType NoteProperty -Name 'offboardingDefaultsSource' -Value $OffboardingDefaultsSource -Force | Out-Null # Get user bookmarks try { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 index 843fc4159857f..a24160bb3987b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 @@ -15,8 +15,66 @@ Function Invoke-ListUsers { $GraphFilter = $Request.Query.graphFilter $userid = $Request.Query.UserID + # When fetching a single user, use an explicit $select so directory/schema extension properties are + # returned. Covers the properties the user view and edit form consume, any attributes added via + # Preferences > Added Attributes, and custom data attributes mapped for manual entry on users. + $SelectParam = '' + if ($userid -and $TenantFilter -ne 'AllTenants') { + $BaseProperties = @( + 'id', 'accountEnabled', 'ageGroup', 'assignedLicenses', 'businessPhones', 'city', 'companyName', + 'consentProvidedForMinor', 'country', 'createdDateTime', 'department', 'displayName', + 'employeeHireDate', 'employeeId', 'employeeLeaveDateTime', 'employeeType', 'faxNumber', + 'givenName', 'jobTitle', 'lastPasswordChangeDateTime', 'legalAgeGroupClassification', 'mail', + 'mailNickname', 'mobilePhone', 'officeLocation', 'onPremisesDistinguishedName', + 'onPremisesImmutableId', 'onPremisesLastSyncDateTime', 'onPremisesSyncEnabled', 'otherMails', 'postalCode', + 'preferredLanguage', 'proxyAddresses', 'showInAddressList', 'state', 'streetAddress', + 'surname', 'usageLocation', 'userPrincipalName', 'userType' + ) + $CustomAttributes = [System.Collections.Generic.List[string]]::new() + try { + # Attributes added via Preferences > 'Added Attributes when creating a new user' + $Username = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Request.Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails + $EscapedUsername = $Username -replace "'", "''" + $UserSettingsTable = Get-CippTable -tablename 'UserSettings' + $UserSettingsEntities = Get-CIPPAzDataTableEntity @UserSettingsTable -Filter "PartitionKey eq 'UserSettings' and (RowKey eq 'allUsers' or RowKey eq '$EscapedUsername')" + foreach ($Entity in $UserSettingsEntities) { + foreach ($Label in (($Entity.JSON | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue).userAttributes.label)) { + if ($Label) { $CustomAttributes.Add($Label) } + } + } + # Custom data attributes mapped for manual entry on users for this tenant + $MappingsTable = Get-CippTable -tablename 'CustomDataMappings' + foreach ($Entity in (Get-CIPPAzDataTableEntity @MappingsTable)) { + $Mapping = $Entity.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($Mapping.sourceType.value -ne 'manualEntry' -or $Mapping.directoryObjectType.value -ne 'user') { continue } + $TenantList = Expand-CIPPTenantGroups -TenantFilter $Mapping.tenantFilter + if ($TenantList.value -notcontains $TenantFilter -and $TenantList.value -notcontains 'AllTenants') { continue } + # Schema extension names are 'schemaId.property'; Graph $select takes the schema id + $AttributeName = ($Mapping.customDataAttribute.value -split '\.')[0] + if ($AttributeName) { $CustomAttributes.Add($AttributeName) } + } + } catch { + Write-Warning "Failed to resolve custom attributes for user $($userid): $($_.Exception.Message)" + } + $ValidCustomAttributes = $CustomAttributes | Where-Object { $_ -match '^[A-Za-z][A-Za-z0-9_]*$' } + $SelectParam = '&$select=' + ((@($BaseProperties) + @($ValidCustomAttributes) | Sort-Object -Unique) -join ',') + Write-Information $SelectParam + } + $GraphRequest = if ($TenantFilter -ne 'AllTenants') { - New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($userid)?`$top=999&`$filter=$GraphFilter&`$count=true&`$expand=manager(`$select=id,userPrincipalName,displayName)" -tenantid $TenantFilter -ComplexFilter | ForEach-Object { + try { + $UserData = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($userid)?`$top=999&`$filter=$GraphFilter&`$count=true&`$expand=manager(`$select=id,userPrincipalName,displayName)$SelectParam" -tenantid $TenantFilter -ComplexFilter + } catch { + if ($SelectParam) { + # A preference-added attribute name Graph does not recognize fails the whole $select; + # fall back to the default property set rather than failing the request. + Write-Warning "ListUsers select query failed, falling back to default properties: $($_.Exception.Message)" + $UserData = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($userid)?`$top=999&`$filter=$GraphFilter&`$count=true&`$expand=manager(`$select=id,userPrincipalName,displayName)" -tenantid $TenantFilter -ComplexFilter + } else { + throw + } + } + $UserData | ForEach-Object { $_ | Add-Member -MemberType NoteProperty -Name 'onPremisesSyncEnabled' -Value ([bool]($_.onPremisesSyncEnabled)) -Force $_ | Add-Member -MemberType NoteProperty -Name 'username' -Value ($_.userPrincipalName -split '@' | Select-Object -First 1) -Force $_ | Add-Member -MemberType NoteProperty -Name 'Aliases' -Value ($_.ProxyAddresses -join ', ') -Force diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecCustomTestRun.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecCustomTestRun.ps1 new file mode 100644 index 0000000000000..7e02d1911c8d6 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecCustomTestRun.ps1 @@ -0,0 +1,48 @@ +function Invoke-ExecCustomTestRun { + <# + .SYNOPSIS + Triggers a Custom test-suite run for one or all tenants. + + .DESCRIPTION + Re-runs the enabled Custom tests against the most recent cached data for the requested + tenant(s) by starting the standard tests orchestration with a Custom-only suite filter. + Used by the cross-tenant Custom Test Report to refresh results on demand. + + .FUNCTIONALITY + Entrypoint,AnyTenant + + .ROLE + Tenant.Tests.ReadWrite + #> + param($Request, $TriggerMetadata) + + $APIName = $TriggerMetadata.FunctionName + Write-LogMessage -headers $Request.Headers -API $APIName -message 'Accessed this API' -Sev 'Debug' + + $TenantFilter = 'allTenants' + try { + $TenantFilterRaw = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + # Accept a plain string, a {value} object, or a single-element array of either. + if ($TenantFilterRaw -is [array]) { $TenantFilterRaw = $TenantFilterRaw | Select-Object -First 1 } + if ($TenantFilterRaw -is [pscustomobject]) { $TenantFilterRaw = $TenantFilterRaw.value } + if (-not [string]::IsNullOrWhiteSpace($TenantFilterRaw)) { $TenantFilter = [string]$TenantFilterRaw } + if ($TenantFilter -eq 'AllTenants') { $TenantFilter = 'allTenants' } + + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Starting Custom test run for: $TenantFilter" -sev Info + $null = Start-CIPPDBTestsRun -TenantFilter $TenantFilter -Suites 'Custom' -Force + + $Scope = if ($TenantFilter -eq 'allTenants') { 'all tenants' } else { $TenantFilter } + $StatusCode = [HttpStatusCode]::OK + $Body = [PSCustomObject]@{ Results = "Successfully started a custom test run for $Scope. Results will populate here as each tenant completes." } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Failed to start custom test run: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + $Body = [PSCustomObject]@{ Results = "Failed to start custom test run: $($ErrorMessage.NormalizedError)" } + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 index 69a66fb92bcdd..984d37b4fe630 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 @@ -21,7 +21,7 @@ function Invoke-ExecListAppId { $env:ApplicationID = $Secret.ApplicationID $env:TenantID = $Secret.TenantID } else { - $keyvaultname = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $keyvaultname = Get-CippKeyVaultName try { $env:ApplicationID = (Get-CippKeyVaultSecret -AsPlainText -VaultName $keyvaultname -Name 'ApplicationID') $env:TenantID = (Get-CippKeyVaultSecret -AsPlainText -VaultName $keyvaultname -Name 'TenantID') diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListTestResultsTenants.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListTestResultsTenants.ps1 new file mode 100644 index 0000000000000..a6d1ab1054284 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListTestResultsTenants.ps1 @@ -0,0 +1,80 @@ +function Invoke-ListTestResultsTenants { + <# + .SYNOPSIS + Lists CIPP test results for a given test across one, many, or all tenants. + + .DESCRIPTION + Cross-tenant overview of stored test results. Backed by Get-CIPPTestResultsTenants, which + queries the shared CippTestResults table server-side and enriches Custom rows with their + definition. Results are filtered to the tenants the calling user is permitted to see via + Test-CIPPAccess, so an all-tenants read still respects per-user tenant scoping. + + .FUNCTIONALITY + Entrypoint,AnyTenant + + .ROLE + Tenant.Reports.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $TriggerMetadata.FunctionName + + try { + $TenantFilterRaw = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $TestIdRaw = $Request.Query.testId ?? $Request.Body.testId + $StatusRaw = $Request.Query.status ?? $Request.Body.status + $TestType = $Request.Query.testType ?? $Request.Body.testType + $Risk = $Request.Query.risk ?? $Request.Body.risk + $Category = $Request.Query.category ?? $Request.Body.category + + # Normalise inputs that may arrive as a single string, a comma-delimited string, or an + # array of strings / {value,label} objects (the frontend autocomplete posts the latter). + $ToArray = { + param($Value) + if ($null -eq $Value) { return @() } + if ($Value -is [string]) { + return @($Value -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } + return @($Value | ForEach-Object { $_.value ?? $_ } | Where-Object { $_ }) + } + + $TenantFilterList = & $ToArray $TenantFilterRaw + $TestIdList = & $ToArray $TestIdRaw + $StatusList = & $ToArray $StatusRaw + + $Params = @{} + if ($TenantFilterList.Count -gt 0) { $Params.TenantFilter = $TenantFilterList } + if ($TestIdList.Count -gt 0) { $Params.TestId = $TestIdList } + if ($StatusList.Count -gt 0) { $Params.Status = $StatusList } + if ($TestType) { $Params.TestType = $TestType } + if ($Risk) { $Params.Risk = $Risk } + if ($Category) { $Params.Category = $Category } + + $Results = @(Get-CIPPTestResultsTenants @Params) + + # Restrict to tenants the caller is allowed to see. Test-CIPPAccess returns the list of + # permitted customerIds, or 'AllTenants' for unrestricted users. + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants') { + $AllowedSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Allowed in $AllowedTenants) { + if ($Allowed) { [void]$AllowedSet.Add([string]$Allowed) } + } + $Results = @($Results | Where-Object { $_.TenantId -and $AllowedSet.Contains([string]$_.TenantId) }) + } + + $StatusCode = [HttpStatusCode]::OK + $Body = @{ Results = @($Results) } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -message "Error retrieving cross-tenant test results: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + $Body = @{ Error = $ErrorMessage.NormalizedError } + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoTypeTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoTypeTemplate.ps1 index 0555e613bd3dc..cc0633a925914 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoTypeTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoTypeTemplate.ps1 @@ -11,8 +11,7 @@ Function Invoke-AddSensitiveInfoTypeTemplate { $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers - # SIT templates are JSON-authored via the deploy drawer. Round-tripping from an existing tenant SIT is not - # supported because the rule pack XML is not exposed reliably through IPPS REST. + # Fields kept for JSON-authored (deploy drawer) templates. $AllowedFields = @( 'Name', 'Description', 'Pattern', 'Confidence', @@ -22,38 +21,74 @@ Function Invoke-AddSensitiveInfoTypeTemplate { try { $GUID = (New-Guid).GUID + $TenantFilter = $Request.Body.tenantFilter ?? $Request.Query.tenantFilter + $Identity = $Request.Body.Identity ?? $Request.Body.Id ?? $Request.Body.Name ?? $Request.Body.name - $Source = if ($Request.Body.PowerShellCommand) { - $Request.Body.PowerShellCommand | ConvertFrom-Json + if ($TenantFilter -and $Identity) { + # --- Path A: capture from an existing tenant SIT's rule pack --- + $Sit = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpSensitiveInformationType' -Compliance | + Where-Object { $_.Name -eq $Identity -or $_.Id -eq $Identity -or $_.Identity -eq $Identity } | Select-Object -First 1 + if (-not $Sit) { + throw "Sensitive Information Type '$Identity' not found in tenant $TenantFilter." + } + if ($Sit.Publisher -like 'Microsoft*') { + throw "SIT '$($Sit.Name)' is a Microsoft built-in and cannot be templated." + } + + $Pack = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpSensitiveInformationTypeRulePackage' -cmdParams @{ Identity = $Sit.RulePackId } -Compliance | Select-Object -First 1 + $Xml = $Pack.ClassificationRuleCollectionXml + if ([string]::IsNullOrWhiteSpace([string]$Xml)) { + throw "Could not retrieve the rule pack XML for SIT '$($Sit.Name)' (pack $($Sit.RulePackId))." + } + + # Reduce to just this SIT's entity (fingerprint SITs share the one managed pack; even regex SITs + # get a fresh pack id so a redeploy can't collide), then store as the UTF-16 base64 bytes the + # New-/Set-*RulePackage cmdlets expect. + $SingleXml = Get-CIPPSitSinglePackXml -PackXml ([string]$Xml) -EntityId ([string]$Sit.Id) -EntityName ([string]$Sit.Name) + $FileDataBase64 = [System.Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($SingleXml)) + + # 'name'/'comments' drive the template list display; deploy reads $Template.Name (resolves to + # 'name' case-insensitively), .Description, and .FileDataBase64. + $Ordered = [ordered]@{ + name = $Sit.Name + comments = $Sit.Description + Description = $Sit.Description + FileDataBase64 = $FileDataBase64 + } } else { - [pscustomobject]$Request.Body - } + # --- Path B: JSON-authored template (simple Pattern or advanced FileDataBase64) --- + $Source = if ($Request.Body.PowerShellCommand) { + $Request.Body.PowerShellCommand | ConvertFrom-Json + } else { + [pscustomobject]$Request.Body + } - $Clean = [ordered]@{} - foreach ($prop in $Source.PSObject.Properties) { - if ($prop.Name -notin $AllowedFields) { continue } - $val = $prop.Value - if ($null -eq $val) { continue } - if ($val -is [string] -and [string]::IsNullOrWhiteSpace($val)) { continue } - $Clean[$prop.Name] = $val - } + $Clean = [ordered]@{} + foreach ($prop in $Source.PSObject.Properties) { + if ($prop.Name -notin $AllowedFields) { continue } + $val = $prop.Value + if ($null -eq $val) { continue } + if ($val -is [string] -and [string]::IsNullOrWhiteSpace($val)) { continue } + $Clean[$prop.Name] = $val + } - if (-not $Clean.Contains('Pattern') -and -not $Clean.Contains('FileDataBase64')) { - $Result = "Template requires either 'Pattern' (simple mode) or 'FileDataBase64' (advanced mode). The list-page action cannot fetch the rule pack XML from existing SITs — author the template JSON in the deploy drawer instead." - Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Warning' - return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::BadRequest - Body = @{ Results = $Result } - }) - } + if (-not $Clean.Contains('Pattern') -and -not $Clean.Contains('FileDataBase64')) { + $Result = "Template requires either 'Pattern' (simple mode) or 'FileDataBase64' (advanced mode), or a tenantFilter + Identity to capture an existing SIT." + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Warning' + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = $Result } + }) + } - $Ordered = [ordered]@{ - name = $Clean['Name'] ?? $Source.Name ?? $Source.name - comments = $Source.Comment ?? $Source.comments - } - foreach ($k in $Clean.Keys) { - if ($Ordered.Contains($k)) { continue } - $Ordered[$k] = $Clean[$k] + $Ordered = [ordered]@{ + name = $Clean['Name'] ?? $Source.Name ?? $Source.name + comments = $Source.Comment ?? $Source.comments + } + foreach ($k in $Clean.Keys) { + if ($Ordered.Contains($k)) { continue } + $Ordered[$k] = $Clean[$k] + } } $JSON = ([pscustomobject]$Ordered | ConvertTo-Json -Depth 10) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoTypeRulePackage.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoTypeRulePackage.ps1 new file mode 100644 index 0000000000000..fd247614f1679 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoTypeRulePackage.ps1 @@ -0,0 +1,47 @@ +Function Invoke-ListSensitiveInfoTypeRulePackage { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitiveInfoType.Read + .DESCRIPTION + Returns the rule pack behind a custom Sensitive Information Type - the parsed rule configuration + (entities with confidence/proximity and their resolved regex/keyword/fingerprint detection) plus + the raw ClassificationRuleCollection XML - so the UI can show what a SIT actually detects. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $RulePackId = $Request.Query.RulePackId ?? $Request.Body.RulePackId + + try { + if ([string]::IsNullOrWhiteSpace($RulePackId)) { throw 'RulePackId is required.' } + + $Pack = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpSensitiveInformationTypeRulePackage' -cmdParams @{ Identity = $RulePackId } -Compliance | + Select-Object * -ExcludeProperty *odata*, *data.type* | Select-Object -First 1 + $Xml = [string]$Pack.ClassificationRuleCollectionXml + + # Reuse the drift comparer's semantic parser to expose a friendly rule configuration. + $Configuration = if (-not [string]::IsNullOrWhiteSpace($Xml)) { ConvertTo-CIPPSitComparable -Xml $Xml } else { @{} } + + $Result = [ordered]@{ + RulePackId = $RulePackId + RuleCollectionName = $Pack.RuleCollectionName + Publisher = $Pack.Publisher + Version = $Pack.Version + Configuration = $Configuration + Xml = $Xml + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $Result = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Result + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 index 3fa5502ee294d..88edcad8111a7 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 @@ -13,12 +13,25 @@ Function Invoke-RemoveSensitiveInfoType { $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + $FingerprintPackId = '00000000-0000-0000-0001-000000000001' try { - $Params = @{ - Identity = $Identity + $Sit = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpSensitiveInformationType' -Compliance | + Where-Object { $_.Name -eq $Identity -or $_.Id -eq $Identity -or $_.Identity -eq $Identity } | Select-Object -First 1 + if (-not $Sit) { + throw "Sensitive Information Type '$Identity' not found." + } + if ($Sit.Publisher -like 'Microsoft*') { + throw "SIT '$($Sit.Name)' is a Microsoft built-in and cannot be deleted." + } + + # Regex/keyword SITs are their own rule package and must be removed at the package level - the + # singular Remove-DlpSensitiveInformationType only removes a SIT from the shared fingerprint pack. + if ($Sit.RulePackId -and $Sit.RulePackId -ne $FingerprintPackId -and $Sit.Type -ne 'Fingerprint') { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-DlpSensitiveInformationTypeRulePackage' -cmdParams @{ Identity = $Sit.RulePackId } -Compliance -useSystemMailbox $true + } else { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-DlpSensitiveInformationType' -cmdParams @{ Identity = $Identity } -Compliance -useSystemMailbox $true } - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-DlpSensitiveInformationType' -cmdParams $Params -Compliance -useSystemMailbox $true $Result = "Deleted Sensitive Information Type $Identity" Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-EditSensitivityLabel.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-EditSensitivityLabel.ps1 index acad97a18693f..5081739fdb737 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-EditSensitivityLabel.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-EditSensitivityLabel.ps1 @@ -23,6 +23,13 @@ Function Invoke-EditSensitivityLabel { } } + # PswsHashtable parameters need the Exchange.GenericHashTable odata type to bind over the AdminApi. + foreach ($HashtableParam in @('AdvancedSettings', 'Settings')) { + if ($Params.ContainsKey($HashtableParam)) { + $Params[$HashtableParam] = ConvertTo-CIPPExoHashtable -InputObject $Params[$HashtableParam] + } + } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-Label' -cmdParams $Params -Compliance -useSystemMailbox $true $Result = "Updated sensitivity label $Identity" Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabel.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabel.ps1 index dc24e819d687b..302babc81eeec 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabel.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabel.ps1 @@ -20,6 +20,19 @@ Function Invoke-ListSensitivityLabel { $labelGuid = $_.Guid @($Policies | Where-Object { $_.Labels -contains $labelGuid -or $_.Labels -contains $_.ImmutableId }) | Select-Object -ExpandProperty Name } + }, + @{l = 'Color'; e = { + # The 'color' advanced setting is only exposed inside the read-only Settings array, + # either as a {Key, Value} object or as the serialized string '[color, #RRGGBB]'. + foreach ($Entry in @($_.Settings)) { + if ($null -eq $Entry) { continue } + if ($Entry -isnot [string] -and $Entry.PSObject.Properties['Key']) { + if ("$($Entry.Key)" -eq 'color') { "$($Entry.Value)"; break } + } elseif ("$Entry" -match '^\[\s*color\s*,\s*(.*?)\s*\]$') { + $Matches[1]; break + } + } + } } $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecSetSecurityIncident.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecSetSecurityIncident.ps1 index 673e7404d8569..d6f01ab1458c2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecSetSecurityIncident.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecSetSecurityIncident.ps1 @@ -1,4 +1,4 @@ -Function Invoke-ExecSetSecurityIncident { +function Invoke-ExecSetSecurityIncident { <# .FUNCTIONALITY Entrypoint @@ -12,27 +12,40 @@ Function Invoke-ExecSetSecurityIncident { $Headers = $Request.Headers - $first = '' # Interact with query parameters or the body of the request. - $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter - $IncidentFilter = $Request.Query.GUID ?? $Request.Body.GUID - $Status = $Request.Query.Status ?? $Request.Body.Status - # $Assigned = $Request.Query.Assigned ?? $Request.Body.Assigned ?? $Headers.'x-ms-client-principal' - $Assigned = $Request.Query.Assigned ?? $Request.Body.Assigned ?? ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails - $Classification = $Request.Query.Classification ?? $Request.Body.Classification - $Determination = $Request.Query.Determination ?? $Request.Body.Determination - $Redirected = $Request.Query.Redirected -as [int] ?? $Request.Body.Redirected -as [int] - $BodyBuild - $AssignBody = '{' + $TenantFilter = $Request.Body.tenantFilter + $IncidentFilter = $Request.Body.GUID + $Status = $Request.Body.Status + $Classification = $Request.Body.Classification + $Determination = $Request.Body.Determination + # Severity autoComplete submits {label, value} + $Severity = $Request.Body.Severity.value + $Comment = $Request.Body.Comment + $Redirected = $Request.Body.Redirected -as [int] + + $AssignToSelf = [System.Convert]::ToBoolean($Request.Body.AssignToSelf) + # Assign-to-self resolves to the caller; other actions omit the assignee so it's preserved. + if ($AssignToSelf -eq $true) { + $Assigned = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails + } + + # Hashtable + ConvertTo-Json so free-text fields (resolvingComment) are escaped correctly. + $BodyObject = [ordered]@{} + $BodyParts = [System.Collections.Generic.List[string]]::new() try { # We won't update redirected incidents because the incident it is redirected to should instead be updated if ($Redirected -lt 1) { # Set received status if ($null -ne $Status) { - $AssignBody += $first + '"status":"' + $Status + '"' - $BodyBuild += $first + 'Set status for incident ' + $IncidentFilter + ' to ' + $Status - $first = ', ' + $BodyObject['status'] = $Status + $BodyParts.Add("status to $Status") + } + + # Set received severity + if ($null -ne $Severity) { + $BodyObject['severity'] = $Severity + $BodyParts.Add("severity to $Severity") } # Set received classification and determination @@ -42,43 +55,47 @@ Function Invoke-ExecSetSecurityIncident { throw } - $AssignBody += $first + '"classification":"' + $Classification + '", "determination":"' + $Determination + '"' - $BodyBuild += $first + 'Set classification & determination for incident ' + $IncidentFilter + ' to ' + $Classification + ' ' + $Determination - $first = ', ' + $BodyObject['classification'] = $Classification + $BodyObject['determination'] = $Determination + $BodyParts.Add("classification & determination to $Classification $Determination") + } + + # Set received resolving comment + if ($null -ne $Comment) { + $BodyObject['resolvingComment'] = $Comment + $BodyParts.Add('resolving comment') } # Set received assignee if ($null -ne $Assigned) { - $AssignBody += $first + '"assignedTo":"' + $Assigned + '"' + $BodyObject['assignedTo'] = $Assigned if ($null -eq $Status) { - $BodyBuild += $first + 'Set assigned for incident ' + $IncidentFilter + ' to ' + $Assigned + $BodyParts.Add("assigned to $Assigned") } - $first = ', ' } - $AssignBody += '}' + $AssignBody = ConvertTo-Json -InputObject $BodyObject -Compress + $BodyBuild = "Set $($BodyParts -join ', ') for incident $IncidentFilter" - $ResponseBody = [pscustomobject]@{'Results' = $BodyBuild } - New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/security/incidents/$IncidentFilter" -type PATCH -tenantid $TenantFilter -body $AssignBody -asApp $true + $Result = $BodyBuild + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/security/incidents/$IncidentFilter" -type PATCH -tenantid $TenantFilter -body $AssignBody -asApp $true Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Update incident $IncidentFilter with values $AssignBody" -Sev 'Info' } else { - $ResponseBody = [pscustomobject]@{'Results' = "Refused to update incident $IncidentFilter with values $AssignBody because it is redirected to another incident" } - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Refused to update incident $IncidentFilter with values $AssignBody because it is redirected to another incident" -Sev 'Info' + $Result = "Refused to update incident $IncidentFilter because it is redirected to another incident" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' } - $body = $ResponseBody $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Failed to update incident $IncidentFilter : $($ErrorMessage.NormalizedError)" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Error' -LogData $ErrorMessage - $body = [pscustomobject]@{'Results' = $Result } $StatusCode = [HttpStatusCode]::InternalServerError } return ([HttpResponseContext]@{ StatusCode = $StatusCode - Body = $body + Body = @{ 'Results' = $Result } }) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-DeleteSharepointSite.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-DeleteSharepointSite.ps1 index 01a8dca2ccf6f..0956440b6995c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-DeleteSharepointSite.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-DeleteSharepointSite.ps1 @@ -19,15 +19,15 @@ function Invoke-DeleteSharepointSite { try { # Validate required parameters if (-not $SiteId) { - throw "SiteId is required" + throw 'SiteId is required' } if (-not $TenantFilter) { - throw "TenantFilter is required" + throw 'TenantFilter is required' } # Validate SiteId format (GUID) if ($SiteId -notmatch '^(\{)?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(\})?$') { - throw "SiteId must be a valid GUID" + throw 'SiteId must be a valid GUID' } $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter @@ -35,42 +35,58 @@ function Invoke-DeleteSharepointSite { # Get site information using SharePoint admin API $SiteInfoUri = "$($SharePointInfo.AdminUrl)/_api/SPO.Tenant/sites('$SiteId')" - # Add the headers that SharePoint REST API expects - $ExtraHeaders = @{ - 'accept' = 'application/json' - 'content-type' = 'application/json' + # The SPO.Tenant vroute GET needs odata-version 4.0; the manager POST endpoints must + # NOT receive it - sending it flips them onto a pipeline that rejects the call with + # 'Elevated context should be used only to create service asserted level PoP'. + $GetHeaders = @{ + 'accept' = 'application/json' 'odata-version' = '4.0' } + $PostHeaders = @{ + 'accept' = 'application/json' + } - $SiteInfo = New-GraphGETRequest -scope "$($SharePointInfo.AdminUrl)/.default" -uri $SiteInfoUri -tenantid $TenantFilter -extraHeaders $ExtraHeaders + try { + $SiteInfo = New-GraphGETRequest -scope "$($SharePointInfo.AdminUrl)/.default" -uri $SiteInfoUri -tenantid $TenantFilter -extraHeaders $GetHeaders -UseCertificate -AsApp $true + } catch { + throw "Could not retrieve site information from the SharePoint Admin API: $($_.Exception.Message)" + } if (-not $SiteInfo) { - throw "Could not retrieve site information from SharePoint Admin API" + throw 'Could not retrieve site information from SharePoint Admin API' } # Determine if site is group-connected based on GroupId - $IsGroupConnected = $SiteInfo.GroupId -and $SiteInfo.GroupId -ne "00000000-0000-0000-0000-000000000000" + $IsGroupConnected = $SiteInfo.GroupId -and $SiteInfo.GroupId -ne '00000000-0000-0000-0000-000000000000' if ($IsGroupConnected) { - # Use GroupSiteManager/Delete for group-connected sites + # Group-connected sites: GroupSiteManager/Delete soft-deletes the backing M365 + # group (and Team) together with the site and registers it in the SPO deleted + # sites list. Runs with the delegated token (the CIPP service account is a + # SharePoint admin); see the header note above for why odata-version is omitted. $body = @{ siteUrl = $SiteInfo.Url } - $DeleteUri = "$($SharePointInfo.AdminUrl)/_api/GroupSiteManager/Delete" + try { + $null = New-GraphPOSTRequest -scope "$($SharePointInfo.AdminUrl)/.default" -uri "$($SharePointInfo.AdminUrl)/_api/GroupSiteManager/Delete" -body (ConvertTo-Json -Depth 10 -InputObject $body) -tenantid $TenantFilter -contentType 'application/json' -AddedHeaders $PostHeaders + } catch { + throw "Site deletion request failed (GroupSiteManager/Delete): $($_.Exception.Message)" + } + $Results = "Successfully initiated deletion of group-connected SharePoint site $($SiteInfo.Url); the backing M365 group (and Team, if any) is deleted with it. This can take some time to complete in the background." } else { - # Use SPSiteManager/delete for regular sites + # Regular sites: SPSiteManager/delete denies delegated tokens (even with a + # certificate assertion) with E_ACCESSDENIED - it requires app-only cert auth. $body = @{ siteId = $SiteId } - $DeleteUri = "$($SharePointInfo.AdminUrl)/_api/SPSiteManager/delete" + try { + $null = New-GraphPOSTRequest -scope "$($SharePointInfo.AdminUrl)/.default" -uri "$($SharePointInfo.AdminUrl)/_api/SPSiteManager/delete" -body (ConvertTo-Json -Depth 10 -InputObject $body) -tenantid $TenantFilter -contentType 'application/json' -AddedHeaders $PostHeaders -UseCertificate -AsApp $true + } catch { + throw "Site deletion request failed (SPSiteManager/delete): $($_.Exception.Message)" + } + $Results = "Successfully initiated deletion of SharePoint site with ID $SiteId, this process can take some time to complete in the background" } - # Execute the deletion - $DeleteResult = New-GraphPOSTRequest -scope "$($SharePointInfo.AdminUrl)/.default" -uri $DeleteUri -body (ConvertTo-Json -Depth 10 -InputObject $body) -tenantid $TenantFilter -extraHeaders $ExtraHeaders - - $SiteTypeMsg = if ($IsGroupConnected) { "group-connected" } else { "regular" } - $Results = "Successfully initiated deletion of $SiteTypeMsg SharePoint site with ID $SiteId, this process can take some time to complete in the background" - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info $StatusCode = [HttpStatusCode]::OK @@ -83,7 +99,7 @@ function Invoke-DeleteSharepointSite { # Associate values to output bindings return ([HttpResponseContext]@{ - StatusCode = $StatusCode - Body = @{ 'Results' = $Results } - }) + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecBulkRemoveSharingLinks.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecBulkRemoveSharingLinks.ps1 new file mode 100644 index 0000000000000..a4608323d50ab --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecBulkRemoveSharingLinks.ps1 @@ -0,0 +1,104 @@ +function Invoke-ExecBulkRemoveSharingLinks { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Revokes all sharing links / external direct grants on one site in bulk, sourced from + the SharePointSharingLinks reporting cache. Scope selects which classifications are + revoked: Anonymous (anyone links only), External (anonymous + external), or All + (every cached link on the site, including internal). Because the source is the + reporting cache, links created after the last sharing sync are not covered. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl + $Scope = $Request.Body.Scope.value ?? $Request.Body.Scope ?? 'Anonymous' + + $ScopeClassifications = @{ + 'Anonymous' = @('Anonymous') + 'External' = @('Anonymous', 'External') + 'All' = @('Anonymous', 'External', 'Internal') + } + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + if (-not $ScopeClassifications.ContainsKey([string]$Scope)) { + throw "Invalid scope '$Scope'. Valid values: $($ScopeClassifications.Keys -join ', ')." + } + $Classifications = $ScopeClassifications[[string]$Scope] + + try { + $AllLinks = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'SharePointSharingLinks') + } catch { + throw "Could not read the sharing links cache: $($_.Exception.Message). Run a sharing report sync first." + } + $NormalizedSite = $SiteUrl.TrimEnd('/') + $Targets = @($AllLinks | Where-Object { + "$($_.siteUrl)".TrimEnd('/') -eq $NormalizedSite -and $_.classification -in $Classifications -and $_.driveId -and $_.itemId -and $_.permissionId + }) + + if ($Targets.Count -eq 0) { + $Results = "No cached $($Scope -eq 'All' ? '' : "$Scope ")sharing links found for $SiteUrl. Links created since the last sharing sync are not in the cache - run a sync from the Sharing Report page to refresh." + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ 'Results' = $Results } + }) + } + + $Revoked = [System.Collections.Generic.List[string]]::new() + $Failed = [System.Collections.Generic.List[string]]::new() + foreach ($Link in $Targets) { + try { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/drives/$($Link.driveId)/items/$($Link.itemId)/permissions/$($Link.permissionId)" -tenantid $TenantFilter -type DELETE -asapp $true + $Revoked.Add("$($Link.fileName) ($($Link.classification))") + if ($Link.id) { + try { + Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -ItemId $Link.id + } catch { + Write-Information "Revoked link but could not update reporting cache row $($Link.id): $($_.Exception.Message)" + } + } + } catch { + # A 404 means the link was already gone; treat as revoked and clean the cache row. + if ($_.Exception.Message -match 'itemNotFound|404') { + $Revoked.Add("$($Link.fileName) (already removed)") + if ($Link.id) { + try { Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -ItemId $Link.id } catch {} + } + } else { + $Failed.Add("$($Link.fileName): $($_.Exception.Message)") + } + } + } + + $Messages = [System.Collections.Generic.List[string]]::new() + $Messages.Add("Revoked $($Revoked.Count) of $($Targets.Count) $Scope sharing link(s) on $SiteUrl.") + if ($Failed.Count -gt 0) { + $Messages.Add("Failed: $($Failed -join '; ')") + } + $Messages.Add('Note: links created since the last sharing report sync are not covered.') + $Results = $Messages -join ' ' + if ($Revoked.Count -eq 0 -and $Failed.Count -gt 0) { + throw $Results + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to bulk revoke sharing links on $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSPOExternalUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSPOExternalUser.ps1 new file mode 100644 index 0000000000000..945cd60f15cdf --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSPOExternalUser.ps1 @@ -0,0 +1,85 @@ +function Invoke-ExecRemoveSPOExternalUser { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Fully removes an external user's guest access: deletes their Entra guest account (when + one exists) AND removes them from every SharePoint site they hold membership on, in one + pass - so no orphaned accounts or lingering site access are left behind. The inert + SharePoint external-store entry cannot be deleted (Microsoft deprecated + RemoveExternalUsers) and ages out on its own; sharing links the user received are + revoked separately via the Sharing Report. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $EntraUserId = $Request.Body.EntraUserId + $LoginName = $Request.Body.LoginName + $SiteUrls = @($Request.Body.SiteUrls) | Where-Object { $_ } + $DisplayName = $Request.Body.DisplayName ?? $EntraUserId ?? $LoginName + + try { + if (-not $EntraUserId -and $SiteUrls.Count -eq 0) { + throw 'This entry has no Entra guest account and no known site memberships. The remaining SharePoint store entry cannot be removed (Microsoft deprecated the API) and ages out on its own; revoke any sharing links they hold via the Sharing Report.' + } + + $Messages = [System.Collections.Generic.List[string]]::new() + $Errors = [System.Collections.Generic.List[string]]::new() + + # 1. Strip the SharePoint footprint first (needs the login; falls back to the Entra UPN). + if ($SiteUrls.Count -gt 0) { + $RemovalLogin = $LoginName + if (-not $RemovalLogin -and $EntraUserId) { + try { + $RemovalLogin = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$($EntraUserId)?`$select=userPrincipalName" -tenantid $TenantFilter -AsApp $true).userPrincipalName + } catch { + $Errors.Add("Could not resolve the user's login for site removal: $($_.Exception.Message)") + } + } + if ($RemovalLogin) { + $Removal = Remove-CIPPSPOSiteUser -TenantFilter $TenantFilter -SiteUrls $SiteUrls -LoginName $RemovalLogin + if ($Removal.Succeeded.Count -gt 0) { + $Messages.Add("Removed from $($Removal.Succeeded.Count) site(s): $($Removal.Succeeded -join ', ').") + } + if ($Removal.Failed.Count -gt 0) { + $Errors.Add("Site removal failed on: $($Removal.Failed -join '; ')") + } + } + } + + # 2. Delete the Entra guest account so the user cannot sign in anywhere. + if ($EntraUserId) { + try { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/users/$EntraUserId" -tenantid $TenantFilter -type DELETE -body '' -asapp $true + $Messages.Add('Deleted the Entra guest account, blocking their sign-in.') + } catch { + $Errors.Add("Deleting the Entra guest account failed: $($_.Exception.Message)") + } + } + + if ($Messages.Count -eq 0) { + throw ($Errors -join ' ') + } + $Results = "Removed guest access for $($DisplayName): $($Messages -join ' ')" + if ($Errors.Count -gt 0) { + $Results += " Issues: $($Errors -join '; ')" + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to remove guest access for $($DisplayName): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSharingLink.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSharingLink.ps1 new file mode 100644 index 0000000000000..f8d6d448b8888 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSharingLink.ps1 @@ -0,0 +1,54 @@ +function Invoke-ExecRemoveSharingLink { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Revokes a sharing link (or direct sharing grant) on a SharePoint or OneDrive item by + deleting the permission from the drive item. Also removes the revoked link from the + SharePointSharingLinks reporting cache so the sharing report reflects it immediately. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $DriveId = $Request.Body.DriveId + $ItemId = $Request.Body.ItemId + $PermissionId = $Request.Body.PermissionId + $FileName = $Request.Body.FileName + $CacheId = $Request.Body.CacheId + + try { + if ([string]::IsNullOrWhiteSpace($DriveId) -or [string]::IsNullOrWhiteSpace($ItemId) -or [string]::IsNullOrWhiteSpace($PermissionId)) { + throw 'DriveId, ItemId and PermissionId are required.' + } + + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/drives/$DriveId/items/$ItemId/permissions/$PermissionId" -tenantid $TenantFilter -type DELETE -asapp $true + + # Best effort: drop the revoked link from the reporting cache so the report updates without a full sync. + if (-not [string]::IsNullOrWhiteSpace($CacheId)) { + try { + Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -ItemId $CacheId + } catch { + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Revoked sharing link but could not update the reporting cache: $($_.Exception.Message)" -sev Warning + } + } + + $Result = "Successfully revoked sharing link$(if ($FileName) { " for $FileName" })." + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to revoke sharing link$(if ($FileName) { " for $FileName" }). Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 new file mode 100644 index 0000000000000..a2315847ea6ae --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 @@ -0,0 +1,55 @@ +function Invoke-ExecRemoveSiteUser { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Removes a user from one or more SharePoint sites entirely: deleting the site user + removes them from every site group and direct permission grant at once. Uses the + SharePoint REST API with certificate authentication. Note this does not revoke + sharing links the user received by mail; use the sharing link actions for those. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + # Single site (SiteUrl) or several at once (SiteUrls, e.g. from the External Users report). + $SiteUrls = @($Request.Body.SiteUrls ?? $Request.Body.SiteUrl) | Where-Object { $_ } + # The picker supplies the SP claims login via addedFields; fall back to building it from the UPN. + $LoginName = $Request.Body.user.addedFields.LoginName ?? $Request.Body.user.value + $Label = $Request.Body.DisplayName ?? $Request.Body.user.value ?? $LoginName + + try { + if ($SiteUrls.Count -eq 0) { throw 'SiteUrl is required.' } + if (-not $LoginName) { throw 'No user was selected.' } + + $Removal = Remove-CIPPSPOSiteUser -TenantFilter $TenantFilter -SiteUrls $SiteUrls -LoginName $LoginName + + $Messages = [System.Collections.Generic.List[string]]::new() + if ($Removal.Succeeded.Count -gt 0) { + $Messages.Add("Successfully removed $Label (all site groups and direct permissions) from: $($Removal.Succeeded -join ', ').") + } + if ($Removal.Failed.Count -gt 0) { + $Messages.Add("Failed on: $($Removal.Failed -join '; ')") + } + $Results = $Messages -join ' ' + if ($Removal.Succeeded.Count -eq 0) { + throw $Results + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to remove $Label from the selected site(s): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreDeletedSite.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreDeletedSite.ps1 new file mode 100644 index 0000000000000..88009fadf8184 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreDeletedSite.ps1 @@ -0,0 +1,40 @@ +function Invoke-ExecRestoreDeletedSite { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Restores a deleted SharePoint site from the tenant recycle bin via the SharePoint + admin CSOM API. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl ?? $Request.Body.Url + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + $Operation = Restore-CIPPSPODeletedSite -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Results = if ($Operation -and -not $Operation.IsComplete) { + "Restore of $SiteUrl has started. Large sites can take a while to finish restoring." + } else { + "Successfully restored $SiteUrl from the tenant recycle bin." + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to restore $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreRecycleBinItems.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreRecycleBinItems.ps1 new file mode 100644 index 0000000000000..cfad4b5705659 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreRecycleBinItems.ps1 @@ -0,0 +1,52 @@ +function Invoke-ExecRestoreRecycleBinItems { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.SiteRecycleBin.ReadWrite + .DESCRIPTION + Restores one or more items from a SharePoint site's recycle bin via the SharePoint + REST API RestoreByIds method with certificate authentication. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl + $Ids = @($Request.Body.Ids) | Where-Object { $_ } + $ItemNames = @($Request.Body.ItemNames) | Where-Object { $_ } + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + if ($Ids.Count -eq 0) { throw 'No recycle bin items were selected.' } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + $RestoreBody = ConvertTo-Json -Compress -Depth 5 -InputObject @{ ids = @($Ids) } + try { + $null = New-GraphPostRequest -uri "$BaseUri/site/RecycleBin/RestoreByIds" -tenantid $TenantFilter -scope $Scope -type POST -body $RestoreBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "RestoreByIds failed: $($_.Exception.Message)" + } + + $Label = if ($ItemNames.Count -gt 0) { $ItemNames -join ', ' } else { "$($Ids.Count) item(s)" } + $Results = "Successfully restored $Label from the recycle bin of $SiteUrl." + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to restore recycle bin items on $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetLibraryPermission.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetLibraryPermission.ps1 new file mode 100644 index 0000000000000..f5e1a0b08d8b3 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetLibraryPermission.ps1 @@ -0,0 +1,123 @@ +function Invoke-ExecSetLibraryPermission { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Grants users and/or groups a SharePoint permission level (Read, Contribute, Edit, Design + or Full Control) on a document library via the SharePoint REST API. Principals are + resolved with ensureuser, role inheritance on the library is broken (copying the + existing permissions) when it still inherits, and the role assignment is added with + addroleassignment. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl + $ListId = $Request.Body.ListId + $LibraryName = $Request.Body.LibraryName + $PermissionLevel = $Request.Body.PermissionLevel + $Users = @($Request.Body.Users) + $Groups = @($Request.Body.Groups) + + # Standard SharePoint role definition IDs. + $RoleDefinitionIds = @{ + 'read' = 1073741826 + 'contribute' = 1073741827 + 'design' = 1073741828 + 'fullControl' = 1073741829 + 'edit' = 1073741830 + } + + try { + $RoleDefId = $RoleDefinitionIds[[string]$PermissionLevel] + + # Build the claims-encoded logon names for ensureuser. + $Principals = [System.Collections.Generic.List[object]]::new() + foreach ($User in $Users) { + if ($null -eq $User -or -not $User.value) { continue } + $Principals.Add([PSCustomObject]@{ + LogonName = "i:0#.f|membership|$($User.value)" + Label = "$($User.value)" + }) + } + foreach ($Group in $Groups) { + if ($null -eq $Group -or -not $Group.value) { continue } + # Microsoft 365 groups use the federated directory claim; security groups the tenant claim. + $IsUnified = @($Group.addedFields.groupTypes) -contains 'Unified' + $LogonName = if ($IsUnified) { + "c:0o.c|federateddirectoryclaimprovider|$($Group.value)" + } else { + "c:0t.c|tenant|$($Group.value)" + } + $Principals.Add([PSCustomObject]@{ + LogonName = $LogonName + Label = "$($Group.label ?? $Group.value)" + }) + } + if ($Principals.Count -eq 0) { + throw 'No users or groups selected.' + } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + # Break role inheritance (copying the existing permissions) when the library still inherits. + $ListInfo = New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$ListId')?`$select=HasUniqueRoleAssignments" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + if (-not $ListInfo.HasUniqueRoleAssignments) { + $null = New-GraphPostRequest -uri "$BaseUri/web/lists(guid'$ListId')/breakroleinheritance(copyRoleAssignments=true,clearSubscopes=false)" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } + + $Granted = [System.Collections.Generic.List[string]]::new() + $Failed = [System.Collections.Generic.List[string]]::new() + foreach ($Principal in $Principals) { + try { + $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $Principal.LogonName } + $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + if (-not $EnsuredUser.Id) { + throw 'Could not resolve principal on the site.' + } + $null = New-GraphPostRequest -uri "$BaseUri/web/lists(guid'$ListId')/roleassignments/addroleassignment(principalid=$($EnsuredUser.Id),roledefid=$RoleDefId)" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + $Granted.Add($Principal.Label) + } catch { + $Failed.Add("$($Principal.Label) ($($_.Exception.Message))") + } + } + + $LevelLabel = switch ([string]$PermissionLevel) { + 'fullControl' { 'Full Control' } + default { (Get-Culture).TextInfo.ToTitleCase([string]$PermissionLevel) } + } + $Messages = [System.Collections.Generic.List[string]]::new() + if ($Granted.Count -gt 0) { + $Messages.Add("Successfully granted $LevelLabel on library $LibraryName to $($Granted -join ', ').") + } + if ($Failed.Count -gt 0) { + $Messages.Add("Failed for $($Failed -join '; ').") + } + $Result = $Messages -join ' ' + if ($Granted.Count -gt 0) { + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Info + $StatusCode = [HttpStatusCode]::OK + } else { + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Error + $StatusCode = [HttpStatusCode]::BadRequest + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to set permission on library $LibraryName. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 index 1949aac407b29..fdffad7a36c74 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 @@ -4,37 +4,127 @@ function Invoke-ExecSetSharePointMember { Entrypoint .ROLE Sharepoint.Site.ReadWrite + .DESCRIPTION + Adds or removes a user in a SharePoint site role (Owners, Members or Visitors). + Group-connected sites manage Owners/Members through the backing M365 group via Graph; + Visitors (and classic/communication sites entirely) are managed through the site's + associated SharePoint role groups via the SharePoint REST API using certificate + authentication. Removals sourced from ListSiteMembers carry the group and type of the + selected entry, so users directly added to a role group on a group-connected site are + removed from that group rather than from the M365 group. #> [CmdletBinding()] param($Request, $TriggerMetadata) - $Headers = $Request.Headers - - # Interact with query parameters or the body of the request. + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers $TenantFilter = $Request.Body.tenantFilter + $UPN = $Request.Body.user.value + $Add = $Request.Body.Add -eq $true + + # Role comes from the removal picker's selected entry when present, else from the form. + $Role = $Request.Body.user.addedFields.Group ?? $Request.Body.Role ?? 'Members' + $MemberType = $Request.Body.user.addedFields.Type + $AssociatedGroups = @{ + 'Owners' = 'associatedownergroup' + 'Members' = 'associatedmembergroup' + 'Visitors' = 'associatedvisitorgroup' + } try { - if ($Request.Body.SharePointType -eq 'Group') { + if (-not $UPN) { throw 'No user was selected.' } + if (-not $AssociatedGroups.ContainsKey([string]$Role)) { + throw "Invalid role '$Role'. Valid roles are: $($AssociatedGroups.Keys -join ', ')." + } + + $IsGroupSite = $Request.Body.SharePointType -eq 'Group' + # Owners/Members of a group-connected site live in the M365 group. Visitors are always + # a SharePoint role group. A removal of a directly-added user (Type 'User') on a group + # site targets the SharePoint role group instead of the M365 group. + $UseGraphGroup = $IsGroupSite -and $Role -ne 'Visitors' -and ($Add -or $MemberType -ne 'User') + + if ($UseGraphGroup) { if ($Request.Body.GroupID -match '^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$') { $GroupId = $Request.Body.GroupID } else { $GroupId = (New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=mail eq '$($Request.Body.GroupID)' or proxyAddresses/any(x:endsWith(x,'$($Request.Body.GroupID)')) or mailNickname eq '$($Request.Body.GroupID)'" -ComplexFilter -tenantid $TenantFilter).id } - if ($Request.Body.Add -eq $true) { - $Results = Add-CIPPGroupMember -GroupType 'Team' -GroupID $GroupID -Member $Request.Body.user.value -TenantFilter $TenantFilter -Headers $Headers + if ($Role -eq 'Owners') { + $UserID = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$UPN`?`$select=id" -tenantid $TenantFilter).id + if ($Add) { + $OwnerBody = ConvertTo-Json -Compress -InputObject @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$UserID" } + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/groups/$GroupId/owners/`$ref" -tenantid $TenantFilter -type POST -body $OwnerBody + $Results = "Successfully added $UPN as an owner of the M365 group backing the site." + } else { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/groups/$GroupId/owners/$UserID/`$ref" -tenantid $TenantFilter -type DELETE -body '' + $Results = "Successfully removed $UPN as an owner of the M365 group backing the site." + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info } else { - $UserID = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$($Request.Body.user.value)" -tenantid $TenantFilter).id - $Results = Remove-CIPPGroupMember -GroupType 'Team' -GroupID $GroupID -Member $UserID -TenantFilter $TenantFilter -Headers $Headers + if ($Add) { + $Results = Add-CIPPGroupMember -GroupType 'Team' -GroupID $GroupID -Member $UPN -TenantFilter $TenantFilter -Headers $Headers + } else { + $UserID = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$UPN`?`$select=id" -tenantid $TenantFilter).id + $Results = Remove-CIPPGroupMember -GroupType 'Team' -GroupID $GroupID -Member $UserID -TenantFilter $TenantFilter -Headers $Headers + } } $StatusCode = [HttpStatusCode]::OK } else { - $StatusCode = [HttpStatusCode]::BadRequest - $Results = 'This type of SharePoint site is not supported.' + # SharePoint role group management via REST with certificate auth. + $SiteUrl = $Request.Body.URL + if (-not $SiteUrl) { throw 'No site URL was provided for this site.' } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $RoleGroup = $AssociatedGroups[[string]$Role] + $RoleLabel = ([string]$Role).ToLower().TrimEnd('s') + $Article = if ($RoleLabel -match '^[aeiou]') { 'an' } else { 'a' } + + try { + $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = "i:0#.f|membership|$UPN" } + $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "Could not resolve $UPN on the site (ensureuser): $($_.Exception.Message)" + } + if (-not $EnsuredUser.Id) { + throw "Could not resolve $UPN on the site." + } + + if ($Add) { + # Same shape PnP sends: an SP.User entity posted to the group's users + # collection, which requires the odata=verbose content type. + $AddBody = ConvertTo-Json -Compress -Depth 5 -InputObject @{ + '__metadata' = @{ 'type' = 'SP.User' } + 'LoginName' = $EnsuredUser.LoginName + } + try { + $null = New-GraphPostRequest -uri "$BaseUri/web/$RoleGroup/users" -tenantid $TenantFilter -scope $Scope -type POST -body $AddBody -contentType 'application/json;odata=verbose' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "Could not add $UPN to the site $Role group: $($_.Exception.Message)" + } + $Results = "Successfully added $UPN as $Article $RoleLabel of $SiteUrl." + } else { + try { + $null = New-GraphPostRequest -uri "$BaseUri/web/$RoleGroup/users/removebyid($($EnsuredUser.Id))" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + if ($_.Exception.Message -match 'Can not find the user') { + throw "$UPN is not in the site's $Role group." + } + throw "Could not remove $UPN from the site $Role group: $($_.Exception.Message)" + } + $Results = "Successfully removed $UPN as $Article $RoleLabel of $SiteUrl." + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK } } catch { - $Results = $_.Exception.Message - $StatusCode = [HttpStatusCode]::InternalServerError + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to modify $Role for $($Request.Body.URL ?? $Request.Body.GroupID). Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSiteProperties.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSiteProperties.ps1 new file mode 100644 index 0000000000000..c8395de597814 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSiteProperties.ps1 @@ -0,0 +1,136 @@ +function Invoke-ExecSetSiteProperties { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Sets admin-level properties on a single SharePoint site (sharing, lifecycle, version + policy) through Set-CIPPSPOSite. Only whitelisted properties are accepted; enum values + arrive as friendly names (matching Invoke-ListSiteProperties output) and are converted + to their numeric CSOM values here. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl + + # Friendly name -> SPO enum value maps + $EnumMaps = @{ + SharingCapability = @{ 'Disabled' = 0; 'ExternalUserSharingOnly' = 1; 'ExternalUserAndGuestSharing' = 2; 'ExistingExternalUserSharingOnly' = 3 } + DefaultSharingLinkType = @{ 'None' = 0; 'Direct' = 1; 'Internal' = 2; 'AnonymousAccess' = 3 } + DefaultLinkPermission = @{ 'None' = 0; 'View' = 1; 'Edit' = 2 } + SharingDomainRestrictionMode = @{ 'None' = 0; 'AllowList' = 1; 'BlockList' = 2 } + } + $StringProperties = @('Title', 'SharingAllowedDomainList', 'SharingBlockedDomainList') + $BoolProperties = @('OverrideTenantAnonymousLinkExpirationPolicy', 'InheritVersionPolicyFromTenant', 'EnableAutoExpirationVersionTrim', 'ApplyToNewDocumentLibraries', 'ApplyToExistingDocumentLibraries') + $IntProperties = @('AnonymousLinkExpirationInDays', 'MajorVersionLimit', 'ExpireVersionsAfterDays') + $Int64Properties = @('StorageMaximumLevel', 'StorageWarningLevel') + $ValidLockStates = @('Unlock', 'ReadOnly', 'NoAccess') + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + + $Properties = @{} + $Changes = [System.Collections.Generic.List[string]]::new() + + foreach ($Key in $EnumMaps.Keys) { + $Value = $Request.Body.$Key.value ?? $Request.Body.$Key + if ($null -ne $Value -and "$Value" -ne '') { + if (-not $EnumMaps[$Key].ContainsKey([string]$Value)) { + throw "Invalid value '$Value' for $Key. Valid values: $($EnumMaps[$Key].Keys -join ', ')." + } + $Properties[$Key] = [int]$EnumMaps[$Key][[string]$Value] + $Changes.Add("$Key=$Value") + } + } + + $LockState = $Request.Body.LockState.value ?? $Request.Body.LockState + if ($null -ne $LockState -and "$LockState" -ne '') { + if ($LockState -notin $ValidLockStates) { + throw "Invalid LockState '$LockState'. Valid values: $($ValidLockStates -join ', ')." + } + $Properties['LockState'] = [string]$LockState + $Changes.Add("LockState=$LockState") + } + + foreach ($Key in $StringProperties) { + $Value = $Request.Body.$Key + if ($null -ne $Value) { + $Properties[$Key] = [string]$Value + $Changes.Add("$Key=$Value") + } + } + foreach ($Key in $BoolProperties) { + $Value = $Request.Body.$Key + if ($null -ne $Value) { + $Properties[$Key] = [bool]$Value + $Changes.Add("$Key=$Value") + } + } + foreach ($Key in $IntProperties) { + $Value = $Request.Body.$Key + if ($null -ne $Value -and "$Value" -ne '') { + $Properties[$Key] = [int]$Value + $Changes.Add("$Key=$Value") + } + } + foreach ($Key in $Int64Properties) { + $Value = $Request.Body.$Key + if ($null -ne $Value -and "$Value" -ne '') { + $Properties[$Key] = [int64]$Value + $Changes.Add("$Key=$Value") + } + } + + if ($Properties.Count -eq 0) { + throw 'No valid properties were provided to set.' + } + + # Group-connected sites only accept a small subset of tenant site properties; SPO + # rejects the whole request if any other property is included. Filter to the + # supported set and report what was skipped. + $Site = Get-CIPPSPOSite -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $IsGroupSite = $Site.GroupId -and $Site.GroupId -notmatch '^0{8}-' + $Skipped = [System.Collections.Generic.List[string]]::new() + if ($IsGroupSite) { + $GroupSiteAllowed = @('SharingCapability', 'DefaultSharingLinkType', 'DefaultLinkPermission', 'LockState', 'StorageMaximumLevel', 'StorageWarningLevel') + foreach ($Key in @($Properties.Keys)) { + if ($Key -notin $GroupSiteAllowed) { + $Properties.Remove($Key) + $Skipped.Add($Key) + } + } + if ($Properties.Count -eq 0) { + throw "None of the selected properties can be changed on a group-connected site. Supported: $($GroupSiteAllowed -join ', ')." + } + } + + $Response = Set-CIPPSPOSite -TenantFilter $TenantFilter -SiteUrl $SiteUrl -Properties $Properties + $CsomError = ($Response | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage + if ($CsomError) { + throw $CsomError + } + + $AppliedChanges = $Changes | Where-Object { ($_ -split '=')[0] -in $Properties.Keys } + $Results = "Successfully updated site properties for $($SiteUrl): $($AppliedChanges -join ', ')" + if ($Skipped.Count -gt 0) { + $Results += " Skipped (not supported on group-connected sites): $($Skipped -join ', ')." + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to update site properties for $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListDeletedSites.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListDeletedSites.ps1 new file mode 100644 index 0000000000000..5020b45d4b2f3 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListDeletedSites.ps1 @@ -0,0 +1,49 @@ +function Invoke-ListDeletedSites { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Lists deleted SharePoint sites still restorable from the tenant recycle bin, via the + SharePoint admin CSOM API. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + + # CSOM verbose JSON dates arrive as '/Date(year,month,day,h,m,s,ms)/' with a 0-based month. + function ConvertFrom-CsomDate($Value) { + if ("$Value" -match '/Date\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/') { + return ([datetime]::new([int]$Matches[1], ([int]$Matches[2] + 1), [int]$Matches[3], [int]$Matches[4], [int]$Matches[5], [int]$Matches[6], [System.DateTimeKind]::Utc)).ToString('yyyy-MM-ddTHH:mm:ssZ') + } + return $Value + } + + try { + $DeletedSites = Get-CIPPSPODeletedSites -TenantFilter $TenantFilter + $Body = @($DeletedSites | ForEach-Object { + [PSCustomObject]@{ + # Deleted-site CSOM entries carry no Title; derive a display name from the URL. + Name = ([uri]$_.Url).Segments[-1].TrimEnd('/') + Url = $_.Url + SiteId = $_.SiteId + Status = $_.Status + DeletionTime = ConvertFrom-CsomDate $_.DeletionTime + DaysRemaining = $_.DaysRemaining + StorageMaximumLevel = $_.StorageMaximumLevel + } + }) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to list deleted sites: $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointExternalUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointExternalUsers.ps1 new file mode 100644 index 0000000000000..803dfa847989b --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointExternalUsers.ps1 @@ -0,0 +1,164 @@ +function Invoke-ListSharePointExternalUsers { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Lists external/guest users known to SharePoint from two sources: the tenant external + users store (populated when a guest first redeems a share) and a sweep of every site's + user list (which also catches guests who were granted membership but never signed in). + Every entry is classified against Entra: 'Entra B2B' (live Entra guest), 'Orphaned B2B' + (the Entra guest was deleted but SharePoint still references them) or 'SharePoint-only' + (legacy email-authenticated guest that never had an Entra object). + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + + # CSOM verbose JSON dates arrive as '/Date(year,month,day,h,m,s,ms)/' with a 0-based month. + function ConvertFrom-CsomDate($Value) { + if ("$Value" -match '/Date\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/') { + return ([datetime]::new([int]$Matches[1], ([int]$Matches[2] + 1), [int]$Matches[3], [int]$Matches[4], [int]$Matches[5], [int]$Matches[6], [System.DateTimeKind]::Utc)).ToString('yyyy-MM-ddTHH:mm:ssZ') + } + return $Value + } + + try { + # --- Source 1: tenant external users store --- + try { + $StoreUsers = @(Get-CIPPSPOExternalUsers -TenantFilter $TenantFilter) + } catch { + throw "Could not enumerate the SharePoint external users store: $($_.Exception.Message)" + } + + # --- Source 2: guest entries in every site's user list --- + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $SiteGuests = @{} + try { + # NB: getAllSites returns an empty set when $select is combined with this $filter. + $Sites = @((New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/getAllSites?`$filter=isPersonalSite eq false&`$top=999" -tenantid $TenantFilter -AsApp $true).webUrl) | Where-Object { $_ } + } catch { + $Sites = @() + Write-Information "Site enumeration failed; external users report is store-only: $($_.Exception.Message)" + } + foreach ($SiteUrl in $Sites) { + try { + $Users = New-GraphGetRequest -uri "$($SiteUrl.TrimEnd('/'))/_api/web/siteusers?`$select=Title,Email,LoginName,IsShareByEmailGuestUser,IsEmailAuthenticationGuestUser" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + continue # sites the app cannot reach (locked etc.) are skipped + } + foreach ($User in $Users) { + $IsGuest = [bool]$User.IsShareByEmailGuestUser -or [bool]$User.IsEmailAuthenticationGuestUser -or $User.LoginName -match '(?i)#ext#|urn%3aspo%3aguest' + if (-not $IsGuest) { continue } + $Key = ($User.LoginName -split '\|')[-1].ToLower() + if (-not $SiteGuests.ContainsKey($Key)) { + $SiteGuests[$Key] = [PSCustomObject]@{ + Title = $User.Title + Email = $User.Email + LoginName = $User.LoginName + Sites = [System.Collections.Generic.List[string]]::new() + } + } + [void]$SiteGuests[$Key].Sites.Add($SiteUrl) + } + } + + # --- Entra guest sweep for the join --- + $EntraGuests = @() + if ($StoreUsers.Count -gt 0 -or $SiteGuests.Count -gt 0) { + try { + $EntraGuests = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users?`$filter=userType eq 'Guest'&`$select=id,userPrincipalName,mail&`$top=999" -tenantid $TenantFilter -AsApp $true) + } catch { + throw "Could not list Entra guest users for cross-referencing: $($_.Exception.Message)" + } + } + $GuestsByUpn = @{} + $GuestsByMail = @{} + foreach ($Guest in $EntraGuests) { + if ($Guest.userPrincipalName) { $GuestsByUpn[$Guest.userPrincipalName.ToLower()] = $Guest } + if ($Guest.mail) { $GuestsByMail[$Guest.mail.ToLower()] = $Guest } + } + + function Resolve-GuestClassification($LoginName, $AcceptedAs, $InvitedAs) { + if ("$LoginName" -match '(?i)urn(%3a|:)spo(%3a|:)guest') { + return @('SharePoint-only (email authenticated)', $null) + } + $ClaimsUpn = ("$LoginName" -split '\|')[-1].ToLower() + $EntraUser = $GuestsByUpn[$ClaimsUpn] + if (-not $EntraUser -and $AcceptedAs) { $EntraUser = $GuestsByMail[([string]$AcceptedAs).ToLower()] } + if (-not $EntraUser -and $InvitedAs) { $EntraUser = $GuestsByMail[([string]$InvitedAs).ToLower()] } + if ($EntraUser) { return @('Entra B2B', $EntraUser) } + return @('Orphaned B2B (not in Entra)', $null) + } + + $Rows = [System.Collections.Generic.List[object]]::new() + $SeenKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + foreach ($User in $StoreUsers) { + $GuestType, $EntraUser = Resolve-GuestClassification $User.LoginName $User.AcceptedAs $User.InvitedAs + # Match this store entry to any site membership sweep hit for a Sites column. + $MatchKey = $null + foreach ($Key in $SiteGuests.Keys) { + $SG = $SiteGuests[$Key] + if (($User.AcceptedAs -and $SG.Email -eq $User.AcceptedAs) -or ($EntraUser -and $Key -eq $EntraUser.userPrincipalName.ToLower())) { $MatchKey = $Key; break } + } + if ($MatchKey) { [void]$SeenKeys.Add($MatchKey) } + # Direct assignment keeps the List intact; an if-expression would pipeline-unwrap + # a single-element list to a scalar and the API would emit a string, not an array. + $RowSites = [System.Collections.Generic.List[string]]::new() + if ($MatchKey) { $RowSites = $SiteGuests[$MatchKey].Sites } + $Rows.Add([PSCustomObject]@{ + DisplayName = $User.DisplayName + InvitedAs = $User.InvitedAs + AcceptedAs = $User.AcceptedAs + # Store entries often carry no login; the site sweep's claims login fills the gap. + LoginName = if ($User.LoginName) { $User.LoginName } elseif ($MatchKey) { $SiteGuests[$MatchKey].LoginName } else { $null } + UniqueId = $User.UniqueId + WhenCreated = ConvertFrom-CsomDate $User.WhenCreated + InvitedBy = $User.InvitedBy + GuestType = $GuestType + InEntra = [bool]$EntraUser + EntraUserId = $EntraUser.id + Source = 'External users store' + Sites = $RowSites + }) + } + + # Guests that only exist as site members (never redeemed / store aged out) + foreach ($Key in $SiteGuests.Keys) { + if ($SeenKeys.Contains($Key)) { continue } + $SG = $SiteGuests[$Key] + $GuestType, $EntraUser = Resolve-GuestClassification $SG.LoginName $SG.Email $null + $Rows.Add([PSCustomObject]@{ + DisplayName = $SG.Title + InvitedAs = $null + AcceptedAs = $SG.Email + LoginName = $SG.LoginName + UniqueId = $null + WhenCreated = $null + InvitedBy = $null + GuestType = $GuestType + InEntra = [bool]$EntraUser + EntraUserId = $EntraUser.id + Source = 'Site membership' + Sites = $SG.Sites + }) + } + + $Body = @($Rows) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to list SharePoint external users: $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointSharing.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointSharing.ps1 new file mode 100644 index 0000000000000..b96a7e1a99ab3 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointSharing.ps1 @@ -0,0 +1,120 @@ +function Invoke-ListSharePointSharing { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Compiles the SharePoint & OneDrive sharing report for a tenant from CACHED data in the + CIPP reporting database (SharePointSharingLinks, SharePointSiteUsage, OneDriveUsage). + No live Graph enumeration is performed - refresh the data by syncing those caches + (ExecCIPPDBCache). Returns environment/file/storage summaries per workload, sharing link + counts by classification, chart datasets and the individual sharing link rows. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + # Usage report values can arrive as numbers, strings or empty strings depending on the tenant. + function ConvertTo-SafeDouble { + param($Value) + $Parsed = [double]0 + if ($null -ne $Value -and [double]::TryParse("$Value", [ref]$Parsed)) { return $Parsed } + return [double]0 + } + + # --- Cached datasets from the CIPP reporting database --- + $CacheTypes = @('SharePointSharingLinks', 'SharePointSiteUsage', 'OneDriveUsage') + $CacheData = @{} + $CacheSynced = @{} + $CacheTimestamps = [System.Collections.Generic.List[object]]::new() + foreach ($Type in $CacheTypes) { + try { + $CacheData[$Type] = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type) + } catch { + $CacheData[$Type] = @() + } + $CacheSynced[$Type] = $false + try { + $CountRow = Get-CIPPDbItem -TenantFilter $TenantFilter -Type $Type -CountsOnly | Select-Object -First 1 + if ($CountRow) { $CacheSynced[$Type] = $true } + if ($CountRow.Timestamp) { $CacheTimestamps.Add($CountRow.Timestamp) } + } catch {} + } + $LastDataRefresh = $CacheTimestamps | Sort-Object | Select-Object -First 1 + + # --- Environment summaries per workload. Teams-connected sites (rootWebTemplate 'Group') + # are reported separately from the remaining SharePoint sites; OneDrive is per account. --- + $SharePointSites = 0; $SharePointFiles = [int64]0; $SharePointStorage = [double]0 + $TeamsSites = 0; $TeamsFiles = [int64]0; $TeamsStorage = [double]0 + foreach ($Site in $CacheData['SharePointSiteUsage']) { + $Files = [int64](ConvertTo-SafeDouble -Value $Site.fileCount) + $Storage = ConvertTo-SafeDouble -Value $Site.storageUsedInBytes + if ($Site.rootWebTemplate -eq 'Group') { + $TeamsSites++; $TeamsFiles += $Files; $TeamsStorage += $Storage + } else { + $SharePointSites++; $SharePointFiles += $Files; $SharePointStorage += $Storage + } + } + + $OneDriveAccounts = 0; $OneDriveFiles = [int64]0; $OneDriveStorage = [double]0 + foreach ($Account in $CacheData['OneDriveUsage']) { + $OneDriveAccounts++ + $OneDriveFiles += [int64](ConvertTo-SafeDouble -Value $Account.fileCount) + $OneDriveStorage += ConvertTo-SafeDouble -Value $Account.storageUsedInBytes + } + + # --- Sharing link rollups --- + $Links = $CacheData['SharePointSharingLinks'] + $AnonymousLinks = 0; $ExternalLinks = 0; $InternalLinks = 0 + $ByScope = @{} + $ByLinkType = @{} + $BySite = @{} + $SharedItemIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Link in $Links) { + switch ($Link.classification) { + 'Anonymous' { $AnonymousLinks++ } + 'External' { $ExternalLinks++ } + default { $InternalLinks++ } + } + $Scope = [string]($Link.classification ?? 'Internal') + $ByScope[$Scope] = [int]($ByScope[$Scope] ?? 0) + 1 + $Type = [string]($Link.linkType ?? 'link') + $ByLinkType[$Type] = [int]($ByLinkType[$Type] ?? 0) + 1 + $SiteName = [string]($Link.siteName ?? $Link.siteUrl) + if ($SiteName) { $BySite[$SiteName] = [int]($BySite[$SiteName] ?? 0) + 1 } + if ($Link.driveId -and $Link.itemId) { [void]$SharedItemIds.Add("$($Link.driveId)|$($Link.itemId)") } + } + + $Body = [PSCustomObject]@{ + summary = [PSCustomObject]@{ + sharePointSites = $SharePointSites + sharePointFiles = $SharePointFiles + sharePointStorageUsedGB = [math]::Round($SharePointStorage / 1GB, 2) + teamsSites = $TeamsSites + teamsFiles = $TeamsFiles + teamsStorageUsedGB = [math]::Round($TeamsStorage / 1GB, 2) + oneDriveAccounts = $OneDriveAccounts + oneDriveFiles = $OneDriveFiles + oneDriveStorageUsedGB = [math]::Round($OneDriveStorage / 1GB, 2) + totalLinks = @($Links).Count + anonymousLinks = $AnonymousLinks + externalLinks = $ExternalLinks + internalLinks = $InternalLinks + itemsShared = $SharedItemIds.Count + linksSynced = $CacheSynced['SharePointSharingLinks'] + usageSynced = ($CacheSynced['SharePointSiteUsage'] -or $CacheSynced['OneDriveUsage']) + lastDataRefresh = $LastDataRefresh + } + byScope = @($ByScope.GetEnumerator() | Sort-Object -Property Value -Descending | ForEach-Object { [PSCustomObject]@{ scope = $_.Key; links = $_.Value } }) + byLinkType = @($ByLinkType.GetEnumerator() | Sort-Object -Property Value -Descending | ForEach-Object { [PSCustomObject]@{ type = $_.Key; links = $_.Value } }) + topSites = @($BySite.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 10 | ForEach-Object { [PSCustomObject]@{ site = $_.Key; links = $_.Value } }) + links = @($Links) + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteLibraries.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteLibraries.ps1 new file mode 100644 index 0000000000000..9a9eea2c2ae03 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteLibraries.ps1 @@ -0,0 +1,57 @@ +function Invoke-ListSiteLibraries { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Lists the visible document libraries of a SharePoint site via the Graph API. The site can + be addressed by SiteId or by SiteUrl (hostname:path addressing). Returns the SharePoint + list GUID as Id so the result can be used directly against the SharePoint REST API. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $SiteId = $Request.Query.SiteId ?? $Request.Body.SiteId + $SiteUrl = $Request.Query.SiteUrl ?? $Request.Body.SiteUrl + + try { + # Resolve the site addressing segment: prefer the Graph site id, else hostname:path from the URL. + if (-not [string]::IsNullOrWhiteSpace($SiteId)) { + $SiteSegment = $SiteId + } elseif (-not [string]::IsNullOrWhiteSpace($SiteUrl)) { + $ParsedUrl = [System.Uri]$SiteUrl + $SiteSegment = if ($ParsedUrl.AbsolutePath -in @('', '/')) { + $ParsedUrl.Host + } else { + "$($ParsedUrl.Host):$($ParsedUrl.AbsolutePath):" + } + } else { + throw 'SiteId or SiteUrl is required.' + } + + $Lists = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteSegment/lists?`$select=id,displayName,name,webUrl,list" -tenantid $TenantFilter -asapp $true + # documentLibrary covers regular libraries; webPageLibrary is the Site Pages library. + $Results = @($Lists | Where-Object { $_.list.hidden -ne $true -and $_.list.template -in @('documentLibrary', 'webPageLibrary') } | ForEach-Object { + [PSCustomObject]@{ + Id = $_.id + Title = $_.displayName + Template = $_.list.template + WebUrl = $_.webUrl + } + }) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to list document libraries: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 index d57571cee6b3f..d1f2627b12d16 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 @@ -5,34 +5,159 @@ Function Invoke-ListSiteMembers { .ROLE Sharepoint.Site.Read .DESCRIPTION - Lists members of a specific SharePoint site by site ID, including user display names and email addresses. + Lists the actual membership of a SharePoint site: the users in the site's associated + Owners/Members/Visitors role groups via the SharePoint REST API with certificate + authentication, plus site collection admins. On group-connected (Team) sites the role + groups contain the backing M365 group as a claim; those are expanded through Graph so + the real people are returned. Falls back to the site's hidden User Information List + via Graph when the SharePoint REST API is unavailable (e.g. the tenant does not have + the SharePoint application permission consented yet). #> [CmdletBinding()] param($Request, $TriggerMetadata) - $TenantFilter = $Request.Query.TenantFilter + $TenantFilter = $Request.Query.tenantFilter $SiteId = $Request.Query.SiteId + $SiteUrl = $Request.Query.SiteUrl + $Filter = $Request.Query.Filter + + # A SharePoint user entity is a guest/external identity when either guest flag is set or + # the claims login carries the external-user or spo-guest marker. + function Test-SPGuestUser($User) { + [bool]$User.IsShareByEmailGuestUser -or [bool]$User.IsEmailAuthenticationGuestUser -or $User.LoginName -match '(?i)#ext#|urn%3aspo%3aguest' + } try { - # Find User Information List by template (language independent) - $Lists = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/lists?`$select=id,list,system" -tenantid $TenantFilter -AsApp $true - $UIList = $Lists | Where-Object { $_.list.template -eq 'userInformation' } | Select-Object -First 1 - - if ($UIList.id) { - $Items = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/lists/$($UIList.id)/items?`$expand=fields" -tenantid $TenantFilter -AsApp $true - } else { - $Items = @() + if (-not $SiteUrl) { + $SiteUrl = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$($SiteId)?`$select=webUrl" -tenantid $TenantFilter -AsApp $true).webUrl + } + + $Members = [System.Collections.Generic.List[object]]::new() + try { + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + $RoleGroups = [ordered]@{ + 'Owners' = 'associatedownergroup' + 'Members' = 'associatedmembergroup' + 'Visitors' = 'associatedvisitorgroup' + } + foreach ($Role in $RoleGroups.Keys) { + $Users = New-GraphGetRequest -uri "$BaseUri/web/$($RoleGroups[$Role])/users?`$select=Id,Title,Email,LoginName,PrincipalType,IsShareByEmailGuestUser,IsEmailAuthenticationGuestUser" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + foreach ($User in $Users) { + if ($User.LoginName -match 'federateddirectoryclaimprovider\|([0-9a-fA-F-]{36})(_o)?$') { + # Group-connected site: the role group holds the backing M365 group as + # a claim ('_o' suffix = the group's owners). Expand it through Graph. + $GroupId = $Matches[1] + $GraphSegment = if ($Matches[2]) { 'owners' } else { 'members' } + try { + $GroupMembers = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/groups/$GroupId/$($GraphSegment)?`$select=displayName,mail,userPrincipalName&`$top=999" -tenantid $TenantFilter -AsApp $true + foreach ($GroupMember in $GroupMembers) { + $Members.Add([PSCustomObject]@{ + Title = $GroupMember.displayName + Email = $GroupMember.mail ?? $GroupMember.userPrincipalName + LoginName = $GroupMember.userPrincipalName + UserPrincipalName = $GroupMember.userPrincipalName + Group = $Role + Type = 'User (via M365 Group)' + IsGuest = ($GroupMember.userPrincipalName -match '(?i)#ext#') + IsSiteAdmin = $false + }) + } + } catch { + # Could not expand the group; show the claim entity itself. + $Members.Add([PSCustomObject]@{ + Title = $User.Title + Email = $User.Email + LoginName = $User.LoginName + UserPrincipalName = $null + Group = $Role + Type = 'M365 Group' + IsGuest = $false + IsSiteAdmin = $false + }) + } + } else { + $Type = switch ($User.PrincipalType) { + 1 { 'User' } + 4 { 'Security Group' } + 8 { 'SharePoint Group' } + default { 'Other' } + } + $Members.Add([PSCustomObject]@{ + Title = $User.Title + Email = $User.Email + LoginName = $User.LoginName + UserPrincipalName = if ($User.PrincipalType -eq 1) { ($User.LoginName -split '\|')[-1] } else { $null } + Group = $Role + Type = $Type + IsGuest = (Test-SPGuestUser $User) + IsSiteAdmin = $false + }) + } + } + } + + # Site collection admins: flag them on existing rows, add rows for admins that + # are not in any role group. + $Admins = New-GraphGetRequest -uri "$BaseUri/web/siteusers?`$filter=IsSiteAdmin eq true&`$select=Id,Title,Email,LoginName,PrincipalType,IsShareByEmailGuestUser,IsEmailAuthenticationGuestUser" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + foreach ($Admin in $Admins) { + $Existing = @($Members | Where-Object { $_.LoginName -eq $Admin.LoginName }) + if ($Existing.Count -gt 0) { + $Existing | ForEach-Object { $_.IsSiteAdmin = $true } + } else { + $Members.Add([PSCustomObject]@{ + Title = $Admin.Title + Email = $Admin.Email + LoginName = $Admin.LoginName + UserPrincipalName = if ($Admin.PrincipalType -eq 1) { ($Admin.LoginName -split '\|')[-1] } else { $null } + Group = 'Site Admins' + IsGuest = (Test-SPGuestUser $Admin) + Type = if ($Admin.PrincipalType -eq 1) { 'User' } elseif ($Admin.LoginName -match 'federateddirectoryclaimprovider') { 'M365 Group' } else { 'Other' } + IsSiteAdmin = $true + }) + } + } + } catch { + # SharePoint REST unavailable (e.g. no certificate/consent) - fall back to the + # hidden User Information List via Graph. This lists everyone ever resolved on + # the site rather than actual role group membership. + Write-Information "ListSiteMembers falling back to User Information List: $($_.Exception.Message)" + $Members.Clear() + $Lists = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/lists?`$select=id,list,system" -tenantid $TenantFilter -AsApp $true + $UIList = $Lists | Where-Object { $_.list.template -eq 'userInformation' } | Select-Object -First 1 + if ($UIList.id) { + $Items = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/lists/$($UIList.id)/items?`$expand=fields" -tenantid $TenantFilter -AsApp $true + foreach ($Item in $Items) { + $Members.Add([PSCustomObject]@{ + Title = $Item.fields.Title + Email = $Item.fields.EMail + LoginName = $Item.fields.UserName + UserPrincipalName = if ($Item.fields.UserName) { ($Item.fields.UserName -split '\|')[-1] } else { $null } + Group = 'Site Users' + Type = 'User' + IsGuest = ($Item.fields.UserName -match '(?i)#ext#') + IsSiteAdmin = [bool]$Item.fields.IsSiteAdmin + }) + } + } + } + + if ($Filter -eq 'External') { + $Members = @($Members | Where-Object { $_.IsGuest }) } $StatusCode = [HttpStatusCode]::OK - $Body = @($Items) + $Body = @($Members) } catch { $StatusCode = [HttpStatusCode]::Forbidden $Body = Get-NormalizedError -Message $_.Exception.Message } return ([HttpResponseContext]@{ - StatusCode = $StatusCode - Body = @{ 'Results' = $Body } - }) + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteProperties.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteProperties.ps1 new file mode 100644 index 0000000000000..0f11bb9f2855b --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteProperties.ps1 @@ -0,0 +1,63 @@ +function Invoke-ListSiteProperties { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Returns a single site's admin-level properties (sharing, lifecycle and version policy) + via the SharePoint admin CSOM API, with enum values translated to friendly names so + the Edit Site form can consume and round-trip them. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + $SiteUrl = $Request.Query.SiteUrl + + # SPO enum value -> friendly name maps (reverse of the maps in Invoke-ExecSetSiteProperties) + $SharingCapabilityNames = @{ 0 = 'Disabled'; 1 = 'ExternalUserSharingOnly'; 2 = 'ExternalUserAndGuestSharing'; 3 = 'ExistingExternalUserSharingOnly' } + $LinkTypeNames = @{ 0 = 'None'; 1 = 'Direct'; 2 = 'Internal'; 3 = 'AnonymousAccess' } + $LinkPermissionNames = @{ 0 = 'None'; 1 = 'View'; 2 = 'Edit' } + $DomainRestrictionNames = @{ 0 = 'None'; 1 = 'AllowList'; 2 = 'BlockList' } + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + $Site = Get-CIPPSPOSite -TenantFilter $TenantFilter -SiteUrl $SiteUrl + + $Body = [PSCustomObject]@{ + Url = $Site.Url + Title = $Site.Title + Template = $Site.Template + # Sharing + SharingCapability = $SharingCapabilityNames[[int]$Site.SharingCapability] ?? $Site.SharingCapability + DefaultSharingLinkType = $LinkTypeNames[[int]$Site.DefaultSharingLinkType] ?? $Site.DefaultSharingLinkType + DefaultLinkPermission = $LinkPermissionNames[[int]$Site.DefaultLinkPermission] ?? $Site.DefaultLinkPermission + SharingDomainRestrictionMode = $DomainRestrictionNames[[int]$Site.SharingDomainRestrictionMode] ?? $Site.SharingDomainRestrictionMode + SharingAllowedDomainList = $Site.SharingAllowedDomainList + SharingBlockedDomainList = $Site.SharingBlockedDomainList + OverrideTenantAnonymousLinkExpirationPolicy = [bool]$Site.OverrideTenantAnonymousLinkExpirationPolicy + AnonymousLinkExpirationInDays = $Site.AnonymousLinkExpirationInDays + # Lifecycle + LockState = $Site.LockState + StorageMaximumLevel = $Site.StorageMaximumLevel + StorageWarningLevel = $Site.StorageWarningLevel + StorageUsage = $Site.StorageUsage + # Version policy + InheritVersionPolicyFromTenant = [bool]$Site.InheritVersionPolicyFromTenant + EnableAutoExpirationVersionTrim = [bool]$Site.EnableAutoExpirationVersionTrim + MajorVersionLimit = $Site.MajorVersionLimit + ExpireVersionsAfterDays = $Site.ExpireVersionsAfterDays + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to get site properties for $($SiteUrl): $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBin.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBin.ps1 new file mode 100644 index 0000000000000..b0530cb6d2ad8 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBin.ps1 @@ -0,0 +1,54 @@ +function Invoke-ListSiteRecycleBin { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.SiteRecycleBin.Read + .DESCRIPTION + Lists the contents of a SharePoint site's recycle bin (first and second stage) via the + SharePoint REST API with certificate authentication. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + $SiteUrl = $Request.Query.SiteUrl + + $ItemTypeNames = @{ 1 = 'File'; 2 = 'File Version'; 3 = 'List Item'; 4 = 'List'; 5 = 'Folder'; 6 = 'Folder'; 7 = 'Attachment'; 8 = 'List Item Version'; 10 = 'Web' } + $ItemStateNames = @{ 1 = 'First Stage'; 2 = 'Second Stage' } + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + $Items = New-GraphGetRequest -uri "$BaseUri/site/RecycleBin?`$top=500&`$orderby=DeletedDate desc" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + + $Body = @($Items | ForEach-Object { + [PSCustomObject]@{ + Id = $_.Id + Title = $_.Title + LeafName = $_.LeafName + DirName = $_.DirName + ItemType = $ItemTypeNames[[int]$_.ItemType] ?? $_.ItemType + ItemState = $ItemStateNames[[int]$_.ItemState] ?? $_.ItemState + Size = $_.Size + DeletedByName = $_.DeletedByName + DeletedDate = $_.DeletedDate + } + }) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to list the recycle bin for $($SiteUrl): $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecAuditLogSearch.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecAuditLogSearch.ps1 index 56cc814a95fbc..7956b8e2902c3 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecAuditLogSearch.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecAuditLogSearch.ps1 @@ -50,6 +50,11 @@ function Invoke-ExecAuditLogSearch { Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force | Out-Null + # Bridge into the V2 AuditLogCoverage ledger so the pipeline picks it up automatically + # (re-queuing resets it to State 'Created' to reprocess). + $ManualStatus = if ($Search) { [string]$Search.status } else { '' } + Add-CippAuditLogCoverageManualEntry -TenantFilter $TenantFilter -SearchId $SearchId -StartTime $Entity.StartTime -EndTime $Entity.EndTime -SearchStatus $ManualStatus + $DisplayName = $Entity.DisplayName Write-LogMessage -headers $Headers -API $APIName -message "Queued search for processing: $($Search.displayName)" -Sev 'Info' -tenant $TenantFilter diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogCoverage.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogCoverage.ps1 new file mode 100644 index 0000000000000..2944d6810ae6b --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogCoverage.ps1 @@ -0,0 +1,135 @@ +function Invoke-ListAuditLogCoverage { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Tenant.Alert.Read + .DESCRIPTION + Lists the V2 audit-log coverage ledger (AuditLogCoverage) - one row per tenant + 60-minute + search window with its state (Planned / Created / Downloaded / Retry / DeadLetter / Skipped), + record count, attempts and last error. Honours the tenant selector (a specific tenant or + AllTenants) and CIPP tenant access control. Accepts the same date filters as the Saved Logs + view: RelativeTime (e.g. 48h, 7d) or StartDate/EndDate. Defaults to the last 48 hours. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + # --- Date range (mirrors Invoke-ListAuditLogs) --- + $RelativeTime = $Request.Query.RelativeTime ?? $Request.Body.RelativeTime + $StartDate = $Request.Query.StartDate ?? $Request.Body.StartDate + $EndDate = $Request.Query.EndDate ?? $Request.Body.EndDate + + $EndUtc = (Get-Date).ToUniversalTime() + $StartUtc = $EndUtc.AddHours(-48) + + if (-not $RelativeTime -and -not $StartDate -and -not $EndDate) { + $RelativeTime = '48h' + } + + if ($RelativeTime -and $RelativeTime -match '(\d+)([dhm])') { + $Interval = [int]$Matches[1] + switch ($Matches[2]) { + 'd' { $StartUtc = $EndUtc.AddDays(-$Interval) } + 'h' { $StartUtc = $EndUtc.AddHours(-$Interval) } + 'm' { $StartUtc = $EndUtc.AddMinutes(-$Interval) } + } + } elseif ($StartDate) { + if ([string]$StartDate -match '^\d+$') { + $StartUtc = [DateTimeOffset]::FromUnixTimeSeconds([int64]$StartDate).UtcDateTime + } else { + try { $StartUtc = ([datetimeoffset]$StartDate).UtcDateTime } catch {} + } + if ($EndDate) { + if ([string]$EndDate -match '^\d+$') { + $EndUtc = [DateTimeOffset]::FromUnixTimeSeconds([int64]$EndDate).UtcDateTime + } else { + try { $EndUtc = ([datetimeoffset]$EndDate).UtcDateTime } catch {} + } + } + } + + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + + # When access is scoped, resolve the caller's allowed tenants to both domain + id for matching. + $AllowedDomains = $null + $AllowedIds = $null + if ($AllowedTenants -notcontains 'AllTenants') { + $TenantList = Get-Tenants -IncludeErrors | Where-Object { $_.customerId -in $AllowedTenants } + $AllowedDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $AllowedIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Tenant in $TenantList) { + if ($Tenant.defaultDomainName) { [void]$AllowedDomains.Add([string]$Tenant.defaultDomainName) } + if ($Tenant.customerId) { [void]$AllowedIds.Add([string]$Tenant.customerId) } + } + } + + function ConvertTo-Utc { + param($Value) + if (-not $Value) { return $null } + try { return ([datetimeoffset]$Value).UtcDateTime } catch { return $null } + } + + $Table = Get-CIPPTable -TableName 'AuditLogCoverage' + $Rows = Get-CIPPAzDataTableEntity @Table + + $Results = foreach ($Row in $Rows) { + $WindowStart = ConvertTo-Utc $Row.WindowStart + # Date range filter on the window start + if ($WindowStart) { + if ($WindowStart -lt $StartUtc -or $WindowStart -gt $EndUtc) { continue } + } + + # Tenant selector filter (match on default domain or customer id) + if ($TenantFilter -and $TenantFilter -ne 'AllTenants') { + if (($Row.PartitionKey -ne $TenantFilter) -and ([string]$Row.TenantId -ne [string]$TenantFilter)) { continue } + } + + # Access control when not AllTenants + if ($AllowedTenants -notcontains 'AllTenants') { + if (-not ($AllowedDomains.Contains([string]$Row.PartitionKey) -or $AllowedIds.Contains([string]$Row.TenantId))) { continue } + } + + [PSCustomObject]@{ + Tenant = $Row.PartitionKey + TenantId = $Row.TenantId + Type = if ($Row.Type) { $Row.Type } elseif ($Row.RowKey -like 'RECON-*') { 'Reconciliation' } else { 'Window' } + WindowStart = $WindowStart + WindowEnd = ConvertTo-Utc $Row.WindowEnd + State = $Row.State + RecordCount = if ($null -ne $Row.RecordCount) { [int]$Row.RecordCount } else { $null } + Attempts = if ($null -ne $Row.Attempts) { [int]$Row.Attempts } else { 0 } + RetryCount = if ($null -ne $Row.RetryCount) { [int]$Row.RetryCount } else { 0 } + ThrottleCount = if ($null -ne $Row.ThrottleCount) { [int]$Row.ThrottleCount } else { 0 } + SearchId = $Row.SearchId + SearchStatus = $Row.SearchStatus + NextAttemptUtc = ConvertTo-Utc $Row.NextAttemptUtc + LastError = $Row.LastError + LastErrorUtc = ConvertTo-Utc $Row.LastErrorUtc + LastPolledUtc = ConvertTo-Utc $Row.LastPolledUtc + CreatedUtc = ConvertTo-Utc $Row.CreatedUtc + DownloadedUtc = ConvertTo-Utc $Row.DownloadedUtc + ProcessedUtc = ConvertTo-Utc $Row.ProcessedUtc + MatchedCount = if ($null -ne $Row.MatchedCount) { [int]$Row.MatchedCount } else { $null } + LastUpdated = ConvertTo-Utc $Row.Timestamp + } + } + + $Results = @($Results | Sort-Object -Property @{ Expression = 'Tenant' }, @{ Expression = 'WindowStart'; Descending = $true }) + + $Body = @{ + Results = @($Results) + Metadata = @{ + TenantFilter = $TenantFilter + StartDate = $StartUtc.ToString('o') + EndDate = $EndUtc.ToString('o') + Total = $Results.Count + } + } | ConvertTo-Json -Depth 10 -Compress + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecApplication.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecApplication.ps1 index 64de2982c3416..6436c66157592 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecApplication.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecApplication.ps1 @@ -62,7 +62,8 @@ function Invoke-ExecApplication { } if ($Request.Body) { - $PostParams.Body = $Request.Body.Payload | ConvertTo-Json -Compress + # Depth 10 so nested payloads (e.g. web/spa/publicClient redirectUris and implicitGrantSettings) aren't truncated. + $PostParams.Body = $Request.Body.Payload | ConvertTo-Json -Depth 10 -Compress } $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 index 78945aa34b2a2..3723e94b01930 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 @@ -34,12 +34,12 @@ function Invoke-ExecCreateAppTemplate { [PSCustomObject]@{ id = 'app' method = 'GET' - url = "/applications(appId='$AppId')?`$select=id,appId,displayName,requiredResourceAccess" + url = "/applications(appId='$AppId')?`$select=id,appId,displayName,signInAudience,requiredResourceAccess" } [PSCustomObject]@{ id = 'splist' method = 'GET' - url = '/servicePrincipals?$top=999&$select=id,appId,displayName' + url = '/servicePrincipals?$top=999&$select=id,appId,displayName,signInAudience' } ) @@ -52,6 +52,20 @@ function Invoke-ExecCreateAppTemplate { # Find the specific service principal in the list $SPResult = $TenantInfo | Where-Object { $_.appId -eq $AppId } | Select-Object -First 1 + # Determine the source app's sign-in audience so we only build Enterprise App + # templates for genuinely multi-tenant apps. A single-tenant app (AzureADMyOrg) + # cannot be deployed via an appId-based service principal, and copying it to the + # partner tenant as multi-tenant produces a template that fails to deploy. Those + # apps must use a Manifest (single-tenant) template instead. + $MultiTenantAudiences = @('AzureADMultipleOrgs', 'AzureADandPersonalMicrosoftAccount') + $SignInAudience = $AppResult.body.signInAudience + if ([string]::IsNullOrWhiteSpace($SignInAudience)) { + $SignInAudience = $SPResult.signInAudience + } + if (-not [string]::IsNullOrWhiteSpace($SignInAudience) -and $SignInAudience -notin $MultiTenantAudiences) { + throw "Application '$DisplayName' is single-tenant (signInAudience '$SignInAudience') and cannot be used as an Enterprise App template. Create a Manifest (single-tenant) template for this app instead." + } + # Get the app details based on type if ($Type -eq 'servicePrincipal') { if (-not $SPResult) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecManageAppCredentials.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecManageAppCredentials.ps1 index 87178d788e332..5715084610646 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecManageAppCredentials.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecManageAppCredentials.ps1 @@ -8,6 +8,9 @@ function Invoke-ExecManageAppCredentials { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter $Action = $Request.Body.Action $AppType = $Request.Body.AppType # applications | servicePrincipals @@ -15,27 +18,135 @@ function Invoke-ExecManageAppCredentials { $KeyId = $Request.Body.KeyId $AppId = $Request.Body.AppId $Id = $Request.Body.Id + $DisplayName = $Request.Body.DisplayName + $EndDateTime = $Request.Body.EndDateTime + $ExpiryMonths = $Request.Body.ExpiryMonths.value ?? $Request.Body.ExpiryMonths + $ExpiryDate = $Request.Body.ExpiryDate + $AppRef = $Id ?? $AppId $IdPath = if ($Id) { "/$Id" } else { "(appId='$AppId')" } $Uri = "https://graph.microsoft.com/beta/$AppType$IdPath" + # Max credential end date allowed by the tenant default app management policy's passwordLifetime + # restriction, or $null when not enforced/readable. Custom per-app policies aren't read here. + function Get-PasswordPolicyMaxEnd { + param([datetime]$From) + try { + $Policy = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/policies/defaultAppManagementPolicy' -tenantid $TenantFilter -AsApp $true + $Lifetime = $Policy.applicationRestrictions.passwordCredentials | Where-Object { $_.restrictionType -eq 'passwordLifetime' } + if ($Lifetime -and -not ($Lifetime.state -eq 'disabled' -or $null -eq $Lifetime.state) -and $Lifetime.maxLifetime) { + return $From.Add([System.Xml.XmlConvert]::ToTimeSpan($Lifetime.maxLifetime)) + } + } catch { + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Could not read app management policy for expiry clamping: $($_.Exception.Message)" -sev Debug + } + return $null + } + try { $Results = switch ($Action) { 'Remove' { if ($CredentialType -eq 'password') { $null = New-GraphPOSTRequest -Uri "$Uri/removePassword" -Body (@{ keyId = $KeyId } | ConvertTo-Json) -tenantid $TenantFilter + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Removed password credential $KeyId from $AppType $AppRef" -sev Info @{ resultText = "Successfully removed password credential $KeyId"; state = 'success' } } else { # Certificates can't use removeKey without a proof JWT, so PATCH the array instead $Current = New-GraphGetRequest -Uri $Uri -tenantid $TenantFilter $Updated = @($Current.keyCredentials | Where-Object { $_.keyId -ne $KeyId }) $null = New-GraphPOSTRequest -Uri $Uri -Type 'PATCH' -Body (@{ keyCredentials = $Updated } | ConvertTo-Json -Depth 10) -tenantid $TenantFilter + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Removed key credential $KeyId from $AppType $AppRef" -sev Info @{ resultText = "Successfully removed key credential $KeyId"; state = 'success' } } } 'Add' { - # TODO: implement credential addition - @{ resultText = 'Add not yet implemented'; state = 'info' } + # Only client secret (password) addition is implemented here. Adding a certificate means + # PATCHing keyCredentials with an uploaded public-key cert - not built yet; use Entra. + if ($CredentialType -ne 'password') { + @{ resultText = 'Adding certificate credentials is not supported here yet. Upload the certificate in Entra instead.'; state = 'error' } + } else { + $PasswordCredential = @{ + displayName = if ([string]::IsNullOrWhiteSpace($DisplayName)) { 'CIPP-Generated Secret' } else { $DisplayName } + } + + $Now = (Get-Date).ToUniversalTime() + if ($EndDateTime) { + $RequestedEnd = ([System.DateTimeOffset]$EndDateTime).UtcDateTime + } elseif ($ExpiryDate) { + $RequestedEnd = [System.DateTimeOffset]::FromUnixTimeSeconds([long]$ExpiryDate).UtcDateTime + } else { + $Months = if ($ExpiryMonths -as [int]) { [int]$ExpiryMonths } else { 12 } + $RequestedEnd = $Now.AddMonths($Months) + } + + # Clamp the expiry to the tenant policy maximum so the add isn't rejected outright. + $ClampNote = '' + $MaxAllowedEnd = Get-PasswordPolicyMaxEnd -From $Now + if ($MaxAllowedEnd -and $RequestedEnd -gt $MaxAllowedEnd) { + $RequestedEnd = $MaxAllowedEnd + $ClampNote = " Expiry was reduced to the tenant policy maximum of $([math]::Round(($MaxAllowedEnd - $Now).TotalDays)) day(s)." + } + + $PasswordCredential.endDateTime = $RequestedEnd.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + + $NewSecret = New-GraphPOSTRequest -Uri "$Uri/addPassword" -Body (@{ passwordCredential = $PasswordCredential } | ConvertTo-Json -Depth 10) -tenantid $TenantFilter + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Added password credential '$($PasswordCredential.displayName)' (keyId $($NewSecret.keyId)) to $AppType $AppRef" -sev Info + @{ + resultText = "Client secret '$($PasswordCredential.displayName)' created for the app registration. Use the Copy to Clipboard button to retrieve the secret.$ClampNote" + copyField = $NewSecret.secretText + state = 'success' + } + } + } + 'Rotate' { + # Rotate a client secret: add a replacement with the same display name, then remove the + # original. Add first so a failure leaves the existing secret intact. + if ($CredentialType -ne 'password') { + @{ resultText = 'Rotation is only supported for client secrets.'; state = 'error' } + } elseif (-not $KeyId) { + @{ resultText = 'KeyId is required to rotate a secret.'; state = 'error' } + } else { + $App = New-GraphGetRequest -Uri $Uri -tenantid $TenantFilter + $OldCred = $App.passwordCredentials | Where-Object { $_.keyId -eq $KeyId } + if (-not $OldCred) { + @{ resultText = "Secret $KeyId was not found on this application."; state = 'error' } + } else { + $Name = if ([string]::IsNullOrWhiteSpace($OldCred.displayName)) { 'CIPP-Generated Secret' } else { $OldCred.displayName } + + $Now = (Get-Date).ToUniversalTime() + $RequestedEnd = $Now.AddMonths(12) + $ClampNote = '' + $MaxAllowedEnd = Get-PasswordPolicyMaxEnd -From $Now + if ($MaxAllowedEnd -and $RequestedEnd -gt $MaxAllowedEnd) { + $RequestedEnd = $MaxAllowedEnd + $ClampNote = " New secret expiry was set to the tenant policy maximum of $([math]::Round(($MaxAllowedEnd - $Now).TotalDays)) day(s)." + } + + $NewCredential = @{ + displayName = $Name + endDateTime = $RequestedEnd.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + } + $NewSecret = New-GraphPOSTRequest -Uri "$Uri/addPassword" -Body (@{ passwordCredential = $NewCredential } | ConvertTo-Json -Depth 10) -tenantid $TenantFilter + + # Remove the old secret; if that fails, keep the new one and tell the caller. + $RemoveNote = '' + try { + $null = New-GraphPOSTRequest -Uri "$Uri/removePassword" -Body (@{ keyId = $KeyId } | ConvertTo-Json) -tenantid $TenantFilter + } catch { + $RemoveNote = ' The new secret was created, but the old one could not be removed - remove it manually.' + } + + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Rotated password credential '$Name' on $AppType $AppRef (old keyId $KeyId, new keyId $($NewSecret.keyId))" -sev Info + @{ + resultText = "Secret '$Name' rotated. Use the Copy to Clipboard button to retrieve the new secret.$ClampNote$RemoveNote" + copyField = $NewSecret.secretText + state = 'success' + } + } + } + } + default { + @{ resultText = "Unknown action: $Action"; state = 'error' } } } @@ -44,9 +155,11 @@ function Invoke-ExecManageAppCredentials { Body = @{ Results = $Results } }) } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Failed to $Action credential: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::InternalServerError - Body = @{ Results = @{ resultText = "Failed to $Action credential: $($_.Exception.Message)"; state = 'error' } } + Body = @{ Results = @{ resultText = "Failed to $Action credential: $($ErrorMessage.NormalizedError)"; state = 'error' } } }) } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 index 71b5adf5984dc..d179814f4f3d0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 @@ -28,6 +28,20 @@ function Invoke-SetAuthMethod { if ($GroupIds) { $Params.GroupIds = @($GroupIds) } + # Forward any method-specific sub-settings present in the body (omitted ones keep current/default values) + $OptionalSettings = @( + 'TAPisUsableOnce', 'TAPMinimumLifetime', 'TAPMaximumLifetime', 'TAPDefaultLifeTime', 'TAPDefaultLength' + 'MicrosoftAuthenticatorSoftwareOathEnabled', 'MicrosoftAuthenticatorDisplayAppInfo', 'MicrosoftAuthenticatorDisplayLocation', 'MicrosoftAuthenticatorCompanionApp' + 'EmailAllowExternalIdToUseEmailOtp', 'EmailExcludeGroupIds' + 'QRCodeLifetimeInDays', 'QRCodePinLength' + 'FIDO2AttestationEnforced', 'FIDO2SelfServiceRegistration', 'VoiceIsOfficePhoneAllowed' + 'SmsIsUsableForSignIn' + ) + foreach ($Setting in $OptionalSettings) { + if ($null -ne $Request.Body.$Setting) { + $Params.$Setting = $Request.Body.$Setting + } + } $Result = Set-CIPPAuthenticationPolicy @Params $StatusCode = [HttpStatusCode]::OK } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecCAExclusion.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecCAExclusion.ps1 index f8936e91f4d1a..8f2c3ffaf4031 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecCAExclusion.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecCAExclusion.ps1 @@ -133,6 +133,55 @@ function Invoke-ExecCAExclusion { Add-CIPPScheduledTask -Task $AuditRemoveTask -hidden $true } $Results = @("Successfully added vacation mode schedule for $Username on policy '$PolicyName'.") + + # Optional: temporary travel policy that only allows sign-ins from the travel destination + $TravelCountries = @($Request.Body.TravelCountries.value | Where-Object { $_ }) + if ($Request.Body.CreateTravelPolicy -eq $true -and $TravelCountries.Count -gt 0) { + $TravelUsers = @($Users.addedFields.userPrincipalName ?? $Users.value ?? $Users ?? $UserID) + $StartLabel = [DateTimeOffset]::FromUnixTimeSeconds([int64]$StartDate).ToString('yyyy-MM-dd') + $EndLabel = [DateTimeOffset]::FromUnixTimeSeconds([int64]$EndDate).ToString('yyyy-MM-dd') + $UserLabel = $TravelUsers -join ', ' + $TravelPolicyName = "Travel Policy $UserLabel - $StartLabel - $EndLabel" + if ($TravelPolicyName.Length -gt 256) { + $TravelPolicyName = "Travel Policy $($TravelUsers.Count) users - $StartLabel - $EndLabel" + } + + $TravelCreateTask = [pscustomobject]@{ + TenantFilter = $TenantFilter + Name = "Create Travel Policy Vacation Mode: $TravelPolicyName" + Command = @{ + value = 'New-CIPPTravelPolicy' + label = 'New-CIPPTravelPolicy' + } + Parameters = [pscustomobject]@{ + Users = $TravelUsers + Countries = $TravelCountries + PolicyName = $TravelPolicyName + } + ScheduledTime = $StartDate + PostExecution = $Request.Body.postExecution + Reference = $Request.Body.reference + } + Add-CIPPScheduledTask -Task $TravelCreateTask -hidden $false + + $TravelRemoveTask = [pscustomobject]@{ + TenantFilter = $TenantFilter + Name = "Remove Travel Policy Vacation Mode: $TravelPolicyName" + Command = @{ + value = 'Remove-CIPPTravelPolicy' + label = 'Remove-CIPPTravelPolicy' + } + Parameters = [pscustomobject]@{ + PolicyName = $TravelPolicyName + } + ScheduledTime = $EndDate + PostExecution = $Request.Body.postExecution + Reference = $Request.Body.reference + } + Add-CIPPScheduledTask -Task $TravelRemoveTask -hidden $false + $Results += "Successfully scheduled temporary travel policy '$TravelPolicyName' restricting sign-ins to $($TravelCountries -join ', '). The policy and named location will be removed at the end date." + } + if ($DuplicateGroupWarning) { $Results += $DuplicateGroupWarning } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 index 769a3e7c43b8a..7c08fb3741ed4 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 @@ -9,7 +9,7 @@ function Invoke-ListCAtemplates { #> [CmdletBinding()] param($Request, $TriggerMetadata) - Write-Host $Request.query.id + $GUID = $Request.query.id ?? $Request.query.ID ?? $Request.query.guid ?? $Request.query.GUID #Migrating old policies whenever you do a list $Table = Get-CippTable -tablename 'templates' $Imported = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'settings'" @@ -31,10 +31,10 @@ function Invoke-ListCAtemplates { } #List new policies $Table = Get-CippTable -tablename 'templates' - $Filter = "PartitionKey eq 'CATemplate'" - $RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) if ($Request.query.mode -eq 'Tag') { + $Filter = "PartitionKey eq 'CATemplate'" + $RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) #when the mode is tag, show all the potential tags, return the object with: label: tag, value: tag, count: number of templates with that tag, unique only $Templates = @($RawTemplates | Where-Object { $_.Package } | Group-Object -Property Package | ForEach-Object { $package = $_.Name @@ -59,6 +59,14 @@ function Invoke-ListCAtemplates { } } | Sort-Object -Property label) } else { + if ($GUID) { + $SafeGUID = ConvertTo-CIPPODataFilterValue -Value $GUID -Type Guid + $Filter = "PartitionKey eq 'CATemplate' and GUID eq '$SafeGUID'" + $RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) + } + else { + $RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'CATemplate'") + } $Templates = $RawTemplates | ForEach-Object { try { $row = $_ @@ -74,8 +82,6 @@ function Invoke-ListCAtemplates { } | Sort-Object -Property displayName } - if ($Request.query.ID) { $Templates = $Templates | Where-Object -Property GUID -EQ $Request.query.id } - $Templates = ConvertTo-Json -InputObject @($Templates) -Depth 100 return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecDeleteGDAPRelationship.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecDeleteGDAPRelationship.ps1 index 8c0898c318ef9..d0f4ed0b234cd 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecDeleteGDAPRelationship.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecDeleteGDAPRelationship.ps1 @@ -1,4 +1,4 @@ -Function Invoke-ExecDeleteGDAPRelationship { +function Invoke-ExecDeleteGDAPRelationship { <# .FUNCTIONALITY Entrypoint,AnyTenant @@ -13,19 +13,23 @@ Function Invoke-ExecDeleteGDAPRelationship { # Interact with query parameters or the body of the request. - $GDAPID = $Request.Query.GDAPId ?? $Request.Body.GDAPId + $GDAPId = $Request.Query.GDAPId ?? $Request.Body.GDAPId try { - $DELETE = New-GraphPostRequest -NoAuthCheck $True -uri "https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships/$($GDAPID)/requests" -type POST -body '{"action":"terminate"}' -tenantid $env:TenantID - $Results = [pscustomobject]@{'Results' = "Success. GDAP relationship for $($GDAPID) been revoked" } - Write-LogMessage -headers $Headers -API $APIName -message "Success. GDAP relationship for $($GDAPID) been revoked" -Sev 'Info' + $null = New-GraphPostRequest -NoAuthCheck $True -uri "https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships/$($GDAPId)/requests" -type POST -body '{"action":"terminate"}' -tenantid $env:TenantID + $Result = "Success. GDAP relationship for $($GDAPId) been revoked" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK } catch { - $Results = [pscustomobject]@{'Results' = "Failed. $($_.Exception.Message)" } + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to revoke GDAP relationship for $($GDAPId). Error: $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::InternalServerError + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = $Results + StatusCode = $StatusCode + Body = @{ 'Results' = $Result } }) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecShadowAISanction.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecShadowAISanction.ps1 new file mode 100644 index 0000000000000..8c5f84c6d1e0a --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecShadowAISanction.ps1 @@ -0,0 +1,61 @@ +function Invoke-ExecShadowAISanction { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Standards.ReadWrite + .DESCRIPTION + Marks an AI tool from the Shadow AI catalog as company sanctioned for a tenant, or removes + that status. Sanctioned tools are stored per tenant in the ShadowAIConfig table and are + reported by ListShadowAI with risk 'Informational' and status 'Sanctioned'; all other + detected AI tools report status 'Unsanctioned'. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Body.tenantFilter ?? $Request.Query.tenantFilter + $Tools = @($Request.Body.Tools ?? $Request.Body.Tool) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + $Action = $Request.Body.Action ?? 'Sanction' + + try { + if (-not $TenantFilter) { throw 'tenantFilter is required' } + if ($Tools.Count -eq 0) { throw 'No AI tool specified' } + if ($Action -notin @('Sanction', 'Unsanction')) { throw "Unknown action '$Action'. Use Sanction or Unsanction." } + + $Table = Get-CIPPTable -TableName 'ShadowAIConfig' + $Results = foreach ($Tool in $Tools) { + # Table storage forbids /, \, # and ? in row keys + $RowKey = ($Tool -replace '[\\/#\?]', ' ').Trim() + if ($Action -eq 'Sanction') { + Add-CIPPAzDataTableEntity @Table -Entity @{ + PartitionKey = $TenantFilter + RowKey = $RowKey + Tool = "$Tool" + Sanctioned = $true + } -Force + Write-LogMessage -headers $Request.Headers -API 'ExecShadowAISanction' -tenant $TenantFilter -message "Marked AI tool '$Tool' as company sanctioned" -Sev 'Info' + "Marked '$Tool' as company sanctioned. Its risk level now reports as Informational." + } else { + $EscapedTenant = $TenantFilter -replace "'", "''" + $EscapedRowKey = $RowKey -replace "'", "''" + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$EscapedTenant' and RowKey eq '$EscapedRowKey'" + if ($Entity) { + Remove-AzDataTableEntity @Table -Entity $Entity -Force + } + Write-LogMessage -headers $Request.Headers -API 'ExecShadowAISanction' -tenant $TenantFilter -message "Removed company sanctioned status from AI tool '$Tool'" -Sev 'Info' + "Removed company sanctioned status from '$Tool'. Its catalog risk level applies again." + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ Results = @($Results) } + }) + } catch { + Write-LogMessage -headers $Request.Headers -API 'ExecShadowAISanction' -tenant $TenantFilter -message "Failed to update sanctioned AI tools: $($_.Exception.Message)" -Sev 'Error' + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = @("Failed to update sanctioned AI tools: $($_.Exception.Message)") } + }) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 index 78c556cf7abef..152e87c5515f9 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 @@ -11,6 +11,49 @@ function Invoke-ExecUpdateDriftDeviation { $APIName = $TriggerMetadata.FunctionName Write-LogMessage -Headers $Request.Headers -API $APINAME -message 'Accessed this API' -Sev 'Debug' + function Find-CIPPTagBundleEntry { + param($Entries, $TemplateId, $TemplatePartition) + + if (-not $Entries) { return $null } + + $BundleEntry = $null + $TemplatesTable = Get-CippTable -tablename 'templates' + $LiveTemplate = Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq '$TemplatePartition' and RowKey eq '$TemplateId'" + if (-not $LiveTemplate) { + # Built-in templates are keyed as '.IntuneTemplate.json' / '.CATemplate.json', + # while deviation ids may carry the bare guid - match by prefix as a fallback + $LiveTemplate = Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq '$TemplatePartition'" | + Where-Object { $_.RowKey -like "$TemplateId*" } | Select-Object -First 1 + } + if ($LiveTemplate.Package) { + $BundleEntry = $Entries | Where-Object { + $TagValues = foreach ($Tag in @($_.'TemplateList-Tags')) { + if ($Tag.value) { $Tag.value } elseif ($Tag -is [string]) { $Tag } + } + @($TagValues) -contains $LiveTemplate.Package + } | Select-Object -First 1 + } + + if (-not $BundleEntry) { + # Fallback: the stored snapshot, for templates whose live row no longer exists + $BundleEntry = $Entries | Where-Object { + ($_.'TemplateList-Tags'.rawData.templates | Where-Object { $_.GUID -like "*$TemplateId*" }) -or + ($_.'TemplateList-Tags'.addedFields.templates | Where-Object { $_.GUID -like "*$TemplateId*" }) + } | Select-Object -First 1 + } + + if ($BundleEntry) { + $Matched = $BundleEntry.PSObject.Copy() + $Matched.PSObject.Properties.Remove('TemplateList-Tags') + $Matched | Add-Member -NotePropertyName TemplateList -NotePropertyValue ([pscustomobject]@{ + label = $TemplateId + value = $TemplateId + }) -Force + return $Matched + } + return $null + } + try { $TenantFilter = $Request.Body.TenantFilter @@ -44,23 +87,17 @@ function Invoke-ExecUpdateDriftDeviation { $Setting = $Deviation.standardName -replace 'standards\.', '' $StandardTemplate = Get-CIPPTenantAlignment -TenantFilter $TenantFilter | Where-Object -Property standardType -EQ 'drift' $DriftTemplateId = $StandardTemplate.StandardId + $Settings = $null if ($Setting -like '*IntuneTemplate*') { $Setting = 'IntuneTemplate' - $TemplateId = $Deviation.standardName.split('.') | Select-Object -Index 2 + # Take everything after the prefix: template ids can contain dots + # (built-in templates are keyed '.IntuneTemplate.json') + $TemplateId = $Deviation.standardName -replace '^(standards\.)?IntuneTemplates?\.', '' $MatchedTemplate = $StandardTemplate.standardSettings.IntuneTemplate | Where-Object { $_.TemplateList.value -like "*$TemplateId*" } | Select-Object -First 1 if (-not $MatchedTemplate) { - # Template may be inside a TemplateList-Tags bundle, expand it - $BundleEntry = $StandardTemplate.standardSettings.IntuneTemplate | Where-Object { - $_.'TemplateList-Tags'.rawData.templates | Where-Object { $_.GUID -like "*$TemplateId*" } - } | Select-Object -First 1 - if ($BundleEntry) { - $MatchedTemplate = $BundleEntry.PSObject.Copy() - $MatchedTemplate.PSObject.Properties.Remove('TemplateList-Tags') - $MatchedTemplate | Add-Member -NotePropertyName TemplateList -NotePropertyValue ([pscustomobject]@{ - label = $TemplateId - value = $TemplateId - }) -Force - } + # Template may be referenced through a TemplateList-Tags bundle - resolve the + # tag membership live so members added after the bundle was saved still remediate + $MatchedTemplate = Find-CIPPTagBundleEntry -Entries $StandardTemplate.standardSettings.IntuneTemplate -TemplateId $TemplateId -TemplatePartition 'IntuneTemplate' } if (-not $MatchedTemplate) { Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Could not find IntuneTemplate $TemplateId in drift standard settings for remediation" -Sev 'Warning' @@ -71,11 +108,21 @@ function Invoke-ExecUpdateDriftDeviation { } } elseif ($Setting -like '*ConditionalAccessTemplate*') { $Setting = 'ConditionalAccessTemplate' - $TemplateId = $Deviation.standardName.split('.') | Select-Object -Index 2 - $StandardTemplate = $StandardTemplate.standardSettings.ConditionalAccessTemplate | Where-Object { $_.TemplateList.value -like "*$TemplateId*" } - $StandardTemplate | Add-Member -MemberType NoteProperty -Name 'remediate' -Value $true -Force - $StandardTemplate | Add-Member -MemberType NoteProperty -Name 'report' -Value $true -Force - $Settings = $StandardTemplate + # Take everything after the prefix: template ids can contain dots + # (built-in templates are keyed '.CATemplate.json') + $TemplateId = $Deviation.standardName -replace '^(standards\.)?ConditionalAccessTemplates?\.', '' + $MatchedTemplate = $StandardTemplate.standardSettings.ConditionalAccessTemplate | Where-Object { $_.TemplateList.value -like "*$TemplateId*" } | Select-Object -First 1 + if (-not $MatchedTemplate) { + # CA templates can be referenced through a TemplateList-Tags bundle too + $MatchedTemplate = Find-CIPPTagBundleEntry -Entries $StandardTemplate.standardSettings.ConditionalAccessTemplate -TemplateId $TemplateId -TemplatePartition 'CATemplate' + } + if (-not $MatchedTemplate) { + Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Could not find ConditionalAccessTemplate $TemplateId in drift standard settings for remediation" -Sev 'Warning' + } else { + $MatchedTemplate | Add-Member -MemberType NoteProperty -Name 'remediate' -Value $true -Force + $MatchedTemplate | Add-Member -MemberType NoteProperty -Name 'report' -Value $true -Force + $Settings = $MatchedTemplate + } } else { $StandardTemplate = $StandardTemplate.standardSettings.$Setting # If the addedComponent values are stored nested under standards. instead of @@ -90,52 +137,62 @@ function Invoke-ExecUpdateDriftDeviation { $StandardTemplate | Add-Member -MemberType NoteProperty -Name 'report' -Value $true -Force $Settings = $StandardTemplate } - $TaskBody = @{ - TenantFilter = $TenantFilter - Name = "One Off Drift Remediation: $Setting - $TenantFilter - $DriftTemplateId" - Tag = "DriftRemediation_$DriftTemplateId" - Command = @{ - value = "Invoke-CIPPStandard$Setting" - label = "Invoke-CIPPStandard$Setting" - } - - Parameters = [pscustomobject]@{ - Tenant = $TenantFilter - Settings = $Settings - } - ScheduledTime = '0' - PostExecution = @{ - Webhook = $false - Email = $false - PSA = $false - } - } - Add-CIPPScheduledTask -Task $TaskBody -hidden $false - Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Scheduled drift remediation task for $Setting" -Sev 'Info' - - if ($PersistentDeny) { - $PersistentTaskBody = @{ + if ($Settings) { + $TaskBody = @{ TenantFilter = $TenantFilter - Name = "Persistent Drift Remediation: $Setting - $TenantFilter - $DriftTemplateId" + Name = "One Off Drift Remediation: $Setting - $TenantFilter - $DriftTemplateId" Tag = "DriftRemediation_$DriftTemplateId" Command = @{ value = "Invoke-CIPPStandard$Setting" label = "Invoke-CIPPStandard$Setting" } + Parameters = [pscustomobject]@{ Tenant = $TenantFilter Settings = $Settings } ScheduledTime = '0' - Recurrence = '12h' PostExecution = @{ Webhook = $false Email = $false PSA = $false } } - Add-CIPPScheduledTask -Task $PersistentTaskBody -hidden $false - Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Scheduled persistent drift remediation task (12h recurrence) for $Setting" -Sev 'Info' + Add-CIPPScheduledTask -Task $TaskBody -hidden $false + Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Scheduled drift remediation task for $Setting" -Sev 'Info' + + if ($PersistentDeny) { + $PersistentTaskBody = @{ + TenantFilter = $TenantFilter + Name = "Persistent Drift Remediation: $Setting - $TenantFilter - $DriftTemplateId" + Tag = "DriftRemediation_$DriftTemplateId" + Command = @{ + value = "Invoke-CIPPStandard$Setting" + label = "Invoke-CIPPStandard$Setting" + } + Parameters = [pscustomobject]@{ + Tenant = $TenantFilter + Settings = $Settings + } + ScheduledTime = '0' + Recurrence = '12h' + PostExecution = @{ + Webhook = $false + Email = $false + PSA = $false + } + } + Add-CIPPScheduledTask -Task $PersistentTaskBody -hidden $false + Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Scheduled persistent drift remediation task (12h recurrence) for $Setting" -Sev 'Info' + } + } else { + # Surface the failure to the caller - previously this silently scheduled a + # remediation task with empty settings that could never do anything + [PSCustomObject]@{ + standardName = $Deviation.standardName + success = $false + error = "The deviation status was updated, but no remediation task was scheduled: the template could not be resolved from the drift template settings. Verify the template still exists in the template library, or re-save the drift template." + } } } if ($Deviation.status -eq 'deniedDelete') { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 index c5ea0190d57d6..ff806f1c5aaa2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 @@ -37,6 +37,18 @@ function Invoke-ListShadowAI { return $null } + $SanctionedTools = @{} + try { + $SanctionTable = Get-CIPPTable -TableName 'ShadowAIConfig' + $EscapedTenant = $TenantFilter -replace "'", "''" + foreach ($Row in @(Get-CIPPAzDataTableEntity @SanctionTable -Filter "PartitionKey eq '$EscapedTenant'")) { + $ToolName = if ($Row.Tool) { $Row.Tool } else { $Row.RowKey } + if ($ToolName) { $SanctionedTools[$ToolName.ToLower()] = $true } + } + } catch { + Write-LogMessage -API 'ShadowAI' -tenant $TenantFilter -message "Could not load sanctioned AI tools: $($_.Exception.Message)" -Sev 'Warning' + } + # --- Cached datasets from the CIPP reporting database (no live Graph enumeration) --- $CacheTypes = @('DetectedApps', 'ServicePrincipals', 'OAuth2PermissionGrants') $CacheData = @{} @@ -56,23 +68,57 @@ function Invoke-ListShadowAI { $EntraSynced = $CacheData['ServicePrincipals'].Count -gt 0 $LastDataRefresh = $CacheTimestamps | Sort-Object | Select-Object -First 1 - # 1) Installed AI tools from the cached Intune detected apps - $DetectedApps = [System.Collections.Generic.List[object]]::new() + # 1) Installed AI tools from the cached Intune detected apps. The inventory reports a separate + # application entry per version (and per install flavor, e.g. 'Copilot' vs 'Microsoft.Copilot'), + # so merge everything that matches the same catalog tool into ONE row: distinct devices only, + # with the observed application names, versions and platforms combined. + $DetectedAppMap = [ordered]@{} foreach ($App in $CacheData['DetectedApps']) { $Match = Get-AiMatch -Text "$($App.displayName) $($App.publisher)" -Catalog $Catalog if (-not $Match) { continue } - $DeviceCount = [int]($App.deviceCount ?? 0) - if ($DeviceCount -eq 0 -and $App.managedDevices) { $DeviceCount = @($App.managedDevices).Count } + if (-not $DetectedAppMap.Contains($Match.name)) { + $DetectedAppMap[$Match.name] = [PSCustomObject]@{ + Match = $Match + Sanctioned = $SanctionedTools.ContainsKey($Match.name.ToLower()) + Applications = [System.Collections.Generic.List[string]]::new() + Publishers = [System.Collections.Generic.List[string]]::new() + Versions = [System.Collections.Generic.List[string]]::new() + Platforms = [System.Collections.Generic.List[string]]::new() + Devices = [ordered]@{} + } + } + $Entry = $DetectedAppMap[$Match.name] + if ($App.displayName -and $Entry.Applications -notcontains [string]$App.displayName) { $Entry.Applications.Add([string]$App.displayName) } + if ($App.publisher -and $Entry.Publishers -notcontains [string]$App.publisher) { $Entry.Publishers.Add([string]$App.publisher) } + if ($App.version -and $Entry.Versions -notcontains [string]$App.version) { $Entry.Versions.Add([string]$App.version) } + $Platform = if ([string]::IsNullOrWhiteSpace($App.platform)) { 'Unknown' } else { [string]$App.platform } + if ($Entry.Platforms -notcontains $Platform) { $Entry.Platforms.Add($Platform) } + foreach ($Device in @($App.managedDevices ?? @())) { + $DeviceKey = if ($Device.id) { [string]$Device.id } else { [string]$Device.deviceName } + if ($DeviceKey -and -not $Entry.Devices.Contains($DeviceKey)) { $Entry.Devices[$DeviceKey] = $Device } + } + } + + $DetectedApps = [System.Collections.Generic.List[object]]::new() + foreach ($Entry in $DetectedAppMap.Values) { + $Match = $Entry.Match + # Inventory rows mix clean publisher names with full certificate subjects - show the shortest + $Publisher = $Entry.Publishers | Sort-Object -Property Length | Select-Object -First 1 $DetectedApps.Add([PSCustomObject]@{ - application = $App.displayName - aiTool = $Match.name - vendor = $Match.vendor - category = $Match.category - risk = $Match.risk - publisher = $App.publisher - version = $App.version - platform = if ([string]::IsNullOrWhiteSpace($App.platform)) { 'Unknown' } else { $App.platform } - deviceCount = $DeviceCount + application = ($Entry.Applications | Sort-Object) -join ', ' + aiTool = $Match.name + vendor = $Match.vendor + category = $Match.category + risk = if ($Entry.Sanctioned) { 'Informational' } else { $Match.risk } + catalogRisk = $Match.risk + status = if ($Entry.Sanctioned) { 'Sanctioned' } else { 'Unsanctioned' } + toolDescription = $Match.description + riskReason = $Match.riskReason + publisher = $Publisher + version = ($Entry.Versions | Sort-Object) -join ', ' + platform = ($Entry.Platforms | Sort-Object) -join ', ' + deviceCount = $Entry.Devices.Count + managedDevices = @($Entry.Devices.Values) }) } @@ -101,12 +147,17 @@ function Invoke-ListShadowAI { } else { @() } + $IsSanctioned = $SanctionedTools.ContainsKey($Match.name.ToLower()) $Consent = [PSCustomObject]@{ application = $Sp.displayName aiTool = $Match.name vendor = $Match.vendor category = $Match.category - risk = $Match.risk + risk = if ($IsSanctioned) { 'Informational' } else { $Match.risk } + catalogRisk = $Match.risk + status = if ($IsSanctioned) { 'Sanctioned' } else { 'Unsanctioned' } + toolDescription = $Match.description + riskReason = $Match.riskReason applicationId = $Sp.appId approvedPermissions = @($Permissions) firstConsentedDateTime = $Sp.createdDateTime @@ -152,13 +203,13 @@ function Invoke-ListShadowAI { $ToolMap = @{} foreach ($App in $DetectedApps) { if (-not $ToolMap.ContainsKey($App.aiTool)) { - $ToolMap[$App.aiTool] = [PSCustomObject]@{ Tool = $App.aiTool; Category = $App.category; Risk = $App.risk; Devices = 0; Users = 0 } + $ToolMap[$App.aiTool] = [PSCustomObject]@{ Tool = $App.aiTool; Category = $App.category; Risk = $App.risk; Status = $App.status; Devices = 0; Users = 0 } } $ToolMap[$App.aiTool].Devices += $App.deviceCount } foreach ($App in $ConsentedApps) { if (-not $ToolMap.ContainsKey($App.aiTool)) { - $ToolMap[$App.aiTool] = [PSCustomObject]@{ Tool = $App.aiTool; Category = $App.category; Risk = $App.risk; Devices = 0; Users = 0 } + $ToolMap[$App.aiTool] = [PSCustomObject]@{ Tool = $App.aiTool; Category = $App.category; Risk = $App.risk; Status = $App.status; Devices = 0; Users = 0 } } $ToolMap[$App.aiTool].Users += [int]$App.activeUsersLast7Days } @@ -184,6 +235,7 @@ function Invoke-ListShadowAI { users = $_.Users footprint = $_.Devices + $_.Users category = $_.Category + status = $_.Status } } @@ -193,6 +245,7 @@ function Invoke-ListShadowAI { deviceInstalls = [int](($DetectedApps | Measure-Object -Property deviceCount -Sum).Sum) consentedAiApps = $ConsentedApps.Count highRiskTools = @($ToolMap.Values | Where-Object { $_.Risk -eq 'High' }).Count + sanctionedTools = @($ToolMap.Values | Where-Object { $_.Status -eq 'Sanctioned' }).Count intuneSynced = $IntuneSynced entraSynced = $EntraSynced lastDataRefresh = $LastDataRefresh diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 index aae480c97d51e..5e717af28807e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 @@ -20,7 +20,7 @@ function Invoke-ListTenantAlignment { $TemplateLookup = @{} if ($Granular) { $TemplateTable = Get-CippTable -tablename 'templates' - $TemplatePartitions = @('IntuneTemplate', 'ConditionalAccessTemplate', 'QuarantineTemplate') + $TemplatePartitions = @('IntuneTemplate', 'ConditionalAccessTemplate', 'QuarantineTemplate', 'IntuneReusableSettingTemplate') foreach ($Partition in $TemplatePartitions) { Get-CIPPAzDataTableEntity @TemplateTable -Filter "PartitionKey eq '$Partition'" | ForEach-Object { $TemplateRow = $_ @@ -64,6 +64,7 @@ function Invoke-ListTenantAlignment { 'IntuneTemplate' { 'Intune Template' } 'ConditionalAccessTemplate' { 'Conditional Access Template' } 'QuarantineTemplate' { 'Quarantine Template' } + 'ReusableSettingsTemplate' { 'Reusable Settings Template' } default { $MatchType } } "$FriendlyType - $PolicyName" diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 index 94444123db7f2..8302317f89a53 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 @@ -35,11 +35,26 @@ function Invoke-RemoveStandardTemplate { Remove-AzDataTableEntity -Force @ScheduledTasksTable -Entity $DriftTask Write-LogMessage -Headers $Headers -API $APIName -message "Removed drift remediation scheduled task: $($DriftTask.Name)" -Sev Info } + $StandardsReportsTable = Get-CIPPTable -TableName 'CippStandardsReports' + $RemovedTemplateIds = @(@($Entities.RowKey) + $ID | Where-Object { $_ } | Select-Object -Unique) + $OrphanedReports = [System.Collections.Generic.List[object]]::new() + foreach ($RemovedTemplateId in $RemovedTemplateIds) { + $SafeTemplateId = ConvertTo-CIPPODataFilterValue -Value $RemovedTemplateId -Type Guid + $Rows = Get-CIPPAzDataTableEntity @StandardsReportsTable -Filter "TemplateId eq '$SafeTemplateId'" + foreach ($Row in $Rows) { $OrphanedReports.Add($Row) } + } + if ($OrphanedReports.Count -gt 0) { + Remove-AzDataTableEntity -Force @StandardsReportsTable -Entity @($OrphanedReports) + Write-LogMessage -Headers $Headers -API $APIName -message "Removed $($OrphanedReports.Count) orphaned standards comparison row(s) for template id: $($ID)" -Sev Info + } $Result = "Removed Standards Template named: '$($TemplateName)' with id: $($ID)" if ($DriftTasks) { $Result += ". Also removed $(@($DriftTasks).Count) associated drift remediation scheduled task(s)." } + if ($OrphanedReports.Count -gt 0) { + $Result += " Cleaned up $($OrphanedReports.Count) orphaned standards comparison row(s)." + } Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev Info $StatusCode = [HttpStatusCode]::OK } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 index 8f844ddb9de77..eb71fa0dcbfb2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 @@ -35,7 +35,8 @@ function Invoke-listStandardTemplates { } -Force Write-LogMessage -headers $Request.Headers -API 'Standards' -message "Standards template '$($RowKey)' contained corrupt data (case-duplicate keys) and was automatically repaired and re-saved." -Sev 'Warning' } catch { - Write-LogMessage -headers $Request.Headers -API 'Standards' -message "Standards template '$($RowKey)' was repaired for this response but could not be re-saved: $($_.Exception.Message)" -Sev 'Warning' + Write-LogMessage -headers $Request.Headers -API 'Standards' -message "Standards template '$($RowKey)' was repaired but could not be re-saved, so it was omitted from the response: $($_.Exception.Message)" -Sev 'Error' + return } } if ($Data) { diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 index 7ed2feadb7563..8c3ef3c619c99 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 @@ -46,7 +46,7 @@ function Invoke-CIPPStandardAddDMARCToMOERA { try { $DomainsResponse = New-GraphGetRequest -TenantID $Tenant -Uri 'https://graph.microsoft.com/beta/domains' Write-Warning ($DomainsResponse | ConvertTo-Json -Depth 5) - $Domains = @($DomainsResponse | Where-Object { $_.id -like '*.onmicrosoft.com' } | ForEach-Object { $_.id }) + $Domains = @($DomainsResponse | Where-Object { $_.id -like '*.onmicrosoft.com' -and $_.id -notlike '*.mail.onmicrosoft.com' } | ForEach-Object { $_.id }) Write-Information "Detected $($Domains.Count) MOERA domains: $($Domains -join ', ')" $CurrentInfo = foreach ($Domain in $Domains) { diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAntiPhishPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAntiPhishPolicy.ps1 index 855f555caf193..c202a60b3ff68 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAntiPhishPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAntiPhishPolicy.ps1 @@ -312,8 +312,7 @@ function Invoke-CIPPStandardAntiPhishPolicy { } if ($Settings.report -eq $true) { - $FieldValue = $StateIsCorrect ? $true : $CurrentState - Set-CIPPStandardsCompareField -FieldName 'standards.AntiPhishPolicy' -FieldValue $FieldValue -TenantFilter $Tenant + Set-CIPPStandardsCompareField -FieldName 'standards.AntiPhishPolicy' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant Add-CIPPBPAField -FieldName 'AntiPhishPolicy' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardBranding.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardBranding.ps1 index 050f5d6043f6d..38986f49dd7d6 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardBranding.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardBranding.ps1 @@ -54,6 +54,22 @@ function Invoke-CIPPStandardBranding { $Localizations = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/organization/$($TenantId.customerId)/branding/localizations" -tenantID $Tenant -AsApp $true # Get layoutTemplateType value using null-coalescing operator $layoutTemplateType = $Settings.layoutTemplateType.value ?? $Settings.layoutTemplateType + + $SetSignInPageText = -not [string]::IsNullOrEmpty($Settings.signInPageText) + $SetUsernameHintText = -not [string]::IsNullOrEmpty($Settings.usernameHintText) + + $BrandingBody = [ordered]@{ + loginPageTextVisibilitySettings = [pscustomobject]@{ + hideAccountResetCredentials = $Settings.hideAccountResetCredentials + } + loginPageLayoutConfiguration = [pscustomobject]@{ + layoutTemplateType = $layoutTemplateType + isHeaderShown = $Settings.isHeaderShown + isFooterShown = $Settings.isFooterShown + } + } + if ($SetSignInPageText) { $BrandingBody['signInPageText'] = $Settings.signInPageText } + if ($SetUsernameHintText) { $BrandingBody['usernameHintText'] = $Settings.usernameHintText } # If default localization (id "0") exists, use that to get the currentState. Otherwise we have to create it first. if ($Localizations | Where-Object { $_.id -eq '0' }) { try { @@ -71,18 +87,7 @@ function Invoke-CIPPStandardBranding { AsApp = $true Type = 'POST' ContentType = 'application/json; charset=utf-8' - Body = [pscustomobject]@{ - signInPageText = $Settings.signInPageText - usernameHintText = $Settings.usernameHintText - loginPageTextVisibilitySettings = [pscustomobject]@{ - hideAccountResetCredentials = $Settings.hideAccountResetCredentials - } - loginPageLayoutConfiguration = [pscustomobject]@{ - layoutTemplateType = $layoutTemplateType - isHeaderShown = $Settings.isHeaderShown - isFooterShown = $Settings.isFooterShown - } - } | ConvertTo-Json -Compress + Body = [pscustomobject]$BrandingBody | ConvertTo-Json -Compress } $CurrentState = New-GraphPostRequest @GraphRequest } catch { @@ -92,22 +97,18 @@ function Invoke-CIPPStandardBranding { } } - $StateIsCorrect = ($CurrentState.signInPageText -eq $Settings.signInPageText) -and - ($CurrentState.usernameHintText -eq $Settings.usernameHintText) -and - ($CurrentState.loginPageTextVisibilitySettings.hideAccountResetCredentials -eq $Settings.hideAccountResetCredentials) -and + $StateIsCorrect = ($CurrentState.loginPageTextVisibilitySettings.hideAccountResetCredentials -eq $Settings.hideAccountResetCredentials) -and ($CurrentState.loginPageLayoutConfiguration.layoutTemplateType -eq $layoutTemplateType) -and ($CurrentState.loginPageLayoutConfiguration.isHeaderShown -eq $Settings.isHeaderShown) -and - ($CurrentState.loginPageLayoutConfiguration.isFooterShown -eq $Settings.isFooterShown) + ($CurrentState.loginPageLayoutConfiguration.isFooterShown -eq $Settings.isFooterShown) -and + (-not $SetSignInPageText -or ($CurrentState.signInPageText -eq $Settings.signInPageText)) -and + (-not $SetUsernameHintText -or ($CurrentState.usernameHintText -eq $Settings.usernameHintText)) - $CurrentValue = [PSCustomObject]@{ - signInPageText = $CurrentState.signInPageText - usernameHintText = $CurrentState.usernameHintText + $CurrentValue = [ordered]@{ loginPageTextVisibilitySettings = $CurrentState.loginPageTextVisibilitySettings | Select-Object -Property hideAccountResetCredentials loginPageLayoutConfiguration = $CurrentState.loginPageLayoutConfiguration | Select-Object -Property layoutTemplateType, isHeaderShown, isFooterShown } - $ExpectedValue = [PSCustomObject]@{ - signInPageText = $Settings.signInPageText - usernameHintText = $Settings.usernameHintText + $ExpectedValue = [ordered]@{ loginPageTextVisibilitySettings = [pscustomobject]@{ hideAccountResetCredentials = $Settings.hideAccountResetCredentials } @@ -117,6 +118,16 @@ function Invoke-CIPPStandardBranding { isFooterShown = $Settings.isFooterShown } } + if ($SetSignInPageText) { + $CurrentValue['signInPageText'] = $CurrentState.signInPageText + $ExpectedValue['signInPageText'] = $Settings.signInPageText + } + if ($SetUsernameHintText) { + $CurrentValue['usernameHintText'] = $CurrentState.usernameHintText + $ExpectedValue['usernameHintText'] = $Settings.usernameHintText + } + $CurrentValue = [pscustomobject]$CurrentValue + $ExpectedValue = [pscustomobject]$ExpectedValue if ($Settings.remediate -eq $true) { if ($StateIsCorrect -eq $true) { @@ -129,18 +140,7 @@ function Invoke-CIPPStandardBranding { AsApp = $true Type = 'PATCH' ContentType = 'application/json; charset=utf-8' - Body = [pscustomobject]@{ - signInPageText = $Settings.signInPageText - usernameHintText = $Settings.usernameHintText - loginPageTextVisibilitySettings = [pscustomobject]@{ - hideAccountResetCredentials = $Settings.hideAccountResetCredentials - } - loginPageLayoutConfiguration = [pscustomobject]@{ - layoutTemplateType = $layoutTemplateType - isHeaderShown = $Settings.isHeaderShown - isFooterShown = $Settings.isFooterShown - } - } | ConvertTo-Json -Compress + Body = [pscustomobject]$BrandingBody | ConvertTo-Json -Compress } $null = New-GraphPostRequest @GraphRequest Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'Successfully updated branding.' -Sev Info diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index bb7c1d6d1d2ca..1fe08bd2235e4 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -67,6 +67,7 @@ function Invoke-CIPPStandardConditionalAccessTemplate { return } + $DeployError = $null if ($Settings.remediate -eq $true) { try { $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Settings.TemplateList.value)'" @@ -76,7 +77,7 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -Preset EntraP2 -SkipLog if (!$TestP2) { Write-Information "Skipping policy $($Policy.displayName) as it requires AAD Premium P2 license." - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Policy.displayName) requires AAD Premium P2 license." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -CurrentValue @{ Differences = 'Policy requires an AAD Premium P2 license, which this tenant does not have.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant return $true } } @@ -97,17 +98,20 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $null = New-CIPPCAPolicy @NewCAPolicy } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to create or update conditional access rule $($JSONObj.displayName). Error: $ErrorMessage" -sev 'Error' + # Capture the Graph deploy error (e.g. invalid CA policy 1011/1085) so the report + # section below surfaces the reason in the compare fields instead of just "missing". + $DeployError = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to create or update conditional access rule $($JSONObj.displayName). Error: $DeployError" -sev 'Error' } } if ($Settings.report -eq $true -or $Settings.remediate -eq $true) { + $FieldName = "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Settings.TemplateList.value)'" $Policy = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON | ConvertFrom-Json -Depth 10 if ($null -eq $Policy) { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Conditional Access template '$($Settings.TemplateList.label)' ($($Settings.TemplateList.value)) could not be loaded from the template store - skipping." -Sev 'Error' - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Template '$($Settings.TemplateList.label)' could not be loaded from the template store." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Template '$($Settings.TemplateList.label)' could not be loaded from the template store." } -ExpectedValue @{ Differences = @() } -Tenant $Tenant return } @@ -118,43 +122,75 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $Policy | Add-Member -NotePropertyName 'state' -NotePropertyValue $Settings.state -Force } + if ($Policy.sessionControls) { + if ($Policy.sessionControls.disableResilienceDefaults -ne $true) { + $Policy.sessionControls.PSObject.Properties.Remove('disableResilienceDefaults') + } + if (@($Policy.sessionControls.PSObject.Properties).Count -eq 0) { + $Policy.PSObject.Properties.Remove('sessionControls') + } + } + + # Resolve the template's location GUIDs to display names so they compare like-for-like + # with the deployed policy. The template's own LocationInfo carries the id->name map + # (the GUID is the source tenant's id); fall back to this tenant's named-location cache. + if ($Policy.conditions.locations) { + $LocNameById = @{} + foreach ($li in @($Policy.LocationInfo)) { if ($li.id -and $li.displayName) { $LocNameById[$li.id] = $li.displayName } } + foreach ($pl in @($PreloadedLocations)) { if ($pl.id -and $pl.displayName -and -not $LocNameById.ContainsKey($pl.id)) { $LocNameById[$pl.id] = $pl.displayName } } + foreach ($LocDir in 'includeLocations', 'excludeLocations') { + if ($Policy.conditions.locations.PSObject.Properties.Name -contains $LocDir -and $Policy.conditions.locations.$LocDir) { + $Policy.conditions.locations.$LocDir = @($Policy.conditions.locations.$LocDir | ForEach-Object { + if ($LocNameById.ContainsKey($_)) { $LocNameById[$_] } else { $_ } + }) + } + } + } + $CheckExististing = $AllCAPolicies | Where-Object -Property displayName -EQ $Settings.TemplateList.label + # Duplicate display names would pass an array to New-CIPPCATemplate (breaking its single-object + # conversion and dumping the whole template). Compare against the first match instead. + if ($CheckExististing -is [array] -and $CheckExististing.Count -gt 1) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Found $($CheckExististing.Count) Conditional Access policies named '$($Settings.TemplateList.label)' in $Tenant. Comparing against the first; duplicate policies should be cleaned up." -Sev 'Warning' + $CheckExististing = $CheckExististing[0] + } if (!$CheckExististing) { - if ($Policy.conditions.userRiskLevels.Count -gt 0 -or $Policy.conditions.signInRiskLevels.Count -gt 0) { + if ($DeployError) { + # Attempted but the Graph deployment errored (e.g. invalid CA policy) - surface the reason + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Failed to deploy: $DeployError" } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + } elseif ($Policy.conditions.userRiskLevels.Count -gt 0 -or $Policy.conditions.signInRiskLevels.Count -gt 0) { $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -Preset EntraP2 -SkipLog if (!$TestP2) { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Settings.TemplateList.label) requires AAD Premium P2 license." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Policy requires an AAD Premium P2 license, which this tenant does not have.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant } else { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Settings.TemplateList.label) is missing from this tenant." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Policy is missing from this tenant.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant } } else { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Settings.TemplateList.label) is missing from this tenant." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Policy is missing from this tenant.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant } } else { - $templateResult = New-CIPPCATemplate -TenantFilter $tenant -JSON $CheckExististing -preloadedLocations $preloadedLocations + $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 + if ($null -eq $CompareObj) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Cannot compare CA policy: tenant policy conversion returned null for $($Settings.TemplateList.label)" -sev Error + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Tenant policy conversion returned null.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant return } try { $Compare = Compare-CIPPIntuneObject -ReferenceObject $Policy -DifferenceObject $CompareObj -CompareType 'ca' } catch { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Error comparing CA policy: $($_.Exception.Message)" -sev Error - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Error comparing policy: $($_.Exception.Message)" -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Error comparing policy: $($_.Exception.Message)" } -ExpectedValue @{ Differences = @() } -Tenant $Tenant return } if (!$Compare) { $ExpectedValue = @{ 'Differences' = 'No Differences found' } $CurrentValue = @{ 'Differences' = 'No Differences found' } - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue $true -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -FieldValue $true -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } else { - #this can still be prettified but is for later. $ExpectedValue = @{ 'Differences' = @() } $CurrentValue = @{ 'Differences' = $Compare } - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } } } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 index a6ddad102a4dd..f861da92045e9 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 @@ -120,15 +120,18 @@ function Invoke-CIPPStandardDeployMailContact { # Report if ($Settings.report -eq $true) { + # Email addresses are compared lowercased on both sides: Exchange re-cases the domain part + # when it creates the contact (support@mydomain.com becomes support@Mydomain.com), which + # would otherwise fail the case-sensitive comparison forever. $ContactData = @{ DisplayName = $Settings.DisplayName - ExternalEmailAddress = $Settings.ExternalEmailAddress + ExternalEmailAddress = ([string]$Settings.ExternalEmailAddress).ToLower() FirstName = $Settings.FirstName ?? '' LastName = $Settings.LastName ?? '' } $currentValue = @{ DisplayName = $ExistingContactLookup.displayName - ExternalEmailAddress = ($ExistingContact.ExternalEmailAddress -replace 'SMTP:', '') + ExternalEmailAddress = ([string]($ExistingContact.ExternalEmailAddress -replace 'SMTP:', '')).ToLower() FirstName = $ExistingContactLookup.givenName ?? '' LastName = $ExistingContactLookup.surname ?? '' } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 index d21bee26084e0..2158e121f8c21 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 @@ -116,11 +116,16 @@ function Invoke-CIPPStandardIntuneTemplate { if ($ExistingPolicy) { try { - $RawJSON = Get-CIPPTextReplacement -Text $RawJSON -TenantFilter $Tenant + $RawJSON = Get-CIPPTextReplacement -Text $RawJSON -TenantFilter $Tenant -EscapeForJson $JSONExistingPolicy = $ExistingPolicy.cippconfiguration | ConvertFrom-Json $JSONTemplate = $RawJSON | ConvertFrom-Json $Compare = Compare-CIPPIntuneObject -ReferenceObject $JSONTemplate -DifferenceObject $JSONExistingPolicy -compareType $TemplateType -ErrorAction SilentlyContinue } catch { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to compare Intune Template $displayname against the existing policy: $($_.Exception.Message)" -sev 'Error' + $Compare = [pscustomobject]@{ + MatchFailed = $true + Difference = "Comparison failed: $($_.Exception.Message)" + } } Write-Information "[IntuneTemplate][$Tenant] Compare '$displayname': $([int]($sw.Elapsed - $lap).TotalMilliseconds)ms" $lap = $sw.Elapsed diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 index f986c4446e3c2..c9bc356ccec04 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardOauthConsent { .SYNOPSIS (Label) Require admin consent for applications (Prevent OAuth phishing) .DESCRIPTION - (Helptext) Disables users from being able to consent to applications, except for those specified in the field below - (DocsDescription) Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications. + (Helptext) Disables users from being able to consent to applications, except for those specified in the field below. This standard conflicts with the "Allow users to consent to applications with low security risk" standard; only one of the two should be assigned per tenant. + (DocsDescription) Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications. This standard conflicts with the "Allow users to consent to applications with low security risk" (OauthConsentLowSec) standard. Enabling both on the same tenant causes a remediation conflict, so only assign one. .NOTES CAT Entra (AAD) Standards @@ -66,7 +66,13 @@ function Invoke-CIPPStandardOauthConsent { } $StateIsCorrect = if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -eq 'ManagePermissionGrantsForSelf.cipp-consent-policy') { $true } else { $false } - if ($Settings.remediate -eq $true) { + $Standards = Get-CIPPStandards -Tenant $tenant + $ConflictingStandard = $Standards | Where-Object -Property Standard -EQ 'OauthConsentLowSec' + + if ($Settings.remediate -eq $true -and $ConflictingStandard -and $State.permissionGrantPolicyIdsAssignedToDefaultUserRole -contains 'ManagePermissionGrantsForSelf.microsoft-user-default-low') { + # A conflicting low security OAuth consent standard is enabled and currently applied. Skip remediation so we don't fight the other standard, but still fall through to alert/report. + Write-LogMessage -API 'Standards' -tenant $tenant -message 'There is a conflicting OAuth Consent policy standard enabled for this tenant. Remove the Allow users to consent to applications with low security risk (Prevent OAuth phishing. Lower impact, less secure) standard from this tenant to apply the require admin consent standard.' -sev Error + } elseif ($Settings.remediate -eq $true) { $DidRemediationChange = $false try { if (-not $CompareIncludesFetched) { @@ -222,6 +228,14 @@ function Invoke-CIPPStandardOauthConsent { permissionGrantPolicyIdsAssignedToDefaultUserRole = $State.permissionGrantPolicyIdsAssignedToDefaultUserRole includes = $CurrentIncludesForCompare } + # Add conflicting standard info if applicable + if ($ConflictingStandard) { + $CurrentValue.conflictingStandard = @{ + name = $ConflictingStandard.Standard + templateid = $ConflictingStandard.TemplateId + } + } + $ExpectedValue = @{ permissionGrantPolicyIdsAssignedToDefaultUserRole = @('ManagePermissionGrantsForSelf.cipp-consent-policy') includes = $ExpectedIncludesForCompare diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 index bd359c2bba5f4..186f02face965 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardOauthConsentLowSec { .SYNOPSIS (Label) Allow users to consent to applications with low security risk (Prevent OAuth phishing. Lower impact, less secure) .DESCRIPTION - (Helptext) Sets the default oauth consent level so users can consent to applications that have low risks. - (DocsDescription) Allows users to consent to applications with low assigned risk. + (Helptext) Sets the default oauth consent level so users can consent to applications that have low risks. This standard conflicts with the "Require admin consent for applications" standard; only one of the two should be assigned per tenant. + (DocsDescription) Allows users to consent to applications with low assigned risk. This standard conflicts with the "Require admin consent for applications (Prevent OAuth phishing)" (OauthConsent) standard. Enabling both on the same tenant causes a remediation conflict, so only assign one. .NOTES CAT Entra (AAD) Standards diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 index c03242b0d0c79..99f4b13fa6705 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 @@ -65,15 +65,16 @@ function Invoke-CIPPStandardReusableSettingsTemplate { $TestResult = Test-CIPPStandardLicense -StandardName 'ReusableSettingsTemplate_general' -TenantFilter $Tenant -Preset Intune if ($TestResult -eq $false) { $settings.TemplateList | ForEach-Object { - $MissingLicenseMessage = "This tenant is missing one or more required licenses for this standard: $($RequiredCapabilities -join ', ')." - Set-CIPPStandardsCompareField -FieldName "standards.ReusableSettingsTemplate.$($_.value)" -FieldValue $MissingLicenseMessage -Tenant $Tenant + $MissingLicenseMessage = 'License Missing: This tenant is missing the required Intune license for this standard.' + Set-CIPPStandardsCompareField -FieldName "standards.ReusableSettingsTemplate.$($_.value)" -FieldValue $MissingLicenseMessage -LicenseAvailable $false -TenantFilter $Tenant } - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Exiting as the correct license is not present for this standard. Missing: $($RequiredCapabilities -join ', ')" -sev 'Warning' + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Exiting as the correct license is not present for this standard.' -sev 'Warning' return $true } $Table = Get-CippTable -tablename 'templates' - $ExistingReusableSettings = New-GraphGETRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings?$top=999' -tenantid $Tenant + # The list endpoint omits settingInstance unless explicitly selected, which would make every compare fail + $ExistingReusableSettings = New-GraphGETRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings?$top=999&$select=id,displayName,description,settingDefinitionId,settingInstance,version' -tenantid $Tenant # Align with other template standards by resolving all selected templates upfront $SelectedTemplateIds = @($Settings.TemplateList.value) @@ -169,8 +170,15 @@ function Invoke-CIPPStandardReusableSettingsTemplate { if ($true -in $Settings.report) { foreach ($Template in $CompareList | Where-Object { $_.report -eq $true -or $_.remediate -eq $true }) { $id = $Template.templateId - $state = $Template.compare ? $Template.compare : $true - Set-CIPPStandardsCompareField -FieldName "standards.ReusableSettingsTemplate.$id" -FieldValue $state -TenantFilter $Tenant + $CurrentValue = @{ + displayName = $Template.displayname + isCompliant = if ($Template.compare) { $false } else { $true } + } + $ExpectedValue = @{ + displayName = $Template.displayname + isCompliant = $true + } + Set-CIPPStandardsCompareField -FieldName "standards.ReusableSettingsTemplate.$id" -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant } } } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitiveInfoTypeTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitiveInfoTypeTemplate.ps1 index d61620b079365..b7bd969e0e982 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitiveInfoTypeTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitiveInfoTypeTemplate.ps1 @@ -50,36 +50,52 @@ function Invoke-CIPPStandardSensitiveInfoTypeTemplate { return } - if ($Settings.remediate -eq $true) { - foreach ($Template in @($Templates)) { - $null = Set-CIPPSensitiveInfoType -TenantFilter $Tenant -Template $Template -APIName 'Standards' + # Compare each template against the live SIT's rule pack and remediate only what drifts (or is + # missing). After a successful remediation, re-compare so the report/alert reflect the fixed state. + $Comparisons = foreach ($Template in @($Templates)) { + $Comparison = Compare-CIPPSensitiveInfoType -TenantFilter $Tenant -Template $Template + + if ($Settings.remediate -eq $true -and $Comparison.State -in @('Missing', 'Drift')) { + $DeployResult = Set-CIPPSensitiveInfoType -TenantFilter $Tenant -Template $Template -APIName 'Standards' + if ($DeployResult -match '^(Created|Updated)') { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Remediated SIT '$($Comparison.Name)' ($($Comparison.State)): $DeployResult" -sev Info + $Comparison = Compare-CIPPSensitiveInfoType -TenantFilter $Tenant -Template $Template + } else { + Write-LogMessage -API 'Standards' -tenant $Tenant -message $DeployResult -sev Error + $Comparison | Add-Member -NotePropertyName DeployError -NotePropertyValue "$DeployResult" -Force + } } + $Comparison } - $ExistingSitNames = try { - @(New-ExoRequest -tenantid $Tenant -cmdlet 'Get-DlpSensitiveInformationType' -Compliance | Select-Object -ExpandProperty Name) - } catch { @() } - - $MissingSits = @(foreach ($Template in @($Templates)) { - $TemplateName = $Template.Name ?? $Template.name - if ($ExistingSitNames -notcontains $TemplateName) { $TemplateName } - }) + # Non-compliant when the SIT is missing, drifted, or the template is invalid. Built-in and in-sync + # SITs are compliant. + $NonCompliant = @($Comparisons | Where-Object { $_.State -in @('Missing', 'Drift', 'Invalid') }) if ($Settings.alert -eq $true) { - if ($MissingSits.Count -eq 0) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All selected Sensitive Information Type templates are deployed.' -sev Info + if ($NonCompliant.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All selected Sensitive Information Type templates are deployed and in sync.' -sev Info } else { - $AlertMessage = "Sensitive Information Types not deployed in tenant: $($MissingSits -join ', ')" - Write-StandardsAlert -message $AlertMessage -object @{ MissingSensitiveInfoTypes = $MissingSits } -tenant $Tenant -standardName 'SensitiveInfoTypeTemplate' -standardId $Settings.standardId + $Summary = $NonCompliant | ForEach-Object { + if ($_.State -eq 'Drift') { + $Fields = @($_.Differences | ForEach-Object { "$($_.Scope)/$($_.Field)" }) -join ', ' + "$($_.Name): drift in $Fields" + } else { + "$($_.Name): $($_.State)" + } + } + $AlertMessage = "Sensitive Information Type templates not in sync: $($Summary -join '; ')" + Write-StandardsAlert -message $AlertMessage -object @{ NonCompliantSensitiveInfoTypes = $NonCompliant } -tenant $Tenant -standardName 'SensitiveInfoTypeTemplate' -standardId $Settings.standardId Write-LogMessage -API 'Standards' -tenant $Tenant -message $AlertMessage -sev Info } } if ($Settings.report -eq $true) { - $CurrentValue = @{ MissingSensitiveInfoTypes = $MissingSits } - $ExpectedValue = @{ MissingSensitiveInfoTypes = @() } + # Expose the actual drift (per SIT: state + the differing fields with expected vs current values). + $CurrentValue = @{ NonCompliantSensitiveInfoTypes = $NonCompliant } + $ExpectedValue = @{ NonCompliantSensitiveInfoTypes = @() } Set-CIPPStandardsCompareField -FieldName 'standards.SensitiveInfoTypeTemplate' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant - Add-CIPPBPAField -FieldName 'SensitiveInfoTypeTemplate' -FieldValue ($MissingSits.Count -eq 0) -StoreAs bool -Tenant $Tenant + Add-CIPPBPAField -FieldName 'SensitiveInfoTypeTemplate' -FieldValue ($NonCompliant.Count -eq 0) -StoreAs bool -Tenant $Tenant } } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardStaleEntraDevices.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardStaleEntraDevices.ps1 index 3ad06b586deae..726cb64bf0d49 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardStaleEntraDevices.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardStaleEntraDevices.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardStaleEntraDevices { .SYNOPSIS (Label) Cleanup stale Entra devices .DESCRIPTION - (Helptext) **Remediate is currently not available**. Cleans up Entra devices that have not connected/signed in for the specified number of days. - (DocsDescription) Remediate is currently not available. Cleans up Entra devices that have not connected/signed in for the specified number of days. First disables and later deletes the devices. More info can be found in the [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices) + (Helptext) Cleans up Entra devices that have not connected/signed in for the specified number of days. Remediation first disables stale enabled devices and, on a later run, deletes stale devices that are already disabled. Hybrid-joined, Intune-managed and Autopilot devices are skipped. Deleting a device permanently removes any BitLocker recovery keys stored on it. + (DocsDescription) Cleans up Entra devices that have not connected/signed in for the specified number of days. Remediation first disables stale enabled devices once they pass the disable threshold, and later deletes devices that are already disabled once they have been inactive for the disable threshold plus the configured grace delta (deletion age = disable threshold + grace days). The disable-before-delete grace period is further guaranteed by never deleting a device in the same pass it was disabled. Hybrid-joined (on-premises synced), Intune-managed/compliant, and system-managed Autopilot devices are excluded, in line with the [Microsoft guidance](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices). **Warning:** deleting a device permanently removes any BitLocker recovery keys stored on that device object. .NOTES CAT Entra (AAD) Standards @@ -19,9 +19,10 @@ function Invoke-CIPPStandardStaleEntraDevices { EXECUTIVETEXT Automatically identifies and removes inactive devices that haven't connected to company systems for a specified period, reducing security risks from abandoned or lost devices. This maintains a clean device inventory and prevents potential unauthorized access through dormant device registrations. ADDEDCOMPONENT - {"type":"number","name":"standards.StaleEntraDevices.deviceAgeThreshold","label":"Days before stale(Do not set below 30)","validators":{"min":{"value":30,"message":"Minimum value is 30"}}} + {"type":"number","name":"standards.StaleEntraDevices.deviceAgeThreshold","label":"Days before stale (disables the device after this many days of inactivity, minimum 30)","required":true,"defaultValue":90,"validators":{"min":{"value":30,"message":"Minimum value is 30"}}} + {"type":"number","name":"standards.StaleEntraDevices.deviceDeleteThreshold","label":"Grace days after disable before deletion (0 = never delete). Devices are deleted once inactive for the disable threshold plus this many additional days.","defaultValue":0,"validators":{"min":{"value":0,"message":"Minimum value is 0"}}} DISABLEDFEATURES - {"report":false,"warn":false,"remediate":true} + {"report":false,"warn":false,"remediate":false} IMPACT High Impact ADDEDDATE @@ -30,11 +31,6 @@ function Invoke-CIPPStandardStaleEntraDevices { Remove-MgDevice, Update-MgDevice or Graph API RECOMMENDEDBY REQUIREDCAPABILITIES - "INTUNE_A" - "MDM_Services" - "EMS" - "SCCM" - "MICROSOFTINTUNEPLAN1" UPDATECOMMENTBLOCK Run the Tools\Update-StandardsComments.ps1 script to update this comment block .LINK @@ -42,72 +38,203 @@ function Invoke-CIPPStandardStaleEntraDevices { #> param($Tenant, $Settings) - $TestResult = Test-CIPPStandardLicense -StandardName 'StaleEntraDevices' -TenantFilter $Tenant -Preset Intune - # Get all Entra devices + # Safety guard: never run below the supported minimum. A blank or low threshold would otherwise treat + # every device with any sign-in as stale and disable the entire fleet. + $DisableThreshold = if ([string]::IsNullOrWhiteSpace([string]$Settings.deviceAgeThreshold)) { 0 } else { [int]$Settings.deviceAgeThreshold } + if ($DisableThreshold -lt 30) { + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "StaleEntraDevices: deviceAgeThreshold ($DisableThreshold) is below the minimum of 30 days. Skipping run to prevent mass device changes." -Sev Error + return + } - if ($TestResult -eq $false) { - return $true - } #we're done. + # deviceDeleteThreshold is a delta (grace days) added on top of the disable threshold, so the effective + # delete age is always greater than the disable age - deletion can never overtake disable by construction. + $DeleteDelta = if ([string]::IsNullOrWhiteSpace([string]$Settings.deviceDeleteThreshold)) { 0 } else { [int]$Settings.deviceDeleteThreshold } + if ($DeleteDelta -lt 0) { $DeleteDelta = 0 } + $DeleteEnabled = $DeleteDelta -gt 0 + $DeleteAge = $DisableThreshold + $DeleteDelta try { - $AllDevices = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/devices?$select=id,displayName,approximateLastSignInDateTime,accountEnabled,enrollmentProfileName,operatingSystem,managementType,profileType' -tenantid $Tenant | Where-Object { $null -ne $_.approximateLastSignInDateTime } + $AllDevices = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/devices?$select=id,displayName,approximateLastSignInDateTime,accountEnabled,enrollmentProfileName,operatingSystem,managementType,profileType,onPremisesSyncEnabled,isManaged,isCompliant,physicalIds' -tenantid $Tenant | Where-Object { $null -ne $_.approximateLastSignInDateTime } } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the StaleEntraDevices state for $Tenant. Error: $ErrorMessage" -Sev Error return } - $Date = (Get-Date).AddDays( - [int]$Settings.deviceAgeThreshold) - $StaleDevices = $AllDevices | Where-Object { $_.approximateLastSignInDateTime -lt $Date } + $DisableDate = (Get-Date).AddDays(-$DisableThreshold) + $DeleteDate = (Get-Date).AddDays(-$DeleteAge) + + # Devices are excluded from remediation when they are hybrid-joined (managed on-premises via Entra Connect), + # Intune managed/compliant (should be retired in Intune first), or system-managed Autopilot devices + # (identified by a ZTDID in physicalIds - deleting these breaks re-provisioning and cannot be undone). + $SafetyFilter = { + $_.onPremisesSyncEnabled -ne $true -and + $_.isManaged -ne $true -and + $_.isCompliant -ne $true -and + (@($_.physicalIds) -join ' ') -notmatch '\[ZTDID\]' + } + + # Compute the working sets from the current device state. Re-run after remediation so alert/report reflect + # the post-remediation state. Delete only targets devices that are ALREADY disabled and stale beyond the + # delete threshold; devices disabled in this same run are not deleted until a later run (grace period). + # Dot-sourced so assignments land in this function's scope (no module-level state persists between runs). + $ComputeSets = { + $StaleDevices = @($AllDevices | Where-Object { $_.approximateLastSignInDateTime -lt $DisableDate }) + $RemediationEligibleStaleDevices = @($StaleDevices | Where-Object $SafetyFilter) + $DevicesToDisable = @($RemediationEligibleStaleDevices | Where-Object { $_.accountEnabled -eq $true }) + if ($DeleteEnabled) { + # Every safety-eligible device inactive beyond the delete age meets the delete threshold (surfaced in reports). + $DevicesMeetingDeleteThreshold = @($AllDevices | Where-Object { $_.approximateLastSignInDateTime -lt $DeleteDate } | Where-Object $SafetyFilter) + # Only those already disabled are actually deleted this run; enabled ones are disabled first and deleted later. + $DevicesToDelete = @($DevicesMeetingDeleteThreshold | Where-Object { $_.accountEnabled -ne $true }) + } else { + $DevicesMeetingDeleteThreshold = @() + $DevicesToDelete = @() + } + } + . $ComputeSets if ($Settings.remediate -eq $true) { - # TODO: Implement remediation. For others in the future that want to try this: - # Good MS guide on what to watch out for https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices#clean-up-stale-devices - # https://learn.microsoft.com/en-us/graph/api/device-list?view=graph-rest-beta&tabs=http - # Properties to look at: - # approximateLastSignInDateTime: For knowing when the device last signed in - # enrollmentProfileName and operatingSystem: For knowing if it's an AutoPilot device - # managementType or isManaged: For knowing if it's an Intune managed device. If it is, should be removed from Intune also. Stale intune standard could possibly be used for this. - # profileType: For knowing if it's only registered or also managed - # accountEnabled: For knowing if the device is disabled or not + $DeletedDeviceIds = [System.Collections.Generic.List[string]]::new() + $DisabledCount = 0 + $DeletedCount = 0 + $FailedCount = 0 + + if ($DevicesToDisable.Count -gt 0) { + $DisableRequests = [System.Collections.Generic.List[hashtable]]::new() + $DisableMap = @{} + $RequestId = 0 + + foreach ($Device in $DevicesToDisable) { + $CurrentId = $RequestId++ + $DisableMap[$CurrentId] = $Device + $DisableRequests.Add(@{ + id = $CurrentId + method = 'PATCH' + url = "devices/$($Device.id)" + body = @{ accountEnabled = $false } + headers = @{ + 'Content-Type' = 'application/json' + } + }) + } + + try { + $DisableResults = New-GraphBulkRequest -tenantid $Tenant -Version 'v1.0' -Requests @($DisableRequests) + foreach ($Result in $DisableResults) { + $Device = $DisableMap[[int]$Result.id] + if ($null -eq $Device) { + continue + } + + if ($Result.status -eq 200 -or $Result.status -eq 204) { + $DisabledCount++ + $Device.accountEnabled = $false + } else { + $FailedCount++ + $ErrorMessage = if ($Result.body.error.message) { $Result.body.error.message } else { "Unknown error (Status: $($Result.status))" } + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not disable stale device $($Device.displayName) ($($Device.id)). Error: $ErrorMessage" -Sev Error + } + } + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $FailedCount += $DevicesToDisable.Count + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Failed to process bulk disable stale devices request. Error: $ErrorMessage" -Sev Error + } + } + + if ($DevicesToDelete.Count -gt 0) { + $DeleteRequests = [System.Collections.Generic.List[hashtable]]::new() + $DeleteMap = @{} + $RequestId = 0 + + foreach ($Device in $DevicesToDelete) { + $CurrentId = $RequestId++ + $DeleteMap[$CurrentId] = $Device + $DeleteRequests.Add(@{ + id = $CurrentId + method = 'DELETE' + url = "devices/$($Device.id)" + }) + } + + try { + $DeleteResults = New-GraphBulkRequest -tenantid $Tenant -Version 'v1.0' -Requests @($DeleteRequests) + foreach ($Result in $DeleteResults) { + $Device = $DeleteMap[[int]$Result.id] + if ($null -eq $Device) { + continue + } + + if ($Result.status -eq 200 -or $Result.status -eq 204) { + $DeletedCount++ + $null = $DeletedDeviceIds.Add([string]$Device.id) + } else { + $FailedCount++ + $ErrorMessage = if ($Result.body.error.message) { $Result.body.error.message } else { "Unknown error (Status: $($Result.status))" } + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not delete stale eligible device $($Device.displayName) ($($Device.id)). Error: $ErrorMessage" -Sev Error + } + } + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $FailedCount += $DevicesToDelete.Count + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Failed to process bulk delete stale devices request. Error: $ErrorMessage" -Sev Error + } + } + + # Drop deleted devices, then recompute the working sets so alert/report reflect the post-remediation state. + if ($DeletedDeviceIds.Count -gt 0) { + $AllDevices = @($AllDevices | Where-Object { $_.id -notin $DeletedDeviceIds }) + } + . $ComputeSets + + # Only log when the standard actually acted; skipped devices alone never generate output. + if ($DisabledCount -gt 0 -or $DeletedCount -gt 0 -or $FailedCount -gt 0) { + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "StaleEntraDevices remediation completed. Disabled: $DisabledCount. Deleted: $DeletedCount. Failed: $FailedCount." -Sev Info + } } if ($Settings.alert -eq $true) { - if ($StaleDevices.Count -gt 0) { - Write-StandardsAlert -message "$($StaleDevices.Count) Stale devices found" -object $StaleDevices -tenant $Tenant -standardName 'StaleEntraDevices' -standardId $Settings.standardId - Write-LogMessage -API 'Standards' -tenant $Tenant -message "$($StaleDevices.Count) Stale devices found" -sev Info + # Alert only on actionable devices. Skipped devices (hybrid-joined/Intune-managed/Autopilot) are + # intentionally excluded so the alert never fires on stale devices this standard would never touch. + if ($RemediationEligibleStaleDevices.Count -gt 0) { + $AlertMessage = "$($RemediationEligibleStaleDevices.Count) stale devices requiring action found (to disable: $($DevicesToDisable.Count), meeting delete threshold: $($DevicesMeetingDeleteThreshold.Count))." + Write-StandardsAlert -message $AlertMessage -object $RemediationEligibleStaleDevices -tenant $Tenant -standardName 'StaleEntraDevices' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message $AlertMessage -sev Info } else { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'No stale devices found' -sev Info + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'No stale devices requiring action found' -sev Info } } if ($Settings.report -eq $true) { - if ($StaleDevices.Count -gt 0) { - $StaleReport = ConvertTo-Json -InputObject ($StaleDevices | Select-Object -Property displayName, id, approximateLastSignInDateTime, accountEnabled, enrollmentProfileName, operatingSystem, managementType, profileType) -Depth 10 -Compress + # Report only on actionable devices; skipped devices (hybrid-joined/Intune-managed/Autopilot) are excluded. + if ($RemediationEligibleStaleDevices.Count -gt 0) { + $StaleReport = ConvertTo-Json -InputObject ($RemediationEligibleStaleDevices | Select-Object -Property displayName, id, approximateLastSignInDateTime, accountEnabled, enrollmentProfileName, operatingSystem, managementType, profileType) -Depth 10 -Compress Add-CIPPBPAField -FieldName 'StaleEntraDevices' -FieldValue $StaleReport -StoreAs json -Tenant $Tenant } else { Add-CIPPBPAField -FieldName 'StaleEntraDevices' -FieldValue $true -StoreAs bool -Tenant $Tenant } - if ($StaleDevices.Count -gt 0) { - $FieldValue = $StaleDevices | Select-Object -Property displayName, id, approximateLastSignInDateTime, accountEnabled, enrollmentProfileName, operatingSystem, managementType, profileType + if ($DevicesToDisable.Count -gt 0) { + $EligibleToDisableFieldValue = $DevicesToDisable | Select-Object -Property displayName, id, approximateLastSignInDateTime, accountEnabled, enrollmentProfileName, operatingSystem, managementType, profileType + } + if ($DeleteEnabled -and $DevicesMeetingDeleteThreshold.Count -gt 0) { + $MeetingDeleteThresholdFieldValue = $DevicesMeetingDeleteThreshold | Select-Object -Property displayName, id, approximateLastSignInDateTime, accountEnabled, enrollmentProfileName, operatingSystem, managementType, profileType } $CurrentValue = @{ - StaleDevicesCount = $StaleDevices.Count - StaleDevices = ($FieldValue ? @($FieldValue) :@()) - DeviceAgeThreshold = [int]$Settings.deviceAgeThreshold + EligibleDevicesToDisable = ($EligibleToDisableFieldValue ? @($EligibleToDisableFieldValue) :@()) + DevicesMeetingDeleteThreshold = if ($DeleteEnabled) { ($MeetingDeleteThresholdFieldValue ? @($MeetingDeleteThresholdFieldValue) :@()) } else { 'Deletion disabled' } } $ExpectedValue = @{ - StaleDevicesCount = 0 - StaleDevices = @() - DeviceAgeThreshold = [int]$Settings.deviceAgeThreshold + EligibleDevicesToDisable = @() + DevicesMeetingDeleteThreshold = if ($DeleteEnabled) { @() } else { 'Deletion disabled' } } Set-CIPPStandardsCompareField -FieldName 'standards.StaleEntraDevices' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsChatProtection.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsChatProtection.ps1 index 93eccf035e10e..aaa33a4ae2d38 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsChatProtection.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsChatProtection.ps1 @@ -46,7 +46,7 @@ function Invoke-CIPPStandardTeamsChatProtection { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMessagingConfiguration' | Select-Object -Property Identity, FileTypeCheck, UrlReputationCheck + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMessagingConfiguration' -Action Get -Identity 'Global' | Select-Object -Property Identity, FileTypeCheck, UrlReputationCheck } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the Teams Chat Protection state for $Tenant. Error: $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage @@ -75,7 +75,7 @@ function Invoke-CIPPStandardTeamsChatProtection { } try { - $null = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsMessagingConfiguration' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMessagingConfiguration' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully updated Teams Chat Protection settings to FileTypeCheck: $FileTypeCheckState, UrlReputationCheck: $UrlReputationCheckState" -sev Info } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEmailIntegration.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEmailIntegration.ps1 index fe913f850dacb..aa4735fa00c90 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEmailIntegration.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEmailIntegration.ps1 @@ -46,7 +46,7 @@ function Invoke-CIPPStandardTeamsEmailIntegration { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsClientConfiguration' -CmdParams @{Identity = 'Global' } | + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsClientConfiguration' -Action Get -Identity 'Global' | Select-Object AllowEmailIntoChannel } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -68,7 +68,7 @@ function Invoke-CIPPStandardTeamsEmailIntegration { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsClientConfiguration' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsClientConfiguration' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated Teams Email Integration settings' -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEnrollUser.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEnrollUser.ps1 index 3748d8d67a03d..805afb25e8680 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEnrollUser.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEnrollUser.ps1 @@ -47,7 +47,7 @@ function Invoke-CIPPStandardTeamsEnrollUser { $enrollUserOverride = $Settings.EnrollUserOverride.value ?? $Settings.EnrollUserOverride try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMeetingPolicy' -cmdParams @{Identity = 'Global' } | + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Get -Identity 'Global' | Select-Object EnrollUserOverride } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -67,7 +67,7 @@ function Invoke-CIPPStandardTeamsEnrollUser { } try { - $null = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsMeetingPolicy' -cmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message "Updated Teams Enroll User Override setting to $enrollUserOverride." -sev Info } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalAccessPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalAccessPolicy.ps1 index a6014c5fec2a8..8eb632ba19456 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalAccessPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalAccessPolicy.ps1 @@ -45,7 +45,7 @@ function Invoke-CIPPStandardTeamsExternalAccessPolicy { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsExternalAccessPolicy' -CmdParams @{Identity = 'Global' } | + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'ExternalAccessPolicy' -Action Get -Identity 'Global' | Select-Object * } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -70,7 +70,7 @@ function Invoke-CIPPStandardTeamsExternalAccessPolicy { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsExternalAccessPolicy' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'ExternalAccessPolicy' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated External Access Policy' -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalChatWithAnyone.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalChatWithAnyone.ps1 index f4772514fb929..14ac9225c9cc0 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalChatWithAnyone.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalChatWithAnyone.ps1 @@ -45,7 +45,7 @@ function Invoke-CIPPStandardTeamsExternalChatWithAnyone { } try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMessagingPolicy' -CmdParams @{ Identity = 'Global' } | Select-Object -Property Identity, UseB2BInvitesToAddExternalUsers + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMessagingPolicy' -Action Get -Identity 'Global' | Select-Object -Property Identity, UseB2BInvitesToAddExternalUsers } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the Teams external chat state for $Tenant. Error: $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage @@ -67,7 +67,7 @@ function Invoke-CIPPStandardTeamsExternalChatWithAnyone { } try { - $null = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsMessagingPolicy' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMessagingPolicy' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully updated Teams external chat with anyone setting to UseB2BInvitesToAddExternalUsers: $DesiredState" -sev Info } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalFileSharing.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalFileSharing.ps1 index 1d29eb41f6fab..d772b556d3ced 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalFileSharing.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalFileSharing.ps1 @@ -50,7 +50,7 @@ function Invoke-CIPPStandardTeamsExternalFileSharing { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsClientConfiguration' | + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsClientConfiguration' -Action Get -Identity 'Global' | Select-Object AllowGoogleDrive, AllowShareFile, AllowBox, AllowDropBox, AllowEgnyte } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -78,7 +78,7 @@ function Invoke-CIPPStandardTeamsExternalFileSharing { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsClientConfiguration' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsClientConfiguration' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated Teams External File Sharing' -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 index 4bebc295e2ee0..75cd4fc383475 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 @@ -46,49 +46,46 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTenantFederationConfiguration' -CmdParams @{Identity = 'Global' } | - Select-Object * + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TenantFederationConfiguration' -Action Get -Identity 'Global' } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the TeamsFederationConfiguration state for $Tenant. Error: $ErrorMessage" -Sev Error return } - $AllowAllKnownDomains = New-CsEdgeAllowAllKnownDomains + # ConfigAPI (TenantFederationSettings) domain payload shapes: + # Allow all external -> AllowedDomains = @() (empty array) + # Allow specific external -> AllowedDomains = @{ AllowList = @(list) } + # Block specific external -> AllowedDomains = @{ AllowList = @() } + BlockedDomains = @(list) $DomainControl = $Settings.DomainControl.value ?? $Settings.DomainControl $AllowedDomainsAsAList = @() + $BlockedDomains = @() switch ($DomainControl) { 'AllowAllExternal' { $AllowFederatedUsers = $true - $AllowedDomains = $AllowAllKnownDomains - $AllowedDomainsAsAList = @() - $BlockedDomains = @() + $AllowedDomainsPayload = @() + $ExpectedAllowAllKnown = $true } 'BlockAllExternal' { $AllowFederatedUsers = $false - $AllowedDomains = $AllowAllKnownDomains - $AllowedDomainsAsAList = @() - $BlockedDomains = @() + $AllowedDomainsPayload = @() + $ExpectedAllowAllKnown = $true } 'AllowSpecificExternal' { $AllowFederatedUsers = $true - $AllowedDomains = $null - $BlockedDomains = @() if ($null -ne $Settings.DomainList) { $AllowedDomainsAsAList = @($Settings.DomainList).Split(',').Trim() | Sort-Object - } else { - $AllowedDomainsAsAList = @() } + $AllowedDomainsPayload = @{ AllowList = @($AllowedDomainsAsAList) } + $ExpectedAllowAllKnown = $false } 'BlockSpecificExternal' { $AllowFederatedUsers = $true - $AllowedDomains = $AllowAllKnownDomains - $AllowedDomainsAsAList = @() if ($null -ne $Settings.DomainList) { $BlockedDomains = @($Settings.DomainList).Split(',').Trim() | Sort-Object - } else { - $BlockedDomains = @() } + $AllowedDomainsPayload = @{ AllowList = @() } + $ExpectedAllowAllKnown = $true } default { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Federation Configuration: Invalid $DomainControl parameter" -sev Error @@ -96,62 +93,23 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { } } - # Parse current state based on DomainControl mode - $CurrentAllowedDomains = $CurrentState.AllowedDomains - $CurrentBlockedDomains = $CurrentState.BlockedDomains - $IsCurrentAllowAllKnownDomains = $false - $AllowedDomainsMatches = $false - $BlockedDomainsMatches = $false - - # Check if current allowed domains is AllowAllKnownDomains, and parse specific domains if not - if ($CurrentAllowedDomains) { - if ($CurrentAllowedDomains.GetType().Name -eq 'PSObject') { - $properties = Get-Member -InputObject $CurrentAllowedDomains -MemberType Properties, NoteProperty - if (($null -ne $CurrentAllowedDomains.AllowAllKnownDomains) -or - (Get-Member -InputObject $CurrentAllowedDomains -Name 'AllowAllKnownDomains') -or - (!$properties -or $properties.Count -eq 0)) { - $IsCurrentAllowAllKnownDomains = $true - Write-Information "Current AllowedDomains is AllowAllKnownDomains" - } else { - # Parse specific allowed domains list - if ($null -ne $CurrentAllowedDomains.AllowedDomain -or (Get-Member -InputObject $CurrentAllowedDomains -Name 'AllowedDomain')) { - $CurrentAllowedDomains = @($CurrentAllowedDomains.AllowedDomain | ForEach-Object { $_.Domain }) | Sort-Object - Write-Information "Current AllowedDomains (extracted): $($CurrentAllowedDomains -join ', ')" - } elseif ($null -ne $CurrentAllowedDomains.Domain -or (Get-Member -InputObject $CurrentAllowedDomains -Name 'Domain')) { - $CurrentAllowedDomains = @($CurrentAllowedDomains.Domain) | Sort-Object - Write-Information "Current AllowedDomains (via Domain property): $($CurrentAllowedDomains -join ', ')" - } else { - $CurrentAllowedDomains = @() - } - } - } elseif ($CurrentAllowedDomains.GetType().Name -eq 'Deserialized.Microsoft.Rtc.Management.WritableConfig.Settings.Edge.AllowAllKnownDomains') { - $IsCurrentAllowAllKnownDomains = $true - Write-Information "Current AllowedDomains is AllowAllKnownDomains (Deserialized type)" - } - } else { - $CurrentAllowedDomains = @() + # Parse current state (ConfigAPI TenantFederationSettings GET shape). NOTE the GET/PUT + # asymmetry: the GET nests the allow-list under AllowedDomains.AllowedDomain (allow-all = + # {} with no AllowedDomain), whereas the PUT expects AllowedDomains.AllowList / []. Items + # may be plain strings or objects with a .Domain property, so handle both. + $CurrentAllowedDomains = @() + $ad = $CurrentState.AllowedDomains + if ($ad -and ($ad.PSObject.Properties.Name -contains 'AllowedDomain') -and $ad.AllowedDomain) { + $CurrentAllowedDomains = @($ad.AllowedDomain | ForEach-Object { if ($_ -is [string]) { $_ } elseif ($_.Domain) { $_.Domain } else { "$_" } }) | Sort-Object } - - # Parse blocked domains upfront (always extract Domain property if present) - if ($CurrentBlockedDomains -is [System.Collections.IEnumerable] -and $CurrentBlockedDomains -isnot [string]) { - $blockedDomainsArray = @($CurrentBlockedDomains) - if ($blockedDomainsArray.Count -gt 0) { - $firstElement = $blockedDomainsArray[0] - $hasDomainProperty = ($null -ne $firstElement.Domain) -or (Get-Member -InputObject $firstElement -Name 'Domain' -MemberType Properties, NoteProperty) - - if ($hasDomainProperty) { - $CurrentBlockedDomains = @($blockedDomainsArray | ForEach-Object { $_.Domain }) | Sort-Object - Write-Information "Current BlockedDomains (extracted): $($CurrentBlockedDomains -join ', ')" - } else { - $CurrentBlockedDomains = @($blockedDomainsArray) | Sort-Object - Write-Information "Current BlockedDomains (plain strings): $($CurrentBlockedDomains -join ', ')" - } - } else { - $CurrentBlockedDomains = @() - } - } else { - $CurrentBlockedDomains = @() + # Allow-all-known = no explicit allow-list present (ConfigAPI returns {} for allow-all). + $IsCurrentAllowAllKnownDomains = ($CurrentAllowedDomains.Count -eq 0) + $CurrentBlockedDomains = @() + if ($CurrentState.BlockedDomains) { + $CurrentBlockedDomains = @($CurrentState.BlockedDomains | ForEach-Object { if ($_ -is [string]) { $_ } elseif ($_.Domain) { $_.Domain } else { "$_" } }) | Sort-Object } + $AllowedDomainsMatches = $false + $BlockedDomainsMatches = $false # Mode-specific validation switch ($DomainControl) { @@ -190,17 +148,13 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { Identity = 'Global' AllowTeamsConsumer = $Settings.AllowTeamsConsumer AllowFederatedUsers = $AllowFederatedUsers - BlockedDomains = $BlockedDomains - } - - if ($AllowedDomainsAsAList -and $AllowedDomainsAsAList.Count -gt 0) { - $cmdParams.AllowedDomainsAsAList = $AllowedDomainsAsAList - } else { - $cmdParams.AllowedDomains = $AllowedDomains + AllowedDomains = $AllowedDomainsPayload + BlockedDomains = @($BlockedDomains) } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTenantFederationConfiguration' -CmdParams $cmdParams + # -NoRead: send bare props exactly like ACMS (no Key envelope) for the federation write + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TenantFederationConfiguration' -Action Set -Parameters $cmdParams -NoRead Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated Federation Configuration Policy' -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -232,7 +186,7 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { # Normalize expected allowed domains for reporting $ExpectedAllowedDomainsForReport = if ($AllowedDomainsAsAList -and $AllowedDomainsAsAList.Count -gt 0) { $AllowedDomainsAsAList - } elseif ($AllowedDomains) { + } elseif ($ExpectedAllowAllKnown) { 'AllowAllKnownDomains' } else { @() diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 index 64f368e07dbca..729d0bb3ff3b1 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 @@ -57,7 +57,7 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMeetingPolicy' -CmdParams @{Identity = 'Global' } | + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Get -Identity 'Global' | Select-Object AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -96,7 +96,7 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsMeetingPolicy' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated Teams Global Policy' -sev Info } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGuestAccess.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGuestAccess.ps1 index 082638fd09fd0..043e4a988a87a 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGuestAccess.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGuestAccess.ps1 @@ -44,7 +44,7 @@ function Invoke-CIPPStandardTeamsGuestAccess { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsClientConfiguration' -CmdParams @{Identity = 'Global' } | + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsClientConfiguration' -Action Get -Identity 'Global' | Select-Object AllowGuestUser } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -66,7 +66,7 @@ function Invoke-CIPPStandardTeamsGuestAccess { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsClientConfiguration' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsClientConfiguration' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated Teams Guest Access settings' -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 index d1ae1626cafb5..2e45f2ca7a5fb 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 @@ -51,7 +51,7 @@ function Invoke-CIPPStandardTeamsMeetingRecordingExpiration { } try { - $CurrentExpirationDays = (New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMeetingPolicy' -CmdParams @{Identity = 'Global' }).NewMeetingRecordingExpirationDays + $CurrentExpirationDays = (New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Get -Identity 'Global').NewMeetingRecordingExpirationDays } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the TeamsMeetingRecordingExpiration state for $Tenant. Error: $ErrorMessage" -Sev Error @@ -70,7 +70,7 @@ function Invoke-CIPPStandardTeamsMeetingRecordingExpiration { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsMeetingPolicy' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully updated Teams Meeting Recording Expiration Policy to $ExpirationDays days." -sev Info } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingVerification.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingVerification.ps1 index e13384d13c8db..b4848b022703b 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingVerification.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingVerification.ps1 @@ -45,7 +45,7 @@ function Invoke-CIPPStandardTeamsMeetingVerification { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMeetingPolicy' -CmdParams @{Identity = 'Global' } | + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Get -Identity 'Global' | Select-Object CaptchaVerificationForMeetingJoin } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -66,7 +66,7 @@ function Invoke-CIPPStandardTeamsMeetingVerification { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsMeetingPolicy' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated Teams Meeting Verification Policy' -sev Info } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMessagingPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMessagingPolicy.ps1 index 31a6384293c49..94fbf91a0705c 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMessagingPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMessagingPolicy.ps1 @@ -52,7 +52,7 @@ function Invoke-CIPPStandardTeamsMessagingPolicy { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMessagingPolicy' -CmdParams @{Identity = 'Global' } + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMessagingPolicy' -Action Get -Identity 'Global' } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the TeamsMessagingPolicy state for $Tenant. Error: $ErrorMessage" -Sev Error @@ -98,7 +98,7 @@ function Invoke-CIPPStandardTeamsMessagingPolicy { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsMessagingPolicy' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMessagingPolicy' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated global Teams messaging policy' -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message diff --git a/Modules/CIPPTests/Public/Helpers/Test-E8AsrRule.ps1 b/Modules/CIPPTests/Public/Helpers/Test-E8AsrRule.ps1 new file mode 100644 index 0000000000000..da1f1d06ec693 --- /dev/null +++ b/Modules/CIPPTests/Public/Helpers/Test-E8AsrRule.ps1 @@ -0,0 +1,66 @@ +function Test-E8AsrRule { + <# + .SYNOPSIS + Internal helper used by E8 Macro/AppHard tests to verify a single Defender ASR rule child setting is enabled and assigned. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Tenant, + [Parameter(Mandatory)] [string] $TestId, + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string] $RuleSettingId, + [Parameter(Mandatory)] [string] $FriendlyRule, + [string] $Risk = 'High', + [string] $Category, + [string] $UserImpact = 'Medium', + [string] $ImplementationEffort = 'Medium' + ) + + try { + $ConfigPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + if (-not $ConfigPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No Intune Configuration Policies cached for this tenant.' -Risk $Risk -Name $Name -UserImpact $UserImpact -ImplementationEffort $ImplementationEffort -Category $Category + return + } + + $AsrPolicies = $ConfigPolicies | Where-Object { + $_.platforms -like '*windows10*' -and + $_.technologies -like '*mdm*' -and + ($_.settings.settingInstance.settingDefinitionId -contains 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules') + } + + if (-not $AsrPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No Defender Attack Surface Reduction policy is configured for Windows 10/11.' -Risk $Risk -Name $Name -UserImpact $UserImpact -ImplementationEffort $ImplementationEffort -Category $Category + return + } + + $Matching = foreach ($P in $AsrPolicies) { + $children = $P.settings.settingInstance.groupSettingCollectionValue.children + $found = $children | Where-Object { $_.settingDefinitionId -eq $RuleSettingId } + $value = $found.choiceSettingValue.value + if ($value -like '*_block' -or $value -like '*_warn') { + [pscustomobject]@{ Policy = $P; Value = $value } + } + } + + if (-not $Matching) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "No ASR policy enables ``$FriendlyRule`` (in Block or Warn mode)." -Risk $Risk -Name $Name -UserImpact $UserImpact -ImplementationEffort $ImplementationEffort -Category $Category + return + } + + $Assigned = $Matching | Where-Object { $_.Policy.assignments -and $_.Policy.assignments.Count -gt 0 } + + if ($Assigned) { + $Status = 'Passed' + $Result = "ASR rule ``$FriendlyRule`` is enabled and assigned in $($Assigned.Count) policy/policies." + } else { + $Status = 'Failed' + $Result = "ASR rule ``$FriendlyRule`` is configured but not assigned to any group/device." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk $Risk -Name $Name -UserImpact $UserImpact -ImplementationEffort $ImplementationEffort -Category $Category + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk $Risk -Name $Name -UserImpact $UserImpact -ImplementationEffort $ImplementationEffort -Category $Category + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 index 9c69b5aea1c4e..9b0eaedac7c9e 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 @@ -6,7 +6,7 @@ function Invoke-CippTestCIS_1_1_4 { param($Tenant) try { - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' @@ -18,9 +18,10 @@ function Invoke-CippTestCIS_1_1_4 { $PrivilegedRoleIds = [System.Collections.Generic.HashSet[string]]::new() $PrivilegedUserIds = [System.Collections.Generic.HashSet[string]]::new() - foreach ($Role in @($Roles.Where({ $_.isPrivileged -eq $true }))) { - if ($Role.id) { - [void]$PrivilegedRoleIds.Add([string]$Role.id) + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { + [void]$PrivilegedRoleIds.Add($RoleTemplateId) } foreach ($Member in @($Role.members)) { diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 index 3cd5c1eca8f6b..6778b8ceeeaca 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 @@ -7,14 +7,15 @@ function Invoke-CippTestCIS_5_2_2_1 { try { $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles if (-not $CA -or -not $Roles) { Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ConditionalAccessPolicies or Roles) not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'MFA is enabled for all users in administrative roles' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' return } - $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new([string[]]$Roles.Where({ $_.isPrivileged -eq $true }).id) + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Roles | ForEach-Object { if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } })) $Matching = $CA.Where({ $_.state -eq 'enabled' -and diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 index c30b77edc38a0..a8170cb48189d 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 @@ -7,14 +7,15 @@ function Invoke-CippTestCIS_5_2_2_4 { try { $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles if (-not $CA -or -not $Roles) { Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ConditionalAccessPolicies or Roles) not found.' -Risk 'Medium' -Name 'Sign-in frequency for administrative users is configured' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Session Management' return } - $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new([string[]]$Roles.Where({ $_.isPrivileged -eq $true }).id) + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Roles | ForEach-Object { if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } })) $Matching = $CA.Where({ $_.state -eq 'enabled' -and diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 index bfc12f0bceede..8ef0c74e6d816 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 @@ -7,7 +7,7 @@ function Invoke-CippTestCIS_5_2_2_5 { try { $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles $Strengths = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationStrengths' if (-not $CA -or -not $Roles) { @@ -15,7 +15,8 @@ function Invoke-CippTestCIS_5_2_2_5 { return } - $PrivRoleIds = ($Roles | Where-Object { $_.isPrivileged -eq $true }).id + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $PrivRoleIds = @($Roles | ForEach-Object { if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } }) $PhishResistantId = '00000000-0000-0000-0000-000000000004' # Built-in 'Phishing-resistant MFA' strength $Matching = $CA | Where-Object { diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.ps1 index dc196f8c0935b..87702dbc75df5 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.ps1 @@ -19,7 +19,7 @@ function Invoke-CippTestCIS_8_2_1 { $PolicyDisabled = $E.EnableFederationAccess -eq $false $TenantDisabled = $F.AllowFederatedUsers -eq $false - $TenantAllowList = $F.AllowedDomains -and ($F.AllowedDomains.AllowedDomain -or ($F.AllowedDomains -is [array] -and $F.AllowedDomains.Count -gt 0)) + $TenantAllowList = $F.AllowedDomains -and ($F.AllowedDomains.AllowList -or $F.AllowedDomains.AllowedDomain -or ($F.AllowedDomains -is [array] -and $F.AllowedDomains.Count -gt 0)) if ($PolicyDisabled -or $TenantDisabled -or $TenantAllowList) { $Status = 'Passed' diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.md new file mode 100644 index 0000000000000..504d1286c14b6 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.md @@ -0,0 +1,13 @@ +Application control (allowlisting) is the most effective single mitigation against malware. Implement WDAC, Smart App Control, or AppLocker on all Windows endpoints. + +**Remediation Action** + +1. Intune > Endpoint security > Account protection / Attack surface reduction > **App and browser control** or **Microsoft Defender Application Control (WDAC)**. +2. Deploy a base policy in audit mode, then move to enforced mode. +3. Assign to all Windows devices. + +**Links** +- [WDAC overview](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/windows-defender-application-control/) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.ps1 new file mode 100644 index 0000000000000..1b650e5e478d0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.ps1 @@ -0,0 +1,41 @@ +function Invoke-CippTestE8_AppCtrl_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Application Control, ML1) - Application control is implemented on workstations + #> + param($Tenant) + + $TestId = 'E8_AppCtrl_01' + $Name = 'Application control (WDAC / Smart App Control / AppLocker) is configured for Windows endpoints' + + try { + $ConfigPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + + if (-not $ConfigPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No Intune Configuration Policies cached for this tenant.' -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Application Control' + return + } + + $AppControlPolicies = $ConfigPolicies | Where-Object { + $ids = $_.settings.settingInstance.settingDefinitionId + ($ids -match 'applicationcontrol') -or ($ids -match 'smartappcontrol') -or ($ids -match 'applocker') + } + $Assigned = $AppControlPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 } + + if ($Assigned) { + $Status = 'Passed' + $Result = "$($Assigned.Count) application-control policy/policies (WDAC/Smart App Control/AppLocker) are configured and assigned." + } elseif ($AppControlPolicies) { + $Status = 'Failed' + $Result = "$($AppControlPolicies.Count) application-control policy/policies exist but none are assigned." + } else { + $Status = 'Failed' + $Result = 'No WDAC, Smart App Control, or AppLocker configuration policy is deployed via Intune.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Application Control' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Application Control' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.md new file mode 100644 index 0000000000000..e85bbf1e8c602 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.md @@ -0,0 +1,12 @@ +ISM-0843 — application control covers more than `.exe`. Scripts (PS1/JS/VBS), DLLs, MSIs, HTAs, drivers, and control panel applets must all be subject to allowlisting. + +**Remediation Action** + +1. Author / extend WDAC policy XML to set `Enabled:Audit Mode` off for the additional file rule levels (DLL, Script, MSI, etc.). +2. Deploy via Intune > Endpoint security > Application Control for Business policy XML. + +**Links** +- [WDAC policy file rules](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/windows-defender-application-control/design/select-types-of-rules-to-create) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.ps1 new file mode 100644 index 0000000000000..ecc0ac4cb9a2d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_AppCtrl_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Application Control, ML2) - App control allowlist covers all executable types (ISM-0843) + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppCtrl_02' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm WDAC/AppLocker rules cover executables, software libraries (DLLs/OCX), scripts (PS1, JS, VBS), installers (MSI/MSIX), compiled HTML, HTA, control panel applets, and drivers. The full rule contents are stored as XML inside Intune profiles which are not easily summarised; review the deployed policy in Intune.' -Risk 'High' -Name 'Application control covers all executable types (ISM-0843)' -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML2 - Application Control' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.md new file mode 100644 index 0000000000000..34246723caa07 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.md @@ -0,0 +1,12 @@ +Microsoft maintains a Recommended Block Rules list that bans known LOLBin abuse (e.g. `bginfo`, `cdb`, `csi`, `dnx`, `mshta` minus exceptions). Merge this list into your WDAC policy. + +**Remediation Action** + +1. Download the latest WDAC recommended block rules XML from Microsoft. +2. Merge with your base policy and re-deploy via Intune. + +**Links** +- [Microsoft recommended block rules](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/windows-defender-application-control/design/applications-that-can-bypass-wdac) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.ps1 new file mode 100644 index 0000000000000..e9f531eeb1f3d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_AppCtrl_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Application Control, ML2) - Microsoft recommended block list is implemented + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppCtrl_03' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm Microsoft''s **recommended block rules** (LOLBins such as bash, bginfo, cdb, msbuild, powershell_ise.exe, etc.) are deployed via WDAC. The block list is published as XML at `https://aka.ms/wdac-block-rules` and is delivered through an Intune WDAC policy XML file.' -Risk 'High' -Name 'Microsoft recommended WDAC block rules are deployed' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Application Control' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.md new file mode 100644 index 0000000000000..5a06847acb07e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.md @@ -0,0 +1,12 @@ +Microsoft's vulnerable driver blocklist prevents kernel-level BYOVD attacks. On Windows 11 22H2+ it is auto-enabled with Memory Integrity; older builds require a WDAC driver policy. + +**Remediation Action** + +1. Intune > Settings catalog > **Memory Integrity / HVCI** = Enabled. +2. Confirm `Enable Microsoft Vulnerable Driver Blocklist` is on. + +**Links** +- [Microsoft recommended driver block rules](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/microsoft-recommended-driver-block-rules) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.ps1 new file mode 100644 index 0000000000000..939f67181246d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_AppCtrl_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Application Control, ML2) - Microsoft recommended driver block list is implemented + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppCtrl_04' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm the Microsoft Vulnerable Driver Blocklist is enabled via *Memory Integrity / Core Isolation*, or via WDAC driver block XML. From Windows 11 22H2 the blocklist is on by default when Memory Integrity is enabled; verify in Settings catalog under *Defender > Allow Memory Integrity*.' -Risk 'High' -Name 'Microsoft vulnerable driver blocklist is deployed' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Application Control' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.md new file mode 100644 index 0000000000000..6e21e03d58393 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.md @@ -0,0 +1,12 @@ +App-control logs (CodeIntegrity event log, AppLocker event logs) must be centrally collected so blocked-execution events become detection signals. + +**Remediation Action** + +1. Sentinel > Data connectors > Windows Security Events via AMA — include `Microsoft-Windows-CodeIntegrity/Operational` and `Microsoft-Windows-AppLocker/EXE and DLL`. +2. Or: ingest via Defender for Endpoint Advanced Hunting (`DeviceEvents | where ActionType startswith "AppControlCodeIntegrity"`). + +**Links** +- [WDAC event logs](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/windows-defender-application-control/operations/event-id-explanations) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.ps1 new file mode 100644 index 0000000000000..489faed92a296 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_AppCtrl_05 { + <# + .SYNOPSIS + ACSC Essential Eight (Application Control, ML3) - Application control event logs are centrally collected + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppCtrl_05' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm WDAC / AppLocker event logs (Microsoft-Windows-CodeIntegrity, Microsoft-Windows-AppLocker) are forwarded to a SIEM (Sentinel via the Windows Security Events connector or Defender for Endpoint AdvancedHunting).' -Risk 'Medium' -Name 'Application control event logs are centrally collected' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Application Control' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.md new file mode 100644 index 0000000000000..b31776277713e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.md @@ -0,0 +1,13 @@ +Web browsers are the most-attacked client application in the enterprise. Disable Flash (now removed), Java applets, and reduce drive-by exposure with an enterprise ad-blocker. + +**Remediation Action** + +1. Intune > Configuration profiles > Settings catalog > Microsoft Edge. +2. Disable plug-ins (`PluginsBlockedForUrls = *`) and Java; deploy an enterprise ad-blocker (uBlock Origin / NoScript / Edge tracking prevention strict). +3. Repeat for Chrome / Firefox where deployed. + +**Links** +- [ACSC Essential Eight - User Application Hardening](https://learn.microsoft.com/en-us/compliance/anz/e8-uah) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.ps1 new file mode 100644 index 0000000000000..16a0a60b1529b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_AppHard_01 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML1) - Web browsers block Flash, web ads and Java content + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppHard_01' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm Edge / Chrome / Firefox managed policies disable Flash and Java plugins, and that an enterprise ad-blocking solution is in place. Browser policies (e.g. Edge ADMX *PluginsBlockedForUrls*, *DefaultPluginsSetting*) live in the Settings Catalog; confirming end-to-end enforcement requires inspection beyond what is cached.' -Risk 'High' -Name 'Web browsers block Flash, web ads, and Java content (ISM-1486)' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.md new file mode 100644 index 0000000000000..3d9869a1fb8fc --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.md @@ -0,0 +1,12 @@ +Internet Explorer 11 is retired and unsupported. Even though Edge can render legacy sites in IE Mode, the standalone IE11 desktop must be disabled to prevent its insecure scripting engines being exposed. + +**Remediation Action** + +1. Intune > Settings catalog > Internet Explorer > **Disable Internet Explorer 11 as a standalone browser** = Enabled. +2. Curate the IE Mode site list in Edge Update for any legacy line-of-business apps. + +**Links** +- [Internet Explorer 11 desktop app retirement](https://learn.microsoft.com/en-us/lifecycle/announcements/internet-explorer-11-end-of-support-microsoft-365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.ps1 new file mode 100644 index 0000000000000..1da0a2a831aef --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_AppHard_02 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML1) - Internet Explorer 11 is disabled or removed + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppHard_02' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. IE11 has been retired by Microsoft, but the legacy MSHTML engine and IE mode still exist on Windows. Confirm IE11 desktop is disabled via the *DisableInternetExplorerApp* policy and that any IE-mode site list is curated. CIPP cannot verify per-device installation state of legacy components from Graph.' -Risk 'High' -Name 'Internet Explorer 11 is disabled or removed (ISM-1666)' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.md new file mode 100644 index 0000000000000..78832029e053d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.md @@ -0,0 +1,12 @@ +Malicious PDFs are a long-standing delivery vector. Configure the standard PDF viewer with Protected View on, JavaScript disabled, and external content blocked. + +**Remediation Action** + +1. Intune > Settings catalog > deploy ADMX for the chosen viewer (Adobe Acrobat, Foxit, Edge built-in viewer). +2. Disable JavaScript inside PDFs and enable Protected View / Sandbox. + +**Links** +- [Acrobat enterprise hardening](https://www.adobe.com/devnet-docs/acrobatetk/tools/AdminGuide/index.html) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.ps1 new file mode 100644 index 0000000000000..d52b8cfd98777 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_AppHard_03 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML1) - PDF viewers are configured securely + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppHard_03' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm the standard organisation PDF viewer (Edge, Adobe Acrobat Reader, Foxit, etc.) is configured with Protected View / Sandbox enabled and JavaScript disabled. PDF viewer configuration is application-specific and not exposed via Graph; verify by reviewing the deployed Intune ADMX/Settings Catalog policy.' -Risk 'High' -Name 'PDF viewers are configured securely (Protected View, no JavaScript)' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.md new file mode 100644 index 0000000000000..7ef2b88de046e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.md @@ -0,0 +1,12 @@ +.NET Framework 3.5 (which carries .NET 2.0 and 3.0 runtimes) lacks modern hardening — no AMSI, no per-app strong name verification — and is a frequent ROP gadget source. Remove it from SOEs. + +**Remediation Action** + +1. Intune > Endpoint security > Compliance policies > custom compliance script: fail when `(Get-WindowsOptionalFeature -Online -FeatureName 'NetFx3').State -eq 'Enabled'`. +2. Remediate via PSscript: `Disable-WindowsOptionalFeature -Online -FeatureName NetFx3`. + +**Links** +- [.NET Framework lifecycle](https://learn.microsoft.com/en-us/lifecycle/products/microsoft-net-framework) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.ps1 new file mode 100644 index 0000000000000..991de2ffee7a4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_AppHard_04 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML1) - Legacy .NET Framework 3.5/2.0 is removed or disabled + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppHard_04' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm .NET Framework 3.5 (which includes 2.0 and 3.0) is uninstalled or never installed on standard SOEs. CIPP can list detected applications via the Intune Discovered Apps inventory but the optional Windows feature state is not surfaced; verify with an Intune Compliance Policy or PowerShell script (Get-WindowsOptionalFeature -FeatureName NetFx3).' -Risk 'High' -Name '.NET Framework 3.5/2.0 is removed (ISM-1655)' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.md new file mode 100644 index 0000000000000..94b748e3d3a20 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.md @@ -0,0 +1,12 @@ +Windows PowerShell 2.0 lacks AMSI, ScriptBlockLogging, and the Constrained Language Mode hardening present in 5.1+. Attackers downgrade to v2 to bypass logging. Remove the optional feature. + +**Remediation Action** + +1. Intune > Compliance / Remediation script: `Disable-WindowsOptionalFeature -Online -FeatureName MicrosoftWindowsPowerShellV2,MicrosoftWindowsPowerShellV2Root -NoRestart`. +2. Verify with `Get-WindowsOptionalFeature -Online -FeatureName MicrosoftWindowsPowerShellV2*`. + +**Links** +- [PowerShell v2 deprecation](https://learn.microsoft.com/en-us/powershell/scripting/whats-new/what-s-new-in-powershell-50) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.ps1 new file mode 100644 index 0000000000000..a79786597fc3e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_AppHard_05 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML1) - Windows PowerShell 2.0 is removed or disabled + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppHard_05' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm the Windows optional feature *MicrosoftWindowsPowerShellV2* is removed from all Windows endpoints. PowerShell 2.0 lacks AMSI and ScriptBlockLogging. Verify with an Intune compliance script (Get-WindowsOptionalFeature -FeatureName MicrosoftWindowsPowerShellV2*).' -Risk 'High' -Name 'Windows PowerShell 2.0 is removed (ISM-1622)' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.md new file mode 100644 index 0000000000000..e867d1a70201e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.md @@ -0,0 +1,13 @@ +Credential theft from `lsass.exe` (e.g. Mimikatz) is the foundation of most lateral movement attacks. The ASR rule blocks reads against LSASS memory by untrusted processes. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block credential stealing from the Windows local security authority subsystem* to **Block**. +3. Assign to all Windows endpoints; pair with Credential Guard. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.ps1 new file mode 100644 index 0000000000000..26078a0bf9864 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_06 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML2) - ASR rule "Block credential stealing from LSASS" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_06' ` + -Name 'ASR rule "Block credential stealing from the Windows local security authority subsystem (lsass.exe)"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockcredentialstealingfromwindowslocalsecurityauthoritysubsystem' ` + -FriendlyRule 'Block credential stealing from the Windows local security authority subsystem' ` + -Risk 'High' -Category 'E8 ML2 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.md new file mode 100644 index 0000000000000..cc2f7ea7a3942 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.md @@ -0,0 +1,13 @@ +Email is the #1 phishing vector. The ASR rule **Block executable content from email client and webmail** prevents Outlook (or web browsers viewing webmail) from saving and launching `.exe`/`.scr`/`.js` attachments. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block executable content from email client and webmail* to **Block**. +3. Assign to all Windows endpoints. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.ps1 new file mode 100644 index 0000000000000..932ef79741997 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_07 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML2) - ASR rule "Block executable content from email and webmail" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_07' ` + -Name 'ASR rule "Block executable content from email client and webmail"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockexecutablecontentfromemailclientandwebmail' ` + -FriendlyRule 'Block executable content from email client and webmail' ` + -Risk 'High' -Category 'E8 ML2 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.md new file mode 100644 index 0000000000000..ecf5bbac7c7d7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.md @@ -0,0 +1,12 @@ +Obfuscated PowerShell, JavaScript, and VBScript are hallmarks of malware delivery. The ASR rule blocks scripts that exhibit obfuscation patterns from running. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block execution of potentially obfuscated scripts* to **Block** (start with *Warn* if you suspect false positives). + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.ps1 new file mode 100644 index 0000000000000..3f5a18f499c81 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_08 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML2) - ASR rule "Block execution of potentially obfuscated scripts" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_08' ` + -Name 'ASR rule "Block execution of potentially obfuscated scripts"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockexecutionofpotentiallyobfuscatedscripts' ` + -FriendlyRule 'Block execution of potentially obfuscated scripts' ` + -Risk 'High' -Category 'E8 ML2 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.md new file mode 100644 index 0000000000000..e77c618ad94f4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.md @@ -0,0 +1,12 @@ +Many drive-by downloads end with a JavaScript or VBScript stub that pulls down and runs a binary. This ASR rule terminates that hand-off. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block JavaScript or VBScript from launching downloaded executable content* to **Block**. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.ps1 new file mode 100644 index 0000000000000..20c1cf782dcda --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_09 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML2) - ASR rule "Block JS/VBS launching downloaded executable content" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_09' ` + -Name 'ASR rule "Block JavaScript or VBScript from launching downloaded executable content"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockjavascriptorvbscriptfromlaunchingdownloadedexecutablecontent' ` + -FriendlyRule 'Block JavaScript or VBScript from launching downloaded executable content' ` + -Risk 'High' -Category 'E8 ML2 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.md new file mode 100644 index 0000000000000..ef8f7ddfe8434 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.md @@ -0,0 +1,13 @@ +Removable media is a common malware vector. This ASR rule blocks unsigned/untrusted executables from launching when run from USB drives. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block untrusted and unsigned processes that run from USB* to **Block**. +3. Pair with a removable storage access policy if USB is not required. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.ps1 new file mode 100644 index 0000000000000..6c9cd88b38c95 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_10 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML3) - ASR rule "Block untrusted/unsigned processes from USB" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_10' ` + -Name 'ASR rule "Block untrusted and unsigned processes that run from USB"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockuntrustedunsignedprocessesthatrunfromusb' ` + -FriendlyRule 'Block untrusted and unsigned processes that run from USB' ` + -Risk 'Medium' -Category 'E8 ML3 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.md new file mode 100644 index 0000000000000..4aaa8a80dbcc3 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.md @@ -0,0 +1,13 @@ +PsExec and WMI process creation are heavily abused for lateral movement. This ASR rule blocks processes spawned through these mechanisms unless explicitly allowed. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block process creations originating from PsExec and WMI commands* to **Block**. +3. Note: this may impact some legitimate management tooling — pilot first. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.ps1 new file mode 100644 index 0000000000000..20bcc8b1022ac --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_11 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML3) - ASR rule "Block process creations from PsExec and WMI commands" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_11' ` + -Name 'ASR rule "Block process creations originating from PsExec and WMI commands"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockprocesscreationsfrompsexecandwmicommands' ` + -FriendlyRule 'Block process creations originating from PsExec and WMI commands' ` + -Risk 'High' -Category 'E8 ML3 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.md new file mode 100644 index 0000000000000..8d7cd18f39d19 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.md @@ -0,0 +1,13 @@ +Bring-Your-Own-Vulnerable-Driver (BYOVD) is a common kernel-privilege escalation technique. This ASR rule blocks Microsoft's curated list of known-bad signed drivers from loading. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block abuse of exploited vulnerable signed drivers* to **Block**. +3. Pair with the Microsoft vulnerable driver blocklist (Smart App Control / WDAC). + +**Links** +- [Microsoft recommended driver block rules](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/microsoft-recommended-driver-block-rules) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.ps1 new file mode 100644 index 0000000000000..7f8efedb98f31 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_12 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML3) - ASR rule "Block abuse of exploited vulnerable signed drivers" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_12' ` + -Name 'ASR rule "Block abuse of exploited vulnerable signed drivers"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockabuseofexploitedvulnerablesigneddrivers' ` + -FriendlyRule 'Block abuse of exploited vulnerable signed drivers' ` + -Risk 'High' -Category 'E8 ML3 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.md new file mode 100644 index 0000000000000..e07093adf7364 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.md @@ -0,0 +1,12 @@ +WMI event subscriptions are a stealthy persistence mechanism (ATT&CK T1546.003). This ASR rule blocks creation of new WMI event consumers/filters/bindings. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block persistence through WMI event subscription* to **Block**. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.ps1 new file mode 100644 index 0000000000000..5a88b0185e7c2 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_13 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML3) - ASR rule "Block persistence through WMI event subscription" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_13' ` + -Name 'ASR rule "Block persistence through WMI event subscription"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockpersistencethroughwmieventsubscription' ` + -FriendlyRule 'Block persistence through WMI event subscription' ` + -Risk 'Medium' -Category 'E8 ML3 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.md new file mode 100644 index 0000000000000..1441fa643b5b4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.md @@ -0,0 +1,13 @@ +Defender's advanced ransomware protection rule uses cloud-delivered ML to detect and block ransomware behaviour patterns even when the binary itself is unknown. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Use advanced protection against ransomware* to **Block**. +3. Pair with Controlled Folder Access for additional anti-ransomware defence. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.ps1 new file mode 100644 index 0000000000000..25bf3b6c77be4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_14 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML3) - ASR rule "Use advanced protection against ransomware" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_14' ` + -Name 'ASR rule "Use advanced protection against ransomware"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_useadvancedprotectionagainstransomware' ` + -FriendlyRule 'Use advanced protection against ransomware' ` + -Risk 'High' -Category 'E8 ML3 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.md new file mode 100644 index 0000000000000..bda0c651ef616 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.md @@ -0,0 +1,14 @@ +Office macros are a major delivery vector for malware. The Defender Attack Surface Reduction rule **Block Win32 API calls from Office macros** stops a macro from invoking the Windows API directly, which is how most macro-based loaders detonate. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction > Create policy (Windows 10+, Endpoint detection and response/Attack Surface Reduction Rules). +2. Set *Block Win32 API calls from Office macros* to **Block** (or *Warn*). +3. Assign to all Windows devices. + +**Links** +- [ACSC Essential Eight - Configure Microsoft Office macros](https://learn.microsoft.com/en-us/compliance/anz/e8-macro) +- [Defender ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.ps1 new file mode 100644 index 0000000000000..8403fac123f86 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.ps1 @@ -0,0 +1,14 @@ +function Invoke-CippTestE8_Macro_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML1) - Win32 API calls from Office macros are blocked via ASR + #> + param($Tenant) + + $TestId = 'E8_Macro_01' + $Name = 'ASR rule "Block Win32 API calls from Office macros" is enabled and assigned' + $RuleId = 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockwin32apicallsfromofficemacros' + $Friendly = 'Block Win32 API calls from Office macros' + + Test-E8AsrRule -Tenant $Tenant -TestId $TestId -Name $Name -RuleSettingId $RuleId -FriendlyRule $Friendly -Risk 'High' -Category 'E8 ML1 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.md new file mode 100644 index 0000000000000..3f90f556da36d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.md @@ -0,0 +1,13 @@ +Office macros frequently drop and execute payloads. The ASR rule **Block Office applications from creating executable content** prevents Word/Excel/PowerPoint from writing `.exe`/`.dll`/`.scr`/macros that drop binaries to disk. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block Office applications from creating executable content* to **Block** (or *Warn*). +3. Assign to all Windows devices. + +**Links** +- [Defender ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.ps1 new file mode 100644 index 0000000000000..0f7d019aeaa2e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_Macro_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML1) - Office apps blocked from creating executable content via ASR + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_Macro_02' ` + -Name 'ASR rule "Block Office applications from creating executable content" is enabled and assigned' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockofficeapplicationsfromcreatingexecutablecontent' ` + -FriendlyRule 'Block Office applications from creating executable content' ` + -Risk 'High' -Category 'E8 ML1 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.md new file mode 100644 index 0000000000000..70785a432a746 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.md @@ -0,0 +1,13 @@ +Macro-based attacks routinely launch PowerShell, cmd, or wscript as a child of Word/Excel. The ASR rule **Block all Office applications from creating child processes** blocks this entire technique class. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block all Office applications from creating child processes* to **Block**. +3. Assign to all Windows devices. + +**Links** +- [Defender ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.ps1 new file mode 100644 index 0000000000000..8089fc95e1c9d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_Macro_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML1) - Office apps blocked from creating child processes via ASR + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_Macro_03' ` + -Name 'ASR rule "Block all Office applications from creating child processes" is enabled and assigned' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockallofficeapplicationsfromcreatingchildprocesses' ` + -FriendlyRule 'Block all Office applications from creating child processes' ` + -Risk 'High' -Category 'E8 ML1 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.md new file mode 100644 index 0000000000000..eabf6b3df9abb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.md @@ -0,0 +1,13 @@ +Code injection is a common technique used by malicious macros to evade detection by piggy-backing on a legitimate process. The ASR rule **Block Office applications from injecting code into other processes** disrupts this. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block Office applications from injecting code into other processes* to **Block**. +3. Assign to all Windows devices. + +**Links** +- [Defender ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.ps1 new file mode 100644 index 0000000000000..f524854516dd0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_Macro_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML2) - Office apps blocked from injecting code via ASR + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_Macro_04' ` + -Name 'ASR rule "Block Office applications from injecting code into other processes" is enabled and assigned' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockofficeapplicationsfrominjectingcodeintootherprocesses' ` + -FriendlyRule 'Block Office applications from injecting code into other processes' ` + -Risk 'High' -Category 'E8 ML2 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.md new file mode 100644 index 0000000000000..085a3c6f0fb51 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.md @@ -0,0 +1,13 @@ +Outlook is a frequent first-stage delivery vector. The ASR rule **Block Office communication application from creating child processes** blocks Outlook from spawning PowerShell, cmd, or scripting hosts — a key step in many phishing kill-chains. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block Office communication application from creating child processes* to **Block**. +3. Assign to all Windows devices. + +**Links** +- [Defender ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.ps1 new file mode 100644 index 0000000000000..a128ebe36cbad --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_Macro_05 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML2) - Office communication apps blocked from creating child processes via ASR + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_Macro_05' ` + -Name 'ASR rule "Block Office communication application from creating child processes" is enabled and assigned' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockofficecommunicationappfromcreatingchildprocesses' ` + -FriendlyRule 'Block Office communication application from creating child processes' ` + -Risk 'Medium' -Category 'E8 ML2 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.md new file mode 100644 index 0000000000000..1584b4db20158 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.md @@ -0,0 +1,13 @@ +Microsoft now blocks VBA macros from the internet by default in supported Office versions, but the policy must be enforced via Office Cloud Policy or Intune ADMX templates to be guaranteed in-tenant. Verify the *Block macros from running in Office files from the Internet* policy is on for Word, Excel, PowerPoint, Visio, and Outlook. + +**Remediation Action** + +1. Office Cloud Policy Service ([config.office.com](https://config.office.com)) > Customization > Policy configurations. +2. Search **Block macros from running in Office files from the Internet**. +3. Enable for each Office app and assign to all users. + +**Links** +- [Macros from the internet are blocked by default in Office](https://learn.microsoft.com/en-us/deployoffice/security/internet-macros-blocked) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.ps1 new file mode 100644 index 0000000000000..314dbba5b1b25 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Macro_06 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML2) - Macros from the internet are blocked + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Macro_06' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm the *Block macros from running in Office files from the Internet* policy is set in the Office Cloud Policy Service (or the corresponding Microsoft Endpoint Manager Settings Catalog ADMX values for Word/Excel/PowerPoint/Visio/Outlook). The setting lives under each application''s Trust Center.' -Risk 'High' -Name 'Macros from the internet are blocked' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML2 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.md new file mode 100644 index 0000000000000..05b5c8a89743a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.md @@ -0,0 +1,12 @@ +Office VBA integrates with the Antimalware Scan Interface (AMSI) so Defender can scan macro contents at runtime. Confirm AMSI/Defender is enabled and that the *Macro Runtime Scan Scope* policy is set to *Enable for all documents*. + +**Remediation Action** + +1. Office Cloud Policy / Intune ADMX > **Macro Runtime Scan Scope** = *Enable for all documents*. +2. Confirm Defender Antivirus is the active AV (or third-party with macro AMSI integration). + +**Links** +- [Office VBA + AMSI](https://learn.microsoft.com/en-us/microsoft-365-apps/security/integration-with-amsi) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.ps1 new file mode 100644 index 0000000000000..9eaf219728b40 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Macro_07 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML3) - Macros are scanned by anti-virus software + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Macro_07' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm the Office *Macro Runtime Scan Scope* policy is set to *Enable for all documents* and that Microsoft Defender Antivirus AMSI is enabled on all Windows endpoints. AMSI integration with Office macros is on by default on supported builds; this control is verified by inspecting Defender + Office configuration which is not exposed via Graph in a deterministic way.' -Risk 'High' -Name 'Macros are scanned by anti-virus software' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.md new file mode 100644 index 0000000000000..ac259b5b35e68 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.md @@ -0,0 +1,13 @@ +Office and other internet-facing apps must auto-update so vulnerabilities are remediated within the E8 windows (2 weeks ML1, 48 hours ML2, fully supported only ML3). + +**Remediation Action** + +1. Office Cloud Policy > **Update Channel** = *Current Channel* or *Monthly Enterprise*; **Enable Automatic Updates** = On. +2. Edge update policies (`UpdateDefault` = 1). +3. Where third-party browsers / PDF viewers are deployed, configure their auto-update. + +**Links** +- [Choose Microsoft 365 Apps update channel](https://learn.microsoft.com/en-us/deployoffice/updates/overview-update-channels) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.ps1 new file mode 100644 index 0000000000000..4c89da953fcc8 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_PatchApp_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Applications, ML1) - Office and supported applications use automatic updates + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_PatchApp_01' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm Microsoft 365 Apps is set to a current channel (Current/Monthly Enterprise) with automatic updates, and that browsers (Edge, Chrome, Firefox) and PDF viewers self-update. Office update channel can be enforced via Office Cloud Policy *UpdateChannel*; Edge auto-update via *UpdateDefault*.' -Risk 'High' -Name 'Office and supported applications use automatic updates' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Applications' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.md new file mode 100644 index 0000000000000..4271b8f6435d1 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.md @@ -0,0 +1,12 @@ +A device that has not synced with Intune for two weeks is invisible to MDM-based patching and detection. Patch state cannot be enforced or measured for these endpoints. + +**Remediation Action** + +1. Contact users of the listed devices and ensure they bring them online and sign in. +2. If a device has been offline >30 days, retire/wipe it from Intune and re-enrol when returned. + +**Links** +- [Intune device sync troubleshooting](https://learn.microsoft.com/en-us/mem/intune/remote-actions/device-sync) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.ps1 new file mode 100644 index 0000000000000..7f810f28621fe --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.ps1 @@ -0,0 +1,41 @@ +function Invoke-CippTestE8_PatchApp_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Applications, ML1) - Managed devices have synced with Intune within the last 14 days + #> + param($Tenant) + + $TestId = 'E8_PatchApp_02' + $Name = 'Managed devices have synced with Intune within the last 14 days' + + try { + $Devices = Get-CIPPTestData -TenantFilter $Tenant -Type 'ManagedDevices' + if (-not $Devices) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No ManagedDevices cached for this tenant.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Applications' + return + } + + $Threshold = (Get-Date).AddDays(-14) + $Stale = foreach ($D in $Devices) { + $LastSync = $D.lastSyncDateTime + if (-not $LastSync) { [pscustomobject]@{ Device = $D.deviceName; LastSync = 'never' }; continue } + $LastSyncDt = [datetime]::Parse($LastSync) + if ($LastSyncDt -lt $Threshold) { [pscustomobject]@{ Device = $D.deviceName; LastSync = $LastSyncDt.ToString('yyyy-MM-dd') } } + } + + if (-not $Stale) { + $Status = 'Passed' + $Result = "All $($Devices.Count) managed device(s) have synced with Intune within the last 14 days." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Stale.Count) of $($Devices.Count) managed device(s) have not synced for >14 days; their patch state is unknown:`n`n| Device | Last sync |`n| :----- | :-------- |`n") + foreach ($S in ($Stale | Select-Object -First 50)) { $null = $Sb.Append("| $($S.Device) | $($S.LastSync) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Applications' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Applications' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.md new file mode 100644 index 0000000000000..84d1c86b6c3da --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.md @@ -0,0 +1,12 @@ +E8 ML2 requires patching of applications with critical vulnerabilities within 48 hours of an exploit becoming public. Use Microsoft Defender Vulnerability Management to identify exposed apps and prioritise. + +**Remediation Action** + +1. Defender > Vulnerability management > Weaknesses; sort by Exposed devices and Public exploit available. +2. Patch listed apps via Intune Win32 / MSIX or vendor auto-update. + +**Links** +- [Microsoft Defender Vulnerability Management](https://learn.microsoft.com/en-us/defender-vulnerability-management/) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.ps1 new file mode 100644 index 0000000000000..b0c0bd90748d7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_PatchApp_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Applications, ML2) - Vulnerable applications detected on managed devices are reviewed + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_PatchApp_03' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Use Microsoft Defender Vulnerability Management (or the Intune Discovered Apps inventory) to triage applications with known CVEs. Patch internet-facing apps within 48 hours of an exploit being known and within 2 weeks otherwise. Determining "critical" CVE status programmatically requires Defender Vulnerability Management licensing and is not surfaced in the local cache.' -Risk 'High' -Name 'Vulnerable applications are patched within 48 hours of an exploit becoming public' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Patch Applications' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.md new file mode 100644 index 0000000000000..483fef3401021 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.md @@ -0,0 +1,12 @@ +ISM-1467 — applications that are no longer supported by the vendor must be removed because they will not receive security fixes. Examples: Office 2016 (out of support October 2025), Java 8 unsupported builds, Adobe Reader 11. + +**Remediation Action** + +1. Inventory installed apps via Intune > Apps > Discovered apps or Defender Vulnerability Management software inventory. +2. Schedule replacement / uninstall for unsupported versions. + +**Links** +- [Microsoft product lifecycle](https://learn.microsoft.com/en-us/lifecycle/products/) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.ps1 new file mode 100644 index 0000000000000..7ba4fc795a099 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_PatchApp_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Applications, ML3) - Unsupported applications are removed + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_PatchApp_04' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Identify and remove applications that are no longer supported by the vendor (e.g. Office 2016/2019 past support, Adobe Reader 11, Java 8 unpatched, Flash). Use the Intune Discovered Apps inventory or Defender Vulnerability Management software inventory to enumerate.' -Risk 'High' -Name 'Unsupported applications are removed (ISM-1467)' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Patch Applications' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.md new file mode 100644 index 0000000000000..9fc6820e60f93 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.md @@ -0,0 +1,13 @@ +Windows Update for Business is the cloud-native way to deliver OS quality and feature updates. At least one Update Ring policy must be deployed and assigned for E8 patch-OS controls to be measurable. + +**Remediation Action** + +1. Intune > Devices > Windows > Update rings for Windows 10 and later > Create. +2. Set service channel, deferral periods, and deadlines (see ML2 quality deferral test). +3. Assign to all Windows devices. + +**Links** +- [Update rings in Intune](https://learn.microsoft.com/en-us/mem/intune/protect/windows-10-update-rings) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.ps1 new file mode 100644 index 0000000000000..58b895b81594c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.ps1 @@ -0,0 +1,43 @@ +function Invoke-CippTestE8_PatchOS_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML1) - A Windows Update Ring policy is configured and assigned + #> + param($Tenant) + + $TestId = 'E8_PatchOS_01' + $Name = 'A Windows Update Ring policy is configured and assigned' + + try { + $ConfigPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + $LegacyPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneDeviceConfigurations' + + $UpdateRings = @() + if ($ConfigPolicies) { + $UpdateRings += $ConfigPolicies | Where-Object { + $ids = $_.settings.settingInstance.settingDefinitionId + ($ids -match 'windowsupdate') -or ($ids -match 'update_ring') + } + } + if ($LegacyPolicies) { + $UpdateRings += $LegacyPolicies | Where-Object { + $_.'@odata.type' -eq '#microsoft.graph.windowsUpdateForBusinessConfiguration' + } + } + + if (-not $UpdateRings) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No Windows Update for Business / Update Ring configuration policy is deployed.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + return + } + + $Assigned = $UpdateRings | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 } + if ($Assigned) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Passed' -ResultMarkdown "$($Assigned.Count) Windows Update Ring policy/policies are configured and assigned." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + } else { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "$($UpdateRings.Count) Windows Update Ring policy/policies exist but none are assigned to any group/device." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.md new file mode 100644 index 0000000000000..961afb55c4b12 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.md @@ -0,0 +1,13 @@ +Only Windows 10 22H2 (build 19045) and Windows 11 (22H2 / 23H2 / 24H2 — builds 22621/22631/26100) currently receive monthly security updates. Older builds must be upgraded. + +**Remediation Action** + +1. Use Intune Feature Update profiles to roll devices to a supported feature release. +2. For devices that cannot upgrade, retire and replace. + +**Links** +- [Windows 10 release information](https://learn.microsoft.com/en-us/windows/release-health/release-information) +- [Windows 11 release information](https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.ps1 new file mode 100644 index 0000000000000..e3caead2bd8ef --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.ps1 @@ -0,0 +1,52 @@ +function Invoke-CippTestE8_PatchOS_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML1) - All managed Windows devices run a supported OS build + #> + param($Tenant) + + $TestId = 'E8_PatchOS_02' + $Name = 'All managed Windows devices run a supported Windows build (Win10 22H2 / Win11 22H2+)' + + try { + $Devices = Get-CIPPTestData -TenantFilter $Tenant -Type 'ManagedDevices' + if (-not $Devices) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No ManagedDevices cached for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Patch Operating Systems' + return + } + + $Windows = $Devices | Where-Object { $_.operatingSystem -eq 'Windows' } + if (-not $Windows) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No Windows managed devices found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Patch Operating Systems' + return + } + + # Win10 22H2 = 19045.x ; Win11 22H2 = 22621.x ; Win11 23H2 = 22631.x ; Win11 24H2 = 26100.x + $Unsupported = foreach ($D in $Windows) { + $V = $D.osVersion + if (-not $V) { continue } + $parts = $V.Split('.') + if ($parts.Count -lt 3) { continue } + $build = [int]$parts[2] + $Reason = $null + if ($build -lt 19045) { $Reason = 'Windows 10 build < 22H2 (out of support)' } + elseif ($build -ge 20000 -and $build -lt 22621) { $Reason = 'Windows 11 build < 22H2 (out of support)' } + if ($Reason) { [pscustomobject]@{ Device = $D.deviceName; OSVersion = $V; Reason = $Reason } } + } + + if (-not $Unsupported) { + $Status = 'Passed' + $Result = "All $($Windows.Count) Windows device(s) run a supported build." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Unsupported.Count) of $($Windows.Count) Windows device(s) are on unsupported builds:`n`n| Device | OS version | Reason |`n| :----- | :--------- | :----- |`n") + foreach ($U in ($Unsupported | Select-Object -First 50)) { $null = $Sb.Append("| $($U.Device) | $($U.OSVersion) | $($U.Reason) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Patch Operating Systems' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Patch Operating Systems' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.md new file mode 100644 index 0000000000000..e62d844e3c748 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.md @@ -0,0 +1,13 @@ +A device compliance policy that sets `osMinimumVersion` causes Conditional Access to block sign-ins from devices on out-of-support builds, providing a strong forcing function for OS patching. + +**Remediation Action** + +1. Intune > Devices > Compliance policies > Windows 10 / 11 policy. +2. Set **Minimum OS version** to the latest supported build (e.g. `10.0.19045.0` for Windows 10 22H2). +3. Assign to all Windows devices and pair with a CA policy requiring compliant device. + +**Links** +- [Windows compliance settings](https://learn.microsoft.com/en-us/mem/intune/protect/compliance-policy-create-windows) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.ps1 new file mode 100644 index 0000000000000..790ccf31f1e88 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.ps1 @@ -0,0 +1,38 @@ +function Invoke-CippTestE8_PatchOS_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML1) - A device compliance policy enforces a minimum OS version + #> + param($Tenant) + + $TestId = 'E8_PatchOS_03' + $Name = 'A device compliance policy enforces a minimum Windows OS version' + + try { + $Compliance = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneDeviceCompliancePolicies' + if (-not $Compliance) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No Intune compliance policies cached for this tenant.' -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + return + } + + $Win = $Compliance | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.windows10CompliancePolicy' } + $WithMinVersion = $Win | Where-Object { $_.osMinimumVersion } + $Assigned = $WithMinVersion | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 } + + if ($Assigned) { + $Status = 'Passed' + $Result = "$($Assigned.Count) Windows compliance policy/policies enforce a minimum OS version (e.g. $($Assigned[0].osMinimumVersion))." + } elseif ($WithMinVersion) { + $Status = 'Failed' + $Result = "$($WithMinVersion.Count) Windows compliance policy/policies set a minimum OS version but none are assigned." + } else { + $Status = 'Failed' + $Result = 'No Windows compliance policy enforces a minimum OS version (`osMinimumVersion`).' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.md new file mode 100644 index 0000000000000..5a1c94be5763f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.md @@ -0,0 +1,13 @@ +E8 ML2 requires patches for the operating system within 2 weeks of release (and 48 hours for known exploited vulnerabilities). Update Rings must therefore not defer quality updates beyond 14 days. + +**Remediation Action** + +1. Intune > Devices > Update rings > each ring > Settings. +2. **Quality update deferral period (days)** = `0` for production, up to `7` for pilot. +3. Configure a deadline of 2 days for installation/restart. + +**Links** +- [Update rings in Intune](https://learn.microsoft.com/en-us/mem/intune/protect/windows-10-update-rings) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.ps1 new file mode 100644 index 0000000000000..848d61102f5a1 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.ps1 @@ -0,0 +1,42 @@ +function Invoke-CippTestE8_PatchOS_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML2) - Quality update deferral is 14 days or less + #> + param($Tenant) + + $TestId = 'E8_PatchOS_04' + $Name = 'Windows Update Ring quality update deferral is 14 days or less' + + try { + $Legacy = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneDeviceConfigurations' + $Rings = $Legacy | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.windowsUpdateForBusinessConfiguration' } + + if (-not $Rings) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No `windowsUpdateForBusinessConfiguration` Update Ring policies cached for this tenant; quality deferral cannot be evaluated automatically.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Patch Operating Systems' + return + } + + $Bad = foreach ($R in $Rings) { + $Defer = $R.qualityUpdatesDeferralPeriodInDays + if ($null -ne $Defer -and $Defer -gt 14) { + [pscustomobject]@{ Ring = $R.displayName; Deferral = $Defer } + } + } + + if (-not $Bad) { + $Status = 'Passed' + $Result = "All $($Rings.Count) Update Ring policy/policies defer quality updates by 14 days or less." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Bad.Count) Update Ring policy/policies defer quality updates by more than 14 days:`n`n| Ring | Deferral (days) |`n| :--- | :-------------: |`n") + foreach ($B in $Bad) { $null = $Sb.Append("| $($B.Ring) | $($B.Deferral) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Patch Operating Systems' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Patch Operating Systems' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.md new file mode 100644 index 0000000000000..a39c3d8fdf26a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.md @@ -0,0 +1,13 @@ +Feature Update profiles target a specific feature release (e.g. Windows 11 23H2) so devices stay on a supported branch automatically. + +**Remediation Action** + +1. Intune > Devices > Windows > Feature updates for Windows 10 and later > Create profile. +2. Choose the latest supported feature update version. +3. Assign to all Windows devices. + +**Links** +- [Feature updates in Intune](https://learn.microsoft.com/en-us/mem/intune/protect/windows-10-feature-updates) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.ps1 new file mode 100644 index 0000000000000..60440e405aa86 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_PatchOS_05 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML2) - A Windows Feature Update profile is configured + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_PatchOS_05' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm a Windows Feature Update profile (Intune > Devices > Windows > Feature updates for Windows 10 and later) is deployed targeting the latest supported feature release. Feature update profiles are stored in a Graph endpoint that is not currently part of the cached IntuneConfigurationPolicies/DeviceConfigurations collections.' -Risk 'Medium' -Name 'A Windows Feature Update profile is configured' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML2 - Patch Operating Systems' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.md new file mode 100644 index 0000000000000..030d15321e7a5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.md @@ -0,0 +1,13 @@ +Disk encryption protects data on lost or stolen devices. The compliance policy must require BitLocker (or `storageRequireEncryption`) so devices that aren't encrypted are blocked by Conditional Access. + +**Remediation Action** + +1. Intune > Devices > Compliance policies > Windows policy > **Require BitLocker** = Require. +2. Pair with a *BitLocker* configuration profile (Endpoint security > Disk encryption) to actually enable it. +3. Assign to all Windows devices. + +**Links** +- [BitLocker policy settings](https://learn.microsoft.com/en-us/mem/intune/protect/encrypt-devices) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.ps1 new file mode 100644 index 0000000000000..1681bcd1224d7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.ps1 @@ -0,0 +1,33 @@ +function Invoke-CippTestE8_PatchOS_06 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML3) - BitLocker / disk encryption is required by compliance policy + #> + param($Tenant) + + $TestId = 'E8_PatchOS_06' + $Name = 'Compliance policy requires storage to be encrypted (BitLocker)' + + try { + $Compliance = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneDeviceCompliancePolicies' + if (-not $Compliance) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No Intune compliance policies cached for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Patch Operating Systems' + return + } + + $Win = $Compliance | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.windows10CompliancePolicy' } + $WithEnc = $Win | Where-Object { $_.bitLockerEnabled -eq $true -or $_.storageRequireEncryption -eq $true } + $Assigned = $WithEnc | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 } + + if ($Assigned) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Passed' -ResultMarkdown "$($Assigned.Count) Windows compliance policy/policies require encryption (BitLocker) and are assigned." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Patch Operating Systems' + } elseif ($WithEnc) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Encryption is required by $($WithEnc.Count) compliance policy/policies but none are assigned." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Patch Operating Systems' + } else { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No Windows compliance policy requires storage encryption / BitLocker.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Patch Operating Systems' + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Patch Operating Systems' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.md new file mode 100644 index 0000000000000..7c8e9fb156d0d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.md @@ -0,0 +1,14 @@ +ISM-0445 — privileged users are issued dedicated privileged accounts that are separate from their unprivileged identities. In a Microsoft 365 tenant the strongest implementation is **cloud-only** privileged accounts so the on-premises directory cannot compromise privileged identities. This test fails any privileged role member whose `onPremisesSyncEnabled` is true. + +**Remediation Action** + +1. Create a dedicated cloud-only account on the `.onmicrosoft.com` domain for each administrator. +2. Reassign privileged roles to the cloud-only account. +3. Remove privileged role assignments from synced accounts. + +**Links** +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) +- [ISM-0445](https://www.cyber.gov.au/ism) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.ps1 new file mode 100644 index 0000000000000..43ebe72c74335 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.ps1 @@ -0,0 +1,59 @@ +function Invoke-CippTestE8_Admin_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML1) - Privileged accounts are dedicated cloud-only accounts (ISM-0445) + #> + param($Tenant) + + $TestId = 'E8_Admin_01' + $Name = 'Privileged accounts are dedicated cloud-only accounts (ISM-0445)' + + try { + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Roles -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles or Users) not found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new() + $PrivUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { [void]$PrivRoleIds.Add($RoleTemplateId) } + foreach ($M in @($Role.members)) { + if ($M.id) { [void]$PrivUserIds.Add([string]$M.id) } + } + } + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $PrivRoleIds.Contains([string]$A.roleDefinitionId)) { + [void]$PrivUserIds.Add([string]$A.principalId) + } + } + $PrivUsers = $Users | Where-Object { $PrivUserIds.Contains($_.id) } + + if (-not $PrivUsers) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No privileged users found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $NonCompliant = $PrivUsers | Where-Object { $_.onPremisesSyncEnabled -eq $true } + + if (-not $NonCompliant) { + $Status = 'Passed' + $Result = "All $($PrivUsers.Count) privileged user(s) are cloud-only." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($NonCompliant.Count) of $($PrivUsers.Count) privileged user(s) are synced from on-premises Active Directory:`n`n| UPN |`n| :-- |`n") + foreach ($U in ($NonCompliant | Select-Object -First 50)) { $null = $Sb.Append("| $($U.userPrincipalName) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.md new file mode 100644 index 0000000000000..0964cc5c24c56 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.md @@ -0,0 +1,14 @@ +ISM-1175 — privileged accounts must be prevented from accessing the internet, email and web services. In a Microsoft 365 tenant the cleanest implementation is to leave privileged accounts unlicensed (no Exchange / Teams / SharePoint mailbox or apps) so that the account cannot send or receive mail, browse SharePoint, or run Office. Entra ID P2 is provisioned via group-based licensing if needed for PIM, but no productivity SKU should be attached. + +**Remediation Action** + +1. Identify each privileged user listed in the test results. +2. Remove all `Microsoft 365 / Office 365` and `Business Premium` style licenses. +3. Where an admin needs email, use a separate unprivileged mailbox. + +**Links** +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) +- [ISM-1175](https://www.cyber.gov.au/ism) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.ps1 new file mode 100644 index 0000000000000..4be920fd7608d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.ps1 @@ -0,0 +1,61 @@ +function Invoke-CippTestE8_Admin_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML1) - Privileged accounts have no productivity licenses (ISM-1175) + #> + param($Tenant) + + $TestId = 'E8_Admin_02' + $Name = 'Privileged accounts have no productivity licenses (ISM-1175)' + + try { + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Roles -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles or Users) not found.' -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new() + $PrivUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { [void]$PrivRoleIds.Add($RoleTemplateId) } + foreach ($M in @($Role.members)) { + if ($M.id) { [void]$PrivUserIds.Add([string]$M.id) } + } + } + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $PrivRoleIds.Contains([string]$A.roleDefinitionId)) { + [void]$PrivUserIds.Add([string]$A.principalId) + } + } + $PrivUsers = $Users | Where-Object { $PrivUserIds.Contains($_.id) } + + if (-not $PrivUsers) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No privileged users found.' -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $Licensed = $PrivUsers | Where-Object { $_.assignedLicenses -and $_.assignedLicenses.Count -gt 0 } + + if (-not $Licensed) { + $Status = 'Passed' + $Result = "All $($PrivUsers.Count) privileged user(s) have no licenses assigned." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Licensed.Count) of $($PrivUsers.Count) privileged user(s) have productivity licenses assigned. ACSC ISM-1175 requires admins not to use mail/Teams/internet on the privileged account.`n`n| UPN | License count |`n| :-- | :-----------: |`n") + foreach ($U in ($Licensed | Select-Object -First 50)) { + $null = $Sb.Append("| $($U.userPrincipalName) | $($U.assignedLicenses.Count) |`n") + } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.md new file mode 100644 index 0000000000000..62eee976a6839 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.md @@ -0,0 +1,16 @@ +Privileged sign-ins must come from devices the organisation manages. ISM-1380, 1688 and 1689 require admins to operate from a privileged operating environment; in a cloud-first tenant the equivalent is a Conditional Access policy that requires the device to be Intune-compliant or hybrid Azure AD joined before privileged roles are activated. + +**Remediation Action** + +1. Entra ID > Conditional Access > New policy. +2. Users > Directory roles: include all privileged roles. +3. Cloud apps: All cloud apps. +4. Grant: **Require compliant device** (and/or *Require Hybrid Azure AD joined device*). +5. Enable. + +**Links** +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) +- [Require managed devices in CA](https://learn.microsoft.com/en-us/azure/active-directory/conditional-access/require-managed-devices) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.ps1 new file mode 100644 index 0000000000000..9de335c058c5b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.ps1 @@ -0,0 +1,47 @@ +function Invoke-CippTestE8_Admin_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML1) - Conditional Access requires a compliant device for privileged sign-ins + #> + param($Tenant) + + $TestId = 'E8_Admin_03' + $Name = 'Conditional Access requires a compliant or hybrid-joined device for privileged role sign-ins' + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + + if (-not $CA -or -not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ConditionalAccessPolicies or Roles) not found.' -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $PrivRoleIds = @($Roles | ForEach-Object { if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } }) + + $Match = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.conditions.users.includeRoles -and + (@($_.conditions.users.includeRoles) | Where-Object { $_ -in $PrivRoleIds }).Count -gt 0 -and + ( + ($_.grantControls.builtInControls -contains 'compliantDevice') -or + ($_.grantControls.builtInControls -contains 'domainJoinedDevice') + ) + } + + if ($Match) { + $Status = 'Passed' + $Result = "$($Match.Count) Conditional Access policy/policies require a compliant/domain-joined device for privileged role sign-ins:`n`n" + + (($Match | ForEach-Object { "- $($_.displayName)" }) -join "`n") + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy targets privileged roles with a *Require compliant device* or *Require hybrid Azure AD joined device* grant. Privileged accounts may sign in from unmanaged endpoints.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.md new file mode 100644 index 0000000000000..1808623cdfa8e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.md @@ -0,0 +1,14 @@ +Restricting user consent for OAuth applications stops attackers from luring users (including admins) into authorising malicious third-party apps to read mail or files. The Essential Eight ISM-1883 control limits which online services privileged accounts can authorise. + +**Remediation Action** + +1. Entra ID > Enterprise applications > Consent and permissions > User consent settings. +2. Set to **Allow user consent for apps from verified publishers, for selected permissions** (low-impact) — or **Do not allow user consent**. +3. Entra ID > User settings > **Users can register applications** = No. + +**Links** +- [Configure user consent settings](https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/configure-user-consent) +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.ps1 new file mode 100644 index 0000000000000..492cb3a3da390 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.ps1 @@ -0,0 +1,47 @@ +function Invoke-CippTestE8_Admin_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML1) - Privileged accounts are blocked from authorising risky OAuth grants (ISM-1883) + #> + param($Tenant) + + $TestId = 'E8_Admin_04' + $Name = 'User consent for risky OAuth applications is restricted (ISM-1883)' + + try { + $AuthPolicy = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthorizationPolicy' + if (-not $AuthPolicy) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthorizationPolicy cache not found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $Cfg = $AuthPolicy | Select-Object -First 1 + $Permissions = $Cfg.defaultUserRolePermissions + # The assigned consent policies live at the top level of the authorization policy, not under defaultUserRolePermissions. + $UserConsent = $Cfg.permissionGrantPolicyIdsAssignedToDefaultUserRole + + $Issues = [System.Collections.Generic.List[string]]::new() + if ($Permissions.allowedToCreateApps -eq $true) { + $Issues.Add('defaultUserRolePermissions.allowedToCreateApps is true — non-admin users can register new applications.') + } + if ($Cfg.allowUserConsentForRiskyApps -eq $true) { + $Issues.Add('allowUserConsentForRiskyApps is true — users can consent to applications Microsoft flags as risky.') + } + if ($UserConsent -contains 'ManagePermissionGrantsForSelf.microsoft-user-default-legacy') { + $Issues.Add('Legacy user consent policy in effect (`ManagePermissionGrantsForSelf.microsoft-user-default-legacy`). Switch to `microsoft-user-default-low` or admin-only.') + } + + if ($Issues.Count -eq 0) { + $Status = 'Passed' + $Result = 'User consent for OAuth apps is restricted to low-impact (or admin-only).' + } else { + $Status = 'Failed' + $Result = "Risky OAuth consent configuration:`n`n$(($Issues | ForEach-Object { "- $_" }) -join "`n")" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.md new file mode 100644 index 0000000000000..692fbc06241a5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.md @@ -0,0 +1,14 @@ +ISM-1648 — privileged access disabled after 45 days of inactivity. Stale privileged accounts are a common foothold for attackers; if no one is signing in, the account either should not exist or should be disabled. The control window is signal-of-life via Entra ID `signInActivity.lastSignInDateTime`. + +**Remediation Action** + +1. Review each stale privileged account listed. +2. Disable, delete, or remove privileged role assignments where appropriate. +3. Where the account is genuinely needed for monthly tasks, document the exception and consider PIM eligible assignment instead of permanent. + +**Links** +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) +- [ISM-1648](https://www.cyber.gov.au/ism) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.ps1 new file mode 100644 index 0000000000000..7f7873cbbe148 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.ps1 @@ -0,0 +1,69 @@ +function Invoke-CippTestE8_Admin_05 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML2) - No privileged account is inactive for more than 45 days (ISM-1648) + #> + param($Tenant) + + $TestId = 'E8_Admin_05' + $Name = 'No privileged account inactive for more than 45 days (ISM-1648)' + + try { + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Roles -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles or Users) not found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new() + $PrivUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { [void]$PrivRoleIds.Add($RoleTemplateId) } + foreach ($M in @($Role.members)) { + if ($M.id) { [void]$PrivUserIds.Add([string]$M.id) } + } + } + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $PrivRoleIds.Contains([string]$A.roleDefinitionId)) { + [void]$PrivUserIds.Add([string]$A.principalId) + } + } + $PrivUsers = $Users | Where-Object { $PrivUserIds.Contains($_.id) -and $_.accountEnabled -eq $true } + + if (-not $PrivUsers) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No enabled privileged users found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + + $Threshold = (Get-Date).AddDays(-45) + $Stale = foreach ($U in $PrivUsers) { + $Last = $U.signInActivity.lastSignInDateTime + if (-not $Last) { continue } + $LastDt = [datetime]::Parse($Last) + if ($LastDt -lt $Threshold) { + [pscustomobject]@{ UPN = $U.userPrincipalName; LastSignIn = $LastDt.ToString('yyyy-MM-dd') } + } + } + + if (-not $Stale) { + $Status = 'Passed' + $Result = "All $($PrivUsers.Count) enabled privileged user(s) signed in within the last 45 days (or have no recorded sign-in)." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Stale.Count) of $($PrivUsers.Count) enabled privileged user(s) have not signed in for more than 45 days:`n`n| UPN | Last sign-in |`n| :-- | :----------- |`n") + foreach ($S in ($Stale | Sort-Object LastSignIn | Select-Object -First 50)) { + $null = $Sb.Append("| $($S.UPN) | $($S.LastSignIn) |`n") + } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.md new file mode 100644 index 0000000000000..804215e13c39a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.md @@ -0,0 +1,14 @@ +ISM-1509 — privileged access events are centrally logged. The Entra ID Sign-in log and Audit log must be forwarded to a SIEM and retained for 12 months. This is configured under *Entra ID > Diagnostic settings* and depends on a target Log Analytics workspace / event hub / storage account that lives outside the tenant CIPP can read; verify manually. + +**Remediation Action** + +1. Entra ID > Diagnostic settings > Add diagnostic setting. +2. Send `SignInLogs`, `AuditLogs`, `RiskyUsers`, `UserRiskEvents`, `ServicePrincipalSignInLogs` to a Log Analytics workspace / Sentinel / Event Hub. +3. Confirm retention ≥ 12 months on the destination. + +**Links** +- [Entra ID diagnostic settings](https://learn.microsoft.com/en-us/azure/active-directory/reports-monitoring/howto-integrate-activity-logs-with-azure-monitor-logs) +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.ps1 new file mode 100644 index 0000000000000..4402d31eb7bd3 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Admin_06 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML2) - Privileged access events are forwarded to a SIEM (ISM-1509) + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Admin_06' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm Entra ID Sign-in and Audit logs (and Microsoft 365 Unified Audit Log) are forwarded to a SIEM (Sentinel, Splunk, etc.) and retained for at least 12 months. CIPP cannot verify diagnostic settings or external SIEM connectivity from the partner tenant.' -Risk 'Medium' -Name 'Privileged access events are centrally logged (ISM-1509)' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.md new file mode 100644 index 0000000000000..50f9723f57f05 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.md @@ -0,0 +1,14 @@ +Microsoft and ACSC both recommend 2-4 dedicated cloud-only Global Administrator break-glass accounts on the `*.onmicrosoft.com` domain that are excluded from MFA-enforcing Conditional Access policies. Their purpose is recovery during a Conditional Access misconfiguration or MFA service outage. + +**Remediation Action** + +1. Maintain 2 (recommended) or up to 4 cloud-only `*.onmicrosoft.com` GA accounts. +2. Exclude them from MFA-required Conditional Access policies. +3. Protect them with FIDO2 / hardware tokens, store credentials in a sealed envelope, monitor sign-ins via Sentinel. + +**Links** +- [Manage emergency access accounts](https://learn.microsoft.com/en-us/azure/active-directory/roles/security-emergency-access) +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.ps1 new file mode 100644 index 0000000000000..5d9a8b2267b2e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.ps1 @@ -0,0 +1,75 @@ +function Invoke-CippTestE8_Admin_07 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML2) - Break-glass accounts exist and are excluded from MFA enforcement + #> + param($Tenant) + + $TestId = 'E8_Admin_07' + $Name = 'Break-glass accounts (2-4) exist and are excluded from at least one MFA Conditional Access policy' + + try { + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + + if (-not $Roles -or -not $Users -or -not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles, Users or ConditionalAccessPolicies) not found.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + + $GaRole = $Roles | Where-Object { $_.displayName -eq 'Global Administrator' } | Select-Object -First 1 + if (-not $GaRole) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Global Administrator role not found in the Roles cache.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + + $GaTemplateId = if ($GaRole.roleTemplateId) { [string]$GaRole.roleTemplateId } elseif ($GaRole.RoletemplateId) { [string]$GaRole.RoletemplateId } else { $null } + + $GaUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($M in @($GaRole.members)) { + if ($M.id) { [void]$GaUserIds.Add([string]$M.id) } + } + # RoleAssignmentScheduleInstances.roleDefinitionId is a role template ID, not the directory role instance ID. + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $GaTemplateId -and [string]$A.roleDefinitionId -eq $GaTemplateId) { + [void]$GaUserIds.Add([string]$A.principalId) + } + } + $GaUsers = $Users | Where-Object { $GaUserIds.Contains($_.id) } + $BreakGlass = $GaUsers | Where-Object { $_.userPrincipalName -like '*onmicrosoft.com' -and $_.accountEnabled -eq $true } + + if (-not $BreakGlass -or $BreakGlass.Count -lt 2) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Only $($BreakGlass.Count) Global Administrator(s) on the *.onmicrosoft.com domain. ACSC guidance recommends 2-4 dedicated cloud-only break-glass accounts." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + if ($BreakGlass.Count -gt 4) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "$($BreakGlass.Count) cloud-only Global Administrators exist. Excessive break-glass accounts increase risk; reduce to 2-4." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + + $BreakGlassIds = [System.Collections.Generic.HashSet[string]]::new([string[]]$BreakGlass.id) + $MfaPolicies = $CA | Where-Object { + $_.state -eq 'enabled' -and + (($_.grantControls.builtInControls -contains 'mfa') -or $_.grantControls.authenticationStrength) + } + $WithExclusion = foreach ($P in $MfaPolicies) { + $Excluded = $P.conditions.users.excludeUsers + if ($Excluded -and (@($Excluded) | Where-Object { $BreakGlassIds.Contains($_) }).Count -gt 0) { $P } + } + + if ($WithExclusion) { + $Status = 'Passed' + $Result = "$($BreakGlass.Count) break-glass account(s) found. They are excluded from $((@($WithExclusion)).Count) MFA-enforcing Conditional Access policy/policies." + } else { + $Status = 'Failed' + $Result = "$($BreakGlass.Count) break-glass account(s) exist but no MFA-enforcing Conditional Access policy excludes them. A token-service MFA outage will lock the tenant." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.md new file mode 100644 index 0000000000000..f83c1b86a154b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.md @@ -0,0 +1,14 @@ +ISM-1508 — privileged accounts are time-bound and reauthenticated for each session. Microsoft's equivalent is Privileged Identity Management (PIM): admins are *eligible* for a role and must activate it for a limited time. This test fails when highly-privileged roles have permanent (active) assignments rather than eligible-only. + +**Remediation Action** + +1. Entra ID > Privileged Identity Management > Microsoft Entra roles. +2. For each role listed, convert all members from *Active* to *Eligible*. +3. Set max activation duration ≤ 8 hours; require justification + MFA on activation. + +**Links** +- [PIM in Microsoft Entra](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure) +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.ps1 new file mode 100644 index 0000000000000..5c7a0fee1911f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.ps1 @@ -0,0 +1,70 @@ +function Invoke-CippTestE8_Admin_08 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML3) - Just-in-Time / PIM is used for highly privileged roles (ISM-1508) + #> + param($Tenant) + + $TestId = 'E8_Admin_08' + $Name = 'Just-in-Time activation (PIM eligibility) is used for highly privileged roles' + + $HighlyPriv = @('Global Administrator','Privileged Role Administrator','Privileged Authentication Administrator','Conditional Access Administrator','Intune Administrator','Security Administrator') + + try { + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + + if (-not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles) not found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Restrict Admin Privileges' + return + } + + # Without PIM active-assignment data we cannot distinguish permanent assignments from + # PIM-eligible activation, so treating a missing cache as "compliant" would be a false pass. + if (-not $RoleAssignmentScheduleInstances) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'RoleAssignmentScheduleInstances (PIM active assignments) cache not found — cannot verify whether highly-privileged roles use Just-in-Time activation.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Restrict Admin Privileges' + return + } + + $TargetRoles = $Roles | Where-Object { $_.displayName -in $HighlyPriv } + if (-not $TargetRoles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No highly-privileged roles found in cache.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Restrict Admin Privileges' + return + } + + # RoleAssignmentScheduleInstances.roleDefinitionId is a role template ID, not a directory role instance ID. + $TargetRoleTemplates = @{} + foreach ($R in $TargetRoles) { + $Tid = if ($R.roleTemplateId) { [string]$R.roleTemplateId } elseif ($R.RoletemplateId) { [string]$R.RoletemplateId } else { $null } + if ($Tid) { $TargetRoleTemplates[$Tid] = $R.displayName } + } + $PermanentByRole = @{} + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $TargetRoleTemplates.ContainsKey([string]$A.roleDefinitionId)) { + $key = [string]$A.roleDefinitionId + if (-not $PermanentByRole.ContainsKey($key)) { $PermanentByRole[$key] = [System.Collections.Generic.HashSet[string]]::new() } + [void]$PermanentByRole[$key].Add([string]$A.principalId) + } + } + + $RolesWithPermanent = foreach ($Tid in $TargetRoleTemplates.Keys) { + $count = if ($PermanentByRole.ContainsKey($Tid)) { $PermanentByRole[$Tid].Count } else { 0 } + if ($count -gt 0) { [pscustomobject]@{ Role = $TargetRoleTemplates[$Tid]; Permanent = $count } } + } + + if (-not $RolesWithPermanent) { + $Status = 'Passed' + $Result = "No permanent role assignments found for highly-privileged roles ($($TargetRoles.displayName -join ', ')). All access appears to be PIM-eligible." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("Permanent (non-PIM) assignments to highly-privileged roles:`n`n| Role | Permanent assignees |`n| :--- | :-----------------: |`n") + foreach ($R in $RolesWithPermanent) { $null = $Sb.Append("| $($R.Role) | $($R.Permanent) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.md new file mode 100644 index 0000000000000..f9849564a60ea --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.md @@ -0,0 +1,13 @@ +PIM activation should require MFA and a typed justification for every privileged role activation. This raises the bar against stolen tokens and provides an audit trail. + +**Remediation Action** + +1. Entra ID > PIM > Microsoft Entra roles > Roles. +2. For each role, open *Role settings* > *Edit*. +3. On Activation, tick **Azure MFA** and **Require justification on activation**. + +**Links** +- [Configure Microsoft Entra role settings in PIM](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-change-default-settings) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.ps1 new file mode 100644 index 0000000000000..ad1add866ded9 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Admin_09 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML3) - PIM activation requires MFA and justification + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Admin_09' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. In Entra ID > PIM > Microsoft Entra roles > Settings, confirm each highly-privileged role requires MFA on activation and a justification. The full PIM rule set is not exposed in cached `RoleManagementPolicies` (rules require `$expand=rules` per role).' -Risk 'High' -Name 'PIM activation requires MFA and justification' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML3 - Restrict Admin Privileges' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.md new file mode 100644 index 0000000000000..a8cc878a93236 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.md @@ -0,0 +1,13 @@ +For tier-zero roles (Global Administrator, Privileged Role Administrator) activation should require approval from a second administrator. This adds a four-eyes control on the most damaging roles. + +**Remediation Action** + +1. Entra ID > PIM > Microsoft Entra roles > Roles > *Global Administrator* > Role settings > Edit. +2. On Activation, enable **Require approval to activate** and add at least one approver. +3. Repeat for *Privileged Role Administrator*. + +**Links** +- [Approve activation requests in PIM](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-approval-workflow) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.ps1 new file mode 100644 index 0000000000000..5ef104d433cc0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Admin_10 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML3) - PIM activation requires approval for highly privileged roles + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Admin_10' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm Global Administrator and Privileged Role Administrator activations require approval (PIM > Role settings > Activation > Require approval to activate). The full PIM rule set is not exposed in the cached `RoleManagementPolicies` collection.' -Risk 'High' -Name 'PIM activation requires approval for Global Administrator and Privileged Role Administrator' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML3 - Restrict Admin Privileges' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.md new file mode 100644 index 0000000000000..6f06c280ce97a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.md @@ -0,0 +1,13 @@ +ISM-1647 — privileges are reviewed at least annually. PIM eligibility assignments must therefore expire within 12 months so the next assignment is a deliberate decision. Permanent eligibility defeats the purpose of access reviews. + +**Remediation Action** + +1. Entra ID > PIM > Microsoft Entra roles > Assignments > Eligible. +2. For each assignment without expiry, set an end date ≤ 12 months from now. +3. Tighten role settings so future assignments cannot exceed 12 months. + +**Links** +- [Assign Microsoft Entra roles in PIM](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-add-role-to-user) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.ps1 new file mode 100644 index 0000000000000..fa90401cba9ad --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.ps1 @@ -0,0 +1,45 @@ +function Invoke-CippTestE8_Admin_11 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML3) - PIM eligibility is reviewed at least every 12 months (ISM-1647) + #> + param($Tenant) + + $TestId = 'E8_Admin_11' + $Name = 'PIM role eligibility expires within 12 months (no permanent eligibility) (ISM-1647)' + + try { + $Schedules = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleEligibilitySchedules' + if (-not $Schedules) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'RoleEligibilitySchedules cache not found (no PIM in use, or P2 not licensed).' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Restrict Admin Privileges' + return + } + + $Now = Get-Date + $MaxFuture = $Now.AddDays(366) + $Bad = foreach ($S in $Schedules) { + $Type = $S.scheduleInfo.expiration.type + $End = $S.scheduleInfo.expiration.endDateTime + if ($Type -eq 'noExpiration' -or -not $End) { + [pscustomobject]@{ Principal = $S.principalId; RoleId = $S.roleDefinitionId; Reason = 'No expiration' } + } elseif ([datetime]::Parse($End) -gt $MaxFuture) { + [pscustomobject]@{ Principal = $S.principalId; RoleId = $S.roleDefinitionId; Reason = "Expires $End (>12 months)" } + } + } + + if (-not $Bad) { + $Status = 'Passed' + $Result = "All $($Schedules.Count) PIM eligibility schedule(s) expire within 12 months." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Bad.Count) of $($Schedules.Count) PIM eligibility schedule(s) do not expire within 12 months:`n`n| Principal | Role | Reason |`n| :-------- | :--- | :----- |`n") + foreach ($B in ($Bad | Select-Object -First 50)) { $null = $Sb.Append("| $($B.Principal) | $($B.RoleId) | $($B.Reason) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.md new file mode 100644 index 0000000000000..3bfb761392c9b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.md @@ -0,0 +1,13 @@ +Microsoft and ACSC recommend a minimum of 2 and a maximum of 4 Global Administrators. Two is the minimum to avoid total lockout; more than four needlessly increases attack surface. + +**Remediation Action** + +1. Identify excess Global Administrators in Entra ID > Roles and administrators > Global Administrator. +2. Replace with least-privileged roles (e.g. *Exchange Administrator*, *User Administrator*). +3. If fewer than 2 — add a second cloud-only break-glass GA. + +**Links** +- [Best practices for roles in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.ps1 new file mode 100644 index 0000000000000..a01b938a4e951 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.ps1 @@ -0,0 +1,58 @@ +function Invoke-CippTestE8_Admin_12 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML1) - Global Administrator count is between 2 and 4 + #> + param($Tenant) + + $TestId = 'E8_Admin_12' + $Name = 'Global Administrator count is between 2 and 4' + + try { + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + + if (-not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles) not found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $GaRole = $Roles | Where-Object { $_.displayName -eq 'Global Administrator' } | Select-Object -First 1 + if (-not $GaRole) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Global Administrator role not present in cache.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $GaTemplateId = if ($GaRole.roleTemplateId) { [string]$GaRole.roleTemplateId } elseif ($GaRole.RoletemplateId) { [string]$GaRole.RoletemplateId } else { $null } + + # Only count user accounts as Global Administrators — service principals holding the role + # (e.g. the CIPP-SAM application) are not human admins and cannot be reduced by delegation. + $GaUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($M in @($GaRole.members)) { + if ($M.id -and $M.'@odata.type' -eq '#microsoft.graph.user') { [void]$GaUserIds.Add([string]$M.id) } + } + # RoleAssignmentScheduleInstances.roleDefinitionId is a role template ID, not the directory role instance ID. + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $GaTemplateId -and [string]$A.roleDefinitionId -eq $GaTemplateId) { + [void]$GaUserIds.Add([string]$A.principalId) + } + } + $GaCount = $GaUserIds.Count + + if ($GaCount -ge 2 -and $GaCount -le 4) { + $Status = 'Passed' + $Result = "$GaCount Global Administrator(s) — within recommended range of 2-4." + } elseif ($GaCount -lt 2) { + $Status = 'Failed' + $Result = "Only $GaCount Global Administrator(s). At least 2 are required so a single account loss does not lock the tenant." + } else { + $Status = 'Failed' + $Result = "$GaCount Global Administrators — exceeds the recommended maximum of 4. Reduce by delegating finer-grained roles." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.md new file mode 100644 index 0000000000000..1f1dc92d11784 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.md @@ -0,0 +1,13 @@ +High-privilege OAuth grants (e.g. application permissions on Microsoft Graph such as `Directory.ReadWrite.All`, `RoleManagement.ReadWrite.Directory`, `Application.ReadWrite.All`, `Mail.ReadWrite`) effectively grant Global-Admin-equivalent access via a service principal. Review these regularly. + +**Remediation Action** + +1. Entra ID > Enterprise applications > All applications. +2. For each app with admin-consented permissions, review *Permissions* and revoke unused. +3. Use Microsoft Defender for Cloud Apps / Microsoft 365 Defender to alert on new high-privilege consents. + +**Links** +- [Investigate risky OAuth apps](https://learn.microsoft.com/en-us/defender-cloud-apps/investigate-risky-oauth) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.ps1 new file mode 100644 index 0000000000000..d0a1d832cb1a0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Admin_13 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML2) - High-privilege OAuth grants are reviewed + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Admin_13' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Review enterprise applications and OAuth2 permission grants for high-privilege scopes (Directory.ReadWrite.All, RoleManagement.ReadWrite.Directory, Application.ReadWrite.All, Mail.ReadWrite, full_access_as_app). The OAuth2PermissionGrants and ServicePrincipals collections are not currently cached for analysis here; use the CIPP *Application Approvals* and *Enterprise Applications* views instead.' -Risk 'Medium' -Name 'High-privilege OAuth grants are reviewed' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.md new file mode 100644 index 0000000000000..421c17398fdbc --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.md @@ -0,0 +1,13 @@ +Litigation hold and retention policies prevent permanent loss of mailbox content from accidental or malicious deletion. While not a true backup, they provide point-in-time recovery for E8 ML1. + +**Remediation Action** + +1. Apply a Microsoft 365 retention policy to user mailboxes covering Exchange, OneDrive, and SharePoint. +2. Or enable per-mailbox litigation hold with a duration of at least the organisation's retention requirement. + +**Links** +- [Litigation hold](https://learn.microsoft.com/en-us/purview/ediscovery-create-a-litigation-hold) +- [Microsoft 365 retention policies](https://learn.microsoft.com/en-us/purview/retention) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.ps1 new file mode 100644 index 0000000000000..081976351991d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.ps1 @@ -0,0 +1,49 @@ +function Invoke-CippTestE8_Backup_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Regular Backups, ML1) - User mailboxes have litigation hold or retention applied + #> + param($Tenant) + + $TestId = 'E8_Backup_01' + $Name = 'User mailboxes have litigation hold or a retention policy applied' + + try { + $Mailboxes = Get-CIPPTestData -TenantFilter $Tenant -Type 'Mailboxes' + if (-not $Mailboxes) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No Mailboxes cached for this tenant.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' + return + } + + # Inactive (soft-deleted) mailboxes carry WhenSoftDeleted; there is no IsInactiveMailbox field in the cache. + $UserMailboxes = $Mailboxes | Where-Object { $_.RecipientTypeDetails -eq 'UserMailbox' -and -not $_.WhenSoftDeleted } + if (-not $UserMailboxes) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No user mailboxes found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' + return + } + + # The built-in "Default MRM Policy" is present on every mailbox and only manages archive/deletion tags — + # it is not a data-protection retention control, so it does not count as protected. + $Unprotected = $UserMailboxes | Where-Object { + -not ($_.LitigationHoldEnabled -eq $true) -and + -not ($_.ComplianceTagHoldApplied -eq $true) -and + ([string]::IsNullOrWhiteSpace($_.RetentionPolicy) -or $_.RetentionPolicy -eq 'Default MRM Policy') -and + -not $_.InPlaceHolds + } + + if (-not $Unprotected) { + $Status = 'Passed' + $Result = "All $($UserMailboxes.Count) user mailbox(es) have at least one of: litigation hold, a non-default retention policy, or compliance hold applied." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Unprotected.Count) of $($UserMailboxes.Count) user mailbox(es) have no litigation hold, retention policy, or compliance tag applied:`n`n| UPN |`n| :-- |`n") + foreach ($M in ($Unprotected | Select-Object -First 50)) { $null = $Sb.Append("| $($M.UPN) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.md new file mode 100644 index 0000000000000..885790e1ad9e6 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.md @@ -0,0 +1,13 @@ +SharePoint version history and the recycle bin together provide point-in-time recovery for documents. Confirm versioning is on (default 500 versions for new libraries) and the recycle bin retention is 93 days. + +**Remediation Action** + +1. SharePoint admin centre > Sites > Active sites. +2. For critical sites, confirm library versioning is enabled and the version count is acceptable. +3. Recycle bin retention is fixed at 93 days for SharePoint and OneDrive. + +**Links** +- [SharePoint versioning](https://learn.microsoft.com/en-us/sharepoint/enable-disable-versioning) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.ps1 new file mode 100644 index 0000000000000..b22052da74da8 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_Backup_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Regular Backups, ML1) - SharePoint Online retains versions and recycle bin items + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Backup_02' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm SharePoint Online sites have versioning enabled (default minimum 100 versions) and the second-stage recycle bin retention is at least 93 days. Site-level versioning is configured per library and is not exposed centrally; review via SharePoint admin centre or PnP PowerShell.' -Risk 'Medium' -Name 'SharePoint Online versioning and recycle bin retention is configured' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.md new file mode 100644 index 0000000000000..c1cd843c1a17d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.md @@ -0,0 +1,12 @@ +Known Folder Move redirects users' Desktop, Documents, and Pictures to OneDrive so file changes are continuously synced and version-protected. + +**Remediation Action** + +1. Intune > Settings catalog > **OneDrive** > *Silently move Windows known folders to OneDrive* = Enabled with the tenant ID. +2. Optionally enable *Use OneDrive Files On-Demand* and *Prevent users from redirecting Windows known folders to their PC*. + +**Links** +- [Redirect known folders to OneDrive](https://learn.microsoft.com/en-us/sharepoint/redirect-known-folders) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.ps1 new file mode 100644 index 0000000000000..f51b96dd5987d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_Backup_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Regular Backups, ML1) - OneDrive Known Folder Move (KFM) is configured + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Backup_03' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm OneDrive Known Folder Move is configured to redirect Desktop, Documents, and Pictures to OneDrive on all Windows endpoints. Configure via Intune Settings catalog: *OneDrive > Silently move Windows known folders to OneDrive*.' -Risk 'Medium' -Name 'OneDrive Known Folder Move (KFM) is enforced' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.md new file mode 100644 index 0000000000000..97d502bf95dc0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.md @@ -0,0 +1,14 @@ +ISM-1547 — backups must be performed regularly, retained for an organisation-determined period, stored separately from the source data, and **tested**. Microsoft retention is not a backup. Use Microsoft 365 Backup or a third-party solution (Veeam, Datto, Druva, AvePoint, etc.) and run a documented restore test at least quarterly. + +**Remediation Action** + +1. Provision a Microsoft 365 backup solution covering Exchange, OneDrive, SharePoint, and Teams. +2. Schedule a quarterly test restore and record the results in your backup runbook. +3. Verify the backup admin account is not synced from on-premises and uses an isolated identity. + +**Links** +- [Microsoft 365 Backup](https://learn.microsoft.com/en-us/microsoft-365/backup/) +- [ACSC Essential Eight - Regular Backups](https://learn.microsoft.com/en-us/compliance/anz/e8-backup) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.ps1 new file mode 100644 index 0000000000000..5052f18712213 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_Backup_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Regular Backups, ML2) - A tested backup and restore process exists for Microsoft 365 data + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Backup_04' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm a third-party (or Microsoft 365 Backup) solution is in place for mailboxes, OneDrive, SharePoint, and Teams data, and that restore tests are performed at least quarterly with documented results. Microsoft retention is **not** a backup — it does not protect against admin deletion or compliance policy changes.' -Risk 'High' -Name 'Microsoft 365 data is backed up by a tested process (ISM-1547)' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'E8 ML2 - Regular Backups' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.md new file mode 100644 index 0000000000000..977487368e95c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.md @@ -0,0 +1,14 @@ +All staff (and any service accounts that interact with the tenant) must be registered for multifactor authentication. The Essential Eight requires MFA at Maturity Level 1 across all users so a stolen password cannot, on its own, grant access to corporate data. + +**Remediation Action** + +1. Identify users without MFA registration (this test lists them). +2. Enable a Conditional Access policy or Security Defaults to force registration on next sign-in. +3. Validate via Entra ID > Authentication methods > User registration details that `isMfaCapable = true`. + +**Links** +- [ACSC Essential Eight - Multifactor Authentication](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Plan an Authentication methods deployment](https://learn.microsoft.com/en-us/azure/active-directory/authentication/howto-authentication-methods-activity) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.ps1 new file mode 100644 index 0000000000000..ad4c52391273d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.ps1 @@ -0,0 +1,49 @@ +function Invoke-CippTestE8_MFA_01 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML1) - All member users are MFA capable + #> + param($Tenant) + + $TestId = 'E8_MFA_01' + $Name = 'All member users are registered for MFA' + + try { + $Reg = Get-CIPPTestData -TenantFilter $Tenant -Type 'UserRegistrationDetails' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Reg -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (UserRegistrationDetails or Users) not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + return + } + + $RegByUpn = @{} + foreach ($R in ($Reg | Where-Object { $_.userPrincipalName })) { + $RegByUpn[$R.userPrincipalName.ToLower()] = $R + } + + $MemberUsers = $Users | Where-Object { $_.accountEnabled -eq $true -and $_.userType -ne 'Guest' } + $NotMfaCapable = foreach ($U in $MemberUsers) { + $R = $RegByUpn[[string]$U.userPrincipalName.ToLower()] + if (-not $R -or $R.isMfaCapable -ne $true) { $U } + } + + if (-not $NotMfaCapable) { + $Status = 'Passed' + $Result = "All $($MemberUsers.Count) enabled member users are MFA capable." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($NotMfaCapable.Count) of $($MemberUsers.Count) enabled member users are not MFA capable:`n`n") + $null = $Sb.Append("| UPN | Display Name |`n| :-- | :----------- |`n") + foreach ($U in ($NotMfaCapable | Select-Object -First 50)) { + $null = $Sb.Append("| $($U.userPrincipalName) | $($U.displayName) |`n") + } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.md new file mode 100644 index 0000000000000..2cee23be4ddbf --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.md @@ -0,0 +1,16 @@ +A tenant-wide Conditional Access policy that requires MFA on every sign-in to every cloud app is the baseline control for Essential Eight ML1. Without it, MFA remains optional and attackers can bypass it via legacy clients or unprotected applications. + +**Remediation Action** + +1. Entra ID > Conditional Access > New policy. +2. Users: All users (exclude break-glass). +3. Cloud apps: All cloud apps. +4. Grant: Require multifactor authentication (or an Authentication Strength). +5. Enable policy. + +**Links** +- [ACSC Essential Eight - Multifactor Authentication](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Common CA policy: Require MFA for all users](https://learn.microsoft.com/en-us/azure/active-directory/conditional-access/howto-conditional-access-policy-all-users-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.ps1 new file mode 100644 index 0000000000000..9d30202b53d56 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.ps1 @@ -0,0 +1,42 @@ +function Invoke-CippTestE8_MFA_02 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML1) - A Conditional Access policy enforces MFA for all users + #> + param($Tenant) + + $TestId = 'E8_MFA_02' + $Name = 'A Conditional Access policy enforces MFA for all users on all cloud apps' + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + return + } + + $Match = $CA | Where-Object { + $_.state -eq 'enabled' -and + ($_.conditions.users.includeUsers -contains 'All') -and + ($_.conditions.applications.includeApplications -contains 'All') -and + ( + ($_.grantControls.builtInControls -contains 'mfa') -or + $_.grantControls.authenticationStrength + ) + } + + if ($Match) { + $Status = 'Passed' + $Result = "$($Match.Count) Conditional Access policy/policies enforce MFA on all users for all cloud apps:`n`n" + + (($Match | ForEach-Object { "- $($_.displayName)" }) -join "`n") + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy targets All Users + All Cloud Apps with an MFA grant control.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.md new file mode 100644 index 0000000000000..37065b793d250 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.md @@ -0,0 +1,17 @@ +Legacy authentication protocols (POP, IMAP, SMTP AUTH, older Outlook clients, ActiveSync basic auth) cannot enforce MFA. If they are reachable, an attacker with stolen credentials defeats Essential Eight MFA controls. + +**Remediation Action** + +1. Entra ID > Conditional Access > Create policy. +2. Users: All users (exclude break-glass + service accounts that genuinely need legacy auth). +3. Cloud apps: All cloud apps. +4. Conditions > Client apps: tick *Exchange ActiveSync clients* and *Other clients*. +5. Grant: Block access. +6. Enable. + +**Links** +- [Block legacy authentication](https://learn.microsoft.com/en-us/azure/active-directory/conditional-access/howto-conditional-access-policy-block-legacy) +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.ps1 new file mode 100644 index 0000000000000..217c9b3799f35 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.ps1 @@ -0,0 +1,39 @@ +function Invoke-CippTestE8_MFA_03 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML1) - Legacy authentication is blocked + #> + param($Tenant) + + $TestId = 'E8_MFA_03' + $Name = 'Legacy authentication is blocked tenant-wide' + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + return + } + + $Match = $CA | Where-Object { + $_.state -eq 'enabled' -and + ($_.conditions.users.includeUsers -contains 'All') -and + ($_.conditions.clientAppTypes -contains 'exchangeActiveSync' -or $_.conditions.clientAppTypes -contains 'other') -and + ($_.grantControls.builtInControls -contains 'block') + } + + if ($Match) { + $Status = 'Passed' + $Result = "$($Match.Count) Conditional Access policy/policies block legacy auth:`n`n" + + (($Match | ForEach-Object { "- $($_.displayName)" }) -join "`n") + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy blocks legacy authentication clients (`exchangeActiveSync`/`other`). MFA can be bypassed via legacy protocols if not blocked.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.md new file mode 100644 index 0000000000000..268ec4164e343 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.md @@ -0,0 +1,14 @@ +Essential Eight Maturity Level 2 requires phishing-resistant MFA for privileged users. Before users can be required to use it, the tenant must have at least one phishing-resistant method (FIDO2 security keys, Windows Hello for Business, or certificate-based authentication) enabled in the Authentication methods policy. + +**Remediation Action** + +1. Entra ID > Authentication methods > Policies. +2. Enable at least one of: FIDO2 security key, Windows Hello for Business, Certificate-based authentication. +3. Target the appropriate user/group scope and save. + +**Links** +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Authentication methods policy](https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-authentication-methods-manage) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.ps1 new file mode 100644 index 0000000000000..f1e6e15ad81c8 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.ps1 @@ -0,0 +1,40 @@ +function Invoke-CippTestE8_MFA_04 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML2) - At least one phishing-resistant authentication method is enabled + #> + param($Tenant) + + $TestId = 'E8_MFA_04' + $Name = 'A phishing-resistant authentication method is enabled in the tenant' + + try { + $AuthMethodsPolicy = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationMethodsPolicy' + if (-not $AuthMethodsPolicy) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthenticationMethodsPolicy cache not found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML2 - MFA' + return + } + + # Windows Hello for Business is provisioned via Intune / device registration and is not part of + # authenticationMethodConfigurations, so it cannot be evaluated from this policy. + $Configs = $AuthMethodsPolicy.authenticationMethodConfigurations + $Enabled = [System.Collections.Generic.List[string]]::new() + foreach ($Id in 'Fido2','X509Certificate') { + $C = $Configs | Where-Object { $_.id -eq $Id } + if ($C -and $C.state -eq 'enabled') { $Enabled.Add($Id) } + } + + if ($Enabled.Count -gt 0) { + $Status = 'Passed' + $Result = "Phishing-resistant authentication method(s) enabled: $($Enabled -join ', ')." + } else { + $Status = 'Failed' + $Result = 'No phishing-resistant authentication method (FIDO2 security key or X509 certificate-based auth) is enabled in the tenant. Windows Hello for Business is managed via Intune and is not evaluated by this test.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML2 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML2 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.md new file mode 100644 index 0000000000000..eee46bb0e1586 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.md @@ -0,0 +1,14 @@ +SMS, Voice and Email OTP are not phishing-resistant: an attacker who can intercept SMS or proxy a sign-in page can capture the code. ACSC Essential Eight ML2/ML3 requires phasing these out in favour of FIDO2, Windows Hello, or certificate-based authentication. + +**Remediation Action** + +1. Entra ID > Authentication methods > Policies. +2. Set *SMS*, *Voice call*, and *Email OTP* to Disabled (or restrict to a tightly-scoped group during transition). +3. Make sure users have a phishing-resistant or Authenticator App method registered first. + +**Links** +- [Phishing-resistant MFA in Entra ID](https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-authentication-strengths) +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.ps1 new file mode 100644 index 0000000000000..4e3cfe13e30fb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.ps1 @@ -0,0 +1,38 @@ +function Invoke-CippTestE8_MFA_05 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML2) - Weak authentication methods (SMS / Voice / Email OTP) are disabled + #> + param($Tenant) + + $TestId = 'E8_MFA_05' + $Name = 'Weak MFA methods (SMS, Voice call, Email OTP) are disabled' + + try { + $AuthMethodsPolicy = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationMethodsPolicy' + if (-not $AuthMethodsPolicy) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthenticationMethodsPolicy cache not found.' -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML2 - MFA' + return + } + + $Configs = $AuthMethodsPolicy.authenticationMethodConfigurations + $Issues = [System.Collections.Generic.List[string]]::new() + foreach ($Id in 'Sms','Voice','Email') { + $C = $Configs | Where-Object { $_.id -eq $Id } + if ($C -and $C.state -eq 'enabled') { $Issues.Add($Id) } + } + + if ($Issues.Count -eq 0) { + $Status = 'Passed' + $Result = 'SMS, Voice and Email OTP methods are all disabled.' + } else { + $Status = 'Failed' + $Result = "The following weak (non phishing-resistant) MFA methods are still enabled: $($Issues -join ', ')." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML2 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML2 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.md new file mode 100644 index 0000000000000..59d59d0279f02 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.md @@ -0,0 +1,16 @@ +Privileged identities are the highest-value targets in a tenant. ACSC Essential Eight Maturity Level 2 mandates phishing-resistant MFA for them. In Entra ID this is enforced by a Conditional Access policy that targets directory roles with the built-in *Phishing-resistant MFA* authentication strength. + +**Remediation Action** + +1. Entra ID > Conditional Access > New policy. +2. Users: include Directory roles (all privileged roles such as Global Administrator, Privileged Role Administrator, Security Administrator, etc.). +3. Cloud apps: All cloud apps. +4. Grant > Require authentication strength > **Phishing-resistant MFA**. +5. Enable. + +**Links** +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Conditional Access authentication strengths](https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-authentication-strengths) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.ps1 new file mode 100644 index 0000000000000..6998fc66f35e9 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.ps1 @@ -0,0 +1,47 @@ +function Invoke-CippTestE8_MFA_06 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML2) - Phishing-resistant MFA strength is required for privileged roles + #> + param($Tenant) + + $TestId = 'E8_MFA_06' + $Name = 'Phishing-resistant authentication strength is required for privileged roles' + # Built-in Phishing-resistant MFA strength + $PhishResistantId = '00000000-0000-0000-0000-000000000004' + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + + if (-not $CA -or -not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ConditionalAccessPolicies or Roles) not found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + return + } + + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $PrivRoleIds = @($Roles | ForEach-Object { if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } }) + + $Match = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.conditions.users.includeRoles -and + (@($_.conditions.users.includeRoles) | Where-Object { $_ -in $PrivRoleIds }).Count -gt 0 -and + $_.grantControls.authenticationStrength -and + $_.grantControls.authenticationStrength.id -eq $PhishResistantId + } + + if ($Match) { + $Status = 'Passed' + $Result = "$($Match.Count) Conditional Access policy/policies require phishing-resistant MFA for privileged roles:`n`n" + + (($Match | ForEach-Object { "- $($_.displayName)" }) -join "`n") + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy targets privileged roles with the built-in *Phishing-resistant MFA* authentication strength.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.md new file mode 100644 index 0000000000000..3bd85f92ec72d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.md @@ -0,0 +1,14 @@ +A Conditional Access policy can require phishing-resistant MFA, but it only takes effect once each privileged user has actually registered such a method (FIDO2 key, Windows Hello, certificate, or device-bound passkey). This test enumerates privileged role members whose `methodsRegistered` list does not yet include a phishing-resistant method. + +**Remediation Action** + +1. Issue FIDO2 keys (or enable Windows Hello / certificate auth / passkeys) to each privileged user. +2. Have them register the method at https://aka.ms/mysecurityinfo before the Conditional Access policy is enforced. +3. Re-run this test once registrations complete. + +**Links** +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [User registration details API](https://learn.microsoft.com/en-us/graph/api/resources/userregistrationdetails) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.ps1 new file mode 100644 index 0000000000000..69aaa8442ef98 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.ps1 @@ -0,0 +1,67 @@ +function Invoke-CippTestE8_MFA_07 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML2) - Privileged users have a phishing-resistant method registered + #> + param($Tenant) + + $TestId = 'E8_MFA_07' + $Name = 'All privileged users have a phishing-resistant authentication method registered' + + try { + $Reg = Get-CIPPTestData -TenantFilter $Tenant -Type 'UserRegistrationDetails' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + + if (-not $Reg -or -not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (UserRegistrationDetails or Roles) not found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + return + } + + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new() + $PrivUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { [void]$PrivRoleIds.Add($RoleTemplateId) } + foreach ($M in @($Role.members)) { + if ($M.id -and $M.'@odata.type' -eq '#microsoft.graph.user') { [void]$PrivUserIds.Add([string]$M.id) } + } + } + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $PrivRoleIds.Contains([string]$A.roleDefinitionId)) { + [void]$PrivUserIds.Add([string]$A.principalId) + } + } + + if ($PrivUserIds.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No privileged users found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + return + } + + $PhishMethods = @('fido2SecurityKey','windowsHelloForBusiness','x509CertificateSingleFactor','x509CertificateMultiFactor','passKeyDeviceBound','passKeyDeviceBoundAuthenticator','passKeyDeviceBoundWindowsHello') + $NonCompliant = foreach ($R in $Reg | Where-Object { $PrivUserIds.Contains($_.id) }) { + $HasPhish = $false + foreach ($M in $R.methodsRegistered) { + if ($PhishMethods -contains $M) { $HasPhish = $true; break } + } + if (-not $HasPhish) { $R } + } + + if (-not $NonCompliant) { + $Status = 'Passed' + $Result = "All $($PrivUserIds.Count) privileged users have at least one phishing-resistant method registered." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($NonCompliant.Count) of $($PrivUserIds.Count) privileged users have no phishing-resistant method registered:`n`n| UPN | Methods Registered |`n| :-- | :----------------- |`n") + foreach ($U in ($NonCompliant | Select-Object -First 50)) { + $null = $Sb.Append("| $($U.userPrincipalName) | $(($U.methodsRegistered) -join ', ') |`n") + } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.md new file mode 100644 index 0000000000000..ce78ca97c809e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.md @@ -0,0 +1,14 @@ +Essential Eight Maturity Level 3 extends the phishing-resistant MFA requirement from privileged users to **all** users. Every enabled member account must have at least one phishing-resistant method (FIDO2, Windows Hello for Business, certificate-based authentication, or device-bound passkey) registered. + +**Remediation Action** + +1. Roll out FIDO2 security keys, Windows Hello for Business, or device-bound passkeys to all staff. +2. Run a registration campaign — make sure each user registers at least one phishing-resistant method. +3. Once coverage is complete, enforce via a tenant-wide Conditional Access policy with the *Phishing-resistant MFA* authentication strength. + +**Links** +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Plan a passwordless authentication deployment](https://learn.microsoft.com/en-us/azure/active-directory/authentication/howto-authentication-passwordless-deployment) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.ps1 new file mode 100644 index 0000000000000..715ffd6898656 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.ps1 @@ -0,0 +1,50 @@ +function Invoke-CippTestE8_MFA_08 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML3) - All member users have a phishing-resistant method registered + #> + param($Tenant) + + $TestId = 'E8_MFA_08' + $Name = 'All member users have a phishing-resistant authentication method registered' + + try { + $Reg = Get-CIPPTestData -TenantFilter $Tenant -Type 'UserRegistrationDetails' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Reg -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (UserRegistrationDetails or Users) not found.' -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + return + } + + $MemberIds = [System.Collections.Generic.HashSet[string]]::new( + [string[]]($Users | Where-Object { $_.accountEnabled -eq $true -and $_.userType -ne 'Guest' }).id) + + if ($MemberIds.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No enabled member users found.' -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + return + } + + $PhishMethods = @('fido2SecurityKey','windowsHelloForBusiness','x509CertificateSingleFactor','x509CertificateMultiFactor','passKeyDeviceBound','passKeyDeviceBoundAuthenticator','passKeyDeviceBoundWindowsHello') + $Total = 0; $NonCompliant = 0 + foreach ($R in $Reg | Where-Object { $MemberIds.Contains($_.id) }) { + $Total++ + $HasPhish = $false + foreach ($M in $R.methodsRegistered) { if ($PhishMethods -contains $M) { $HasPhish = $true; break } } + if (-not $HasPhish) { $NonCompliant++ } + } + + if ($NonCompliant -eq 0) { + $Status = 'Passed' + $Result = "All $Total enabled member users have a phishing-resistant method registered." + } else { + $Status = 'Failed' + $Result = "$NonCompliant of $Total enabled member users have no phishing-resistant authentication method registered (FIDO2, Windows Hello for Business, X509 cert, or device-bound passkey)." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.md new file mode 100644 index 0000000000000..d51eb2f1c84be --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.md @@ -0,0 +1,14 @@ +Number matching defeats MFA fatigue / push-bombing attacks by forcing the user to read a number from the sign-in screen and type it into the Authenticator app. Microsoft made it default in 2023, but tenants that previously customized the policy can still have it disabled or scoped narrowly. + +**Remediation Action** + +1. Entra ID > Authentication methods > Policies > **Microsoft Authenticator**. +2. Configure features > **Require number matching for push notifications** = Enabled. +3. Set the include target to **All users**. + +**Links** +- [How number matching works](https://learn.microsoft.com/en-us/azure/active-directory/authentication/how-to-mfa-number-match) +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.ps1 new file mode 100644 index 0000000000000..4902fa2ed428e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.ps1 @@ -0,0 +1,46 @@ +function Invoke-CippTestE8_MFA_09 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML3) - Number matching is enforced for Microsoft Authenticator + #> + param($Tenant) + + $TestId = 'E8_MFA_09' + $Name = 'Microsoft Authenticator number matching is enforced' + + try { + $AuthMethodsPolicy = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationMethodsPolicy' + if (-not $AuthMethodsPolicy) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthenticationMethodsPolicy cache not found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - MFA' + return + } + + $MsAuth = $AuthMethodsPolicy.authenticationMethodConfigurations | Where-Object { $_.id -eq 'MicrosoftAuthenticator' } + if (-not $MsAuth -or $MsAuth.state -ne 'enabled') { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'Microsoft Authenticator method is not enabled in the tenant.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - MFA' + return + } + + $NumberMatching = $MsAuth.featureSettings.numberMatchingRequiredState + $Issues = [System.Collections.Generic.List[string]]::new() + if ($NumberMatching.state -ne 'enabled') { + $Issues.Add("numberMatchingRequiredState.state is '$($NumberMatching.state)' (expected 'enabled').") + } + if ($NumberMatching.includeTarget.id -and $NumberMatching.includeTarget.id -ne 'all_users') { + $Issues.Add("numberMatchingRequiredState.includeTarget is '$($NumberMatching.includeTarget.id)' (expected 'all_users').") + } + + if ($Issues.Count -eq 0) { + $Status = 'Passed' + $Result = 'Microsoft Authenticator number matching is enabled and targets all users.' + } else { + $Status = 'Failed' + $Result = "Microsoft Authenticator number matching is not fully enforced:`n`n$(($Issues | ForEach-Object { "- $_" }) -join "`n")" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.md new file mode 100644 index 0000000000000..32ee5f5e7c810 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.md @@ -0,0 +1,17 @@ +At Maturity Level 3 the Essential Eight requires phishing-resistant MFA for every user, not just admins. The technical control is a tenant-wide Conditional Access policy that targets *All users* + *All cloud apps* with the built-in **Phishing-resistant MFA** authentication strength. + +**Remediation Action** + +1. Confirm test E8_MFA_08 is passing (every user has a phishing-resistant method registered). +2. Entra ID > Conditional Access > New policy. +3. Users: All users (exclude break-glass). +4. Cloud apps: All cloud apps. +5. Grant > Require authentication strength > **Phishing-resistant MFA**. +6. Enable the policy. + +**Links** +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Conditional Access authentication strengths](https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-authentication-strengths) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.ps1 new file mode 100644 index 0000000000000..53b0e061cfcbb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.ps1 @@ -0,0 +1,41 @@ +function Invoke-CippTestE8_MFA_10 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML3) - Phishing-resistant authentication strength is required for all users + #> + param($Tenant) + + $TestId = 'E8_MFA_10' + $Name = 'Phishing-resistant authentication strength is required for all users' + $PhishResistantId = '00000000-0000-0000-0000-000000000004' + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + return + } + + $Match = $CA | Where-Object { + $_.state -eq 'enabled' -and + ($_.conditions.users.includeUsers -contains 'All') -and + ($_.conditions.applications.includeApplications -contains 'All') -and + $_.grantControls.authenticationStrength -and + $_.grantControls.authenticationStrength.id -eq $PhishResistantId + } + + if ($Match) { + $Status = 'Passed' + $Result = "$($Match.Count) Conditional Access policy/policies enforce phishing-resistant MFA tenant-wide:`n`n" + + (($Match | ForEach-Object { "- $($_.displayName)" }) -join "`n") + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy enforces the *Phishing-resistant MFA* authentication strength on All Users + All Cloud Apps.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/report.json b/Modules/CIPPTests/Public/Tests/E8/report.json new file mode 100644 index 0000000000000..13f01c3c3295f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/report.json @@ -0,0 +1,74 @@ +{ + "name": "ACSC Essential Eight", + "description": "Australian Cyber Security Centre (ACSC) Essential Eight Maturity Model — eight mitigation strategies for adversary defence (MFA, Restrict Administrative Privileges, Application Control, Patch Applications, Patch Operating Systems, Configure Microsoft Office Macro Settings, User Application Hardening, Regular Backups). CIPP tests cover what Microsoft 365 / Entra / Intune / Defender APIs expose. Some lower-level enforcement controls (e.g., kernel-mode WDAC rule contents, Credential Guard runtime state, SAW physical separation) cannot be validated from cloud telemetry and are flagged as manual.", + "version": "November 2023", + "source": "https://learn.microsoft.com/en-us/compliance/anz/e8-overview", + "category": "ACSC Essential Eight", + "IdentityTests": [ + "E8_MFA_01", + "E8_MFA_02", + "E8_MFA_03", + "E8_MFA_04", + "E8_MFA_05", + "E8_MFA_06", + "E8_MFA_07", + "E8_MFA_08", + "E8_MFA_09", + "E8_MFA_10", + "E8_Admin_01", + "E8_Admin_02", + "E8_Admin_03", + "E8_Admin_04", + "E8_Admin_05", + "E8_Admin_06", + "E8_Admin_07", + "E8_Admin_08", + "E8_Admin_09", + "E8_Admin_10", + "E8_Admin_11", + "E8_Admin_12", + "E8_Admin_13", + "E8_Backup_01", + "E8_Backup_02", + "E8_Backup_03", + "E8_Backup_04" + ], + "DevicesTests": [ + "E8_AppCtrl_01", + "E8_AppCtrl_02", + "E8_AppCtrl_03", + "E8_AppCtrl_04", + "E8_AppCtrl_05", + "E8_PatchApp_01", + "E8_PatchApp_02", + "E8_PatchApp_03", + "E8_PatchApp_04", + "E8_PatchOS_01", + "E8_PatchOS_02", + "E8_PatchOS_03", + "E8_PatchOS_04", + "E8_PatchOS_05", + "E8_PatchOS_06", + "E8_Macro_01", + "E8_Macro_02", + "E8_Macro_03", + "E8_Macro_04", + "E8_Macro_05", + "E8_Macro_06", + "E8_Macro_07", + "E8_AppHard_01", + "E8_AppHard_02", + "E8_AppHard_03", + "E8_AppHard_04", + "E8_AppHard_05", + "E8_AppHard_06", + "E8_AppHard_07", + "E8_AppHard_08", + "E8_AppHard_09", + "E8_AppHard_10", + "E8_AppHard_11", + "E8_AppHard_12", + "E8_AppHard_13", + "E8_AppHard_14" + ] +} diff --git a/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 b/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 index ebcb738157ac9..3930ff44c207b 100644 --- a/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 +++ b/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 @@ -13,8 +13,9 @@ function Invoke-CippTestGenericTest011 { return } - # Load standards.json for friendly name resolution + # Load standards.json for friendly name and compliance-tag resolution $StandardsLabelMap = @{} + $StandardsTagMap = @{} $StandardsJsonPath = Join-Path $env:CIPPRootPath 'Config\standards.json' if (Test-Path $StandardsJsonPath) { $StandardsJson = Get-Content $StandardsJsonPath -Raw | ConvertFrom-Json @@ -22,6 +23,14 @@ function Invoke-CippTestGenericTest011 { if ($Std.name -and $Std.label) { $StandardsLabelMap[$Std.name] = $Std.label } + if ($Std.name -and $Std.tag) { + # Keep human-readable compliance-framework references (CIS, NIST, etc. - they + # contain spaces) and drop internal single-token tags like 'mip_search_auditlog'. + $FrameworkTags = @($Std.tag | Where-Object { $_ -is [string] -and $_ -match '\s' }) + if ($FrameworkTags.Count -gt 0) { + $StandardsTagMap[$Std.name] = ($FrameworkTags -join ', ') + } + } } } @@ -130,6 +139,26 @@ function Invoke-CippTestGenericTest011 { return $null } + # Helper: resolve a standard's compliance-framework tags (CIS/NIST/etc.). Template-based + # standards (Intune/CA/Quarantine) have no standards.json entry, so they return empty. + $ResolveTags = { + param($StandardName) + if ([string]::IsNullOrWhiteSpace($StandardName)) { return '' } + if ($StandardsTagMap.ContainsKey($StandardName)) { return $StandardsTagMap[$StandardName] } + return '' + } + + # Helper: render a stored CurrentValue/ExpectedValue (plain string, bool, or compact JSON) + # safely inside a markdown table cell - escape pipes, collapse newlines, and truncate blobs. + $FormatValue = { + param($Value) + if ($null -eq $Value -or "$Value" -eq '') { return '' } + $Text = [string]$Value + $Text = $Text -replace '\|', '\|' -replace '\r?\n', ' ' + if ($Text.Length -gt 300) { $Text = $Text.Substring(0, 297) + '...' } + return $Text + } + foreach ($Template in $AlignmentItems) { $TemplateName = $Template.StandardName $Score = $Template.AlignmentScore @@ -168,24 +197,32 @@ function Invoke-CippTestGenericTest011 { # Compliant items if ($CompliantItems.Count -gt 0) { - $null = $Result.Append("| Standard | Status |`n") - $null = $Result.Append("|----------|--------|`n") + $null = $Result.Append("#### Compliant Standards`n`n") + $null = $Result.Append("| Standard | Tags | Status |`n") + $null = $Result.Append("|----------|------|--------|`n") foreach ($Item in $CompliantItems) { $FriendlyName = & $ResolveDisplayName $Item.StandardName $TemplateSettings if (-not $FriendlyName) { continue } - $null = $Result.Append("| $FriendlyName | ✅ Compliant |`n") + $Tags = & $ResolveTags $Item.StandardName + $null = $Result.Append("| $FriendlyName | $Tags | ✅ Compliant |`n") } $null = $Result.Append("`n") } - # Non-compliant items + # Non-compliant items — include the compliance tags and the reason for failure + # (expected value from the standard vs. the current config on the tenant). if ($NonCompliantItems.Count -gt 0) { - $null = $Result.Append("| Standard | Status |`n") - $null = $Result.Append("|----------|--------|`n") + $null = $Result.Append("#### Non-Compliant Standards`n`n") + $null = $Result.Append("| Standard | Tags | Expected Value | Current Value (on tenant) |`n") + $null = $Result.Append("|----------|------|----------------|---------------------------|`n") foreach ($Item in $NonCompliantItems) { $FriendlyName = & $ResolveDisplayName $Item.StandardName $TemplateSettings if (-not $FriendlyName) { continue } - $null = $Result.Append("| $FriendlyName | ❌ Non-Compliant |`n") + $Tags = & $ResolveTags $Item.StandardName + $Expected = & $FormatValue $Item.ExpectedValue + $Current = & $FormatValue $Item.CurrentValue + if (-not $Current) { $Current = '_Not configured / no data_' } + $null = $Result.Append("| $FriendlyName | $Tags | $Expected | $Current |`n") } $null = $Result.Append("`n") } @@ -194,12 +231,13 @@ function Invoke-CippTestGenericTest011 { if ($LicenseMissingItems.Count -gt 0) { $null = $Result.Append("#### Standards Not Applied Due to Missing Licenses`n`n") $null = $Result.Append("These items are part of this baseline, but your environment does not meet the minimum required licenses for them to be applied.`n`n") - $null = $Result.Append("| Standard | Status |`n") - $null = $Result.Append("|----------|--------|`n") + $null = $Result.Append("| Standard | Tags | Status |`n") + $null = $Result.Append("|----------|------|--------|`n") foreach ($Item in $LicenseMissingItems) { $FriendlyName = & $ResolveDisplayName $Item.StandardName $TemplateSettings if (-not $FriendlyName) { continue } - $null = $Result.Append("| $FriendlyName | ⚠️ License Missing |`n") + $Tags = & $ResolveTags $Item.StandardName + $null = $Result.Append("| $FriendlyName | $Tags | ⚠️ License Missing |`n") } $null = $Result.Append("`n") } @@ -207,12 +245,13 @@ function Invoke-CippTestGenericTest011 { # Reporting disabled items if ($ReportingDisabledItems.Count -gt 0) { $null = $Result.Append("#### Standards With Reporting Disabled`n`n") - $null = $Result.Append("| Standard | Status |`n") - $null = $Result.Append("|----------|--------|`n") + $null = $Result.Append("| Standard | Tags | Status |`n") + $null = $Result.Append("|----------|------|--------|`n") foreach ($Item in $ReportingDisabledItems) { $FriendlyName = & $ResolveDisplayName $Item.StandardName $TemplateSettings if (-not $FriendlyName) { continue } - $null = $Result.Append("| $FriendlyName | ⏸️ Reporting Disabled |`n") + $Tags = & $ResolveTags $Item.StandardName + $null = $Result.Append("| $FriendlyName | $Tags | ⏸️ Reporting Disabled |`n") } $null = $Result.Append("`n") } diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21782.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21782.ps1 index 8d4ed2870e3b6..71dc891fce1b5 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21782.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21782.ps1 @@ -7,7 +7,7 @@ function Invoke-CippTestZTNA21782 { try { $UserRegistrationDetails = Get-CIPPTestData -TenantFilter $Tenant -Type 'UserRegistrationDetails' - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' if ($null -eq $UserRegistrationDetails -or $null -eq $Roles) { @@ -17,17 +17,19 @@ function Invoke-CippTestZTNA21782 { $PhishResistantMethods = @('passKeyDeviceBound', 'passKeyDeviceBoundAuthenticator', 'windowsHelloForBusiness') + # RoleAssignmentScheduleInstances.roleDefinitionId is a role template ID, so key the privileged role set by template ID. $PrivilegedRoleIds = [System.Collections.Generic.HashSet[string]]::new() $RoleNamesById = @{} - foreach ($Role in @($Roles.Where({ $_.isPrivileged -eq $true }))) { - if ($Role.id) { - [void]$PrivilegedRoleIds.Add([string]$Role.id) - $RoleNamesById[[string]$Role.id] = $Role.displayName + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { + [void]$PrivilegedRoleIds.Add($RoleTemplateId) + $RoleNamesById[$RoleTemplateId] = $Role.displayName } } $PrivilegedPrincipalsById = @{} - foreach ($Role in @($Roles.Where({ $_.isPrivileged -eq $true }))) { + foreach ($Role in @($Roles)) { foreach ($Member in @($Role.members)) { if (-not $Member.id) { continue } $principalId = [string]$Member.id diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21783.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21783.ps1 index 76ee3e4300cda..9dd3017715458 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21783.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21783.ps1 @@ -7,14 +7,15 @@ function Invoke-CippTestZTNA21783 { #tested try { $CAPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles if (-not $CAPolicies -or -not $Roles) { Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21783' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'High' -Name 'Privileged Microsoft Entra built-in roles are targeted with Conditional Access policies to enforce phishing-resistant methods' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Access Control' return } - $PrivilegedRoles = $Roles | Where-Object { $_.isPrivileged -and $_.isBuiltIn } + # Get-CippDbRole -IncludePrivilegedRoles already returns the privileged built-in directory roles. + $PrivilegedRoles = @($Roles) if (-not $PrivilegedRoles) { Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21783' -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No privileged built-in roles found in tenant' -Risk 'High' -Name 'Privileged Microsoft Entra built-in roles are targeted with Conditional Access policies to enforce phishing-resistant methods' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Access Control' @@ -31,7 +32,11 @@ function Invoke-CippTestZTNA21783 { $CoveredRoleIds = $PhishResistantPolicies.conditions.users.includeRoles | Select-Object -Unique - $UnprotectedRoles = $PrivilegedRoles | Where-Object { $_.id -notin $CoveredRoleIds } + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $UnprotectedRoles = $PrivilegedRoles | Where-Object { + $Tid = if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } else { $null } + $Tid -notin $CoveredRoleIds + } if ($UnprotectedRoles.Count -eq 0) { $Status = 'Passed' diff --git a/Modules/CippExtensions/Public/Extension Functions/Get-ExtensionAPIKey.ps1 b/Modules/CippExtensions/Public/Extension Functions/Get-ExtensionAPIKey.ps1 index 561893aee457f..ccf25d46bf996 100644 --- a/Modules/CippExtensions/Public/Extension Functions/Get-ExtensionAPIKey.ps1 +++ b/Modules/CippExtensions/Public/Extension Functions/Get-ExtensionAPIKey.ps1 @@ -20,7 +20,7 @@ function Get-ExtensionAPIKey { $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' $APIKey = (Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq '$Extension' and RowKey eq '$Extension'").APIKey } else { - $keyvaultname = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $keyvaultname = Get-CippKeyVaultName $APIKey = (Get-CippKeyVaultSecret -VaultName $keyvaultname -Name $Extension -AsPlainText) } Set-Item -Path "env:$Var" -Value $APIKey -Force -ErrorAction SilentlyContinue diff --git a/Modules/CippExtensions/Public/Extension Functions/Remove-ExtensionAPIKey.ps1 b/Modules/CippExtensions/Public/Extension Functions/Remove-ExtensionAPIKey.ps1 index 3e42100a218fe..804dd8e69994d 100644 --- a/Modules/CippExtensions/Public/Extension Functions/Remove-ExtensionAPIKey.ps1 +++ b/Modules/CippExtensions/Public/Extension Functions/Remove-ExtensionAPIKey.ps1 @@ -19,7 +19,7 @@ function Remove-ExtensionAPIKey { Write-Information "No existing DevSecrets row found for '$Extension' to delete." } } else { - $keyvaultname = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $keyvaultname = Get-CippKeyVaultName try { $null = Remove-CippKeyVaultSecret -VaultName $keyvaultname -Name $Extension } catch { diff --git a/Modules/CippExtensions/Public/Extension Functions/Set-ExtensionAPIKey.ps1 b/Modules/CippExtensions/Public/Extension Functions/Set-ExtensionAPIKey.ps1 index f8b0975bc9639..c7ab471f0df77 100644 --- a/Modules/CippExtensions/Public/Extension Functions/Set-ExtensionAPIKey.ps1 +++ b/Modules/CippExtensions/Public/Extension Functions/Set-ExtensionAPIKey.ps1 @@ -23,7 +23,7 @@ function Set-ExtensionAPIKey { } Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force } else { - $keyvaultname = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $keyvaultname = Get-CippKeyVaultName $null = Set-CippKeyVaultSecret -VaultName $keyvaultname -Name $Extension -SecretValue (ConvertTo-SecureString -AsPlainText -Force -String $APIKey) } Set-Item -Path "env:$Var" -Value $APIKey -Force -ErrorAction SilentlyContinue diff --git a/Modules/CippExtensions/Public/Gradient/Get-GradientToken.ps1 b/Modules/CippExtensions/Public/Gradient/Get-GradientToken.ps1 index fc9968070e66c..c7cdb3703b89e 100644 --- a/Modules/CippExtensions/Public/Gradient/Get-GradientToken.ps1 +++ b/Modules/CippExtensions/Public/Gradient/Get-GradientToken.ps1 @@ -3,7 +3,7 @@ function Get-GradientToken { $Configuration ) if ($Configuration.vendorKey) { - $keyvaultname = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $keyvaultname = Get-CippKeyVaultName $partnerApiKey = (Get-CippKeyVaultSecret -VaultName $keyvaultname -Name 'Gradient' -AsPlainText) $authorizationToken = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("$($configuration.vendorKey):$($partnerApiKey)")) diff --git a/Modules/CippExtensions/Public/HIBP/Get-HIBPAuth.ps1 b/Modules/CippExtensions/Public/HIBP/Get-HIBPAuth.ps1 index 365b387e09fc2..4af70cb4e57b8 100644 --- a/Modules/CippExtensions/Public/HIBP/Get-HIBPAuth.ps1 +++ b/Modules/CippExtensions/Public/HIBP/Get-HIBPAuth.ps1 @@ -9,7 +9,7 @@ function Get-HIBPAuth { $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' $Secret = (Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'HIBP' and RowKey eq 'HIBP'").APIKey } else { - $VaultName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $VaultName = Get-CippKeyVaultName try { $Secret = Get-CippKeyVaultSecret -VaultName $VaultName -Name 'HIBP' -AsPlainText -ErrorAction Stop } catch { diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 new file mode 100644 index 0000000000000..cc2df9f62cb99 --- /dev/null +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -0,0 +1,125 @@ +function Get-HaloUser { + <# + .SYNOPSIS + Look up a HaloPSA user/contact for a Microsoft 365 end-user. + .DESCRIPTION + Searches the HaloPSA /Users endpoint scoped to a specific client. Matches first by Azure + Object ID (against the HaloPSA contact fields 'azureoid' and 'aaduserid'), then falls back + to the user's email/UPN (against 'emailaddress', 'networklogin' and 'aaduserid' - Halo's + AD-sync contacts often store the UPN in any of these). Returns a small object containing + the matched user's id and site_id (Halo requires both when a specific user is set on a + ticket), or $null when no match is found. + .PARAMETER AzureOID + The Microsoft Entra (Azure AD) Object ID of the user to match. Preferred when present. + .PARAMETER Email + The user's email address (typically the UPN). Used as a fallback when AzureOID is missing + or returns no match. + .PARAMETER ClientId + The HaloPSA client id to scope the search to. + .PARAMETER Configuration + The HaloPSA extension configuration object (already extracted from Extensionsconfig). + .PARAMETER Token + A valid Halo OAuth token object as returned by Get-HaloToken. + #> + [CmdletBinding()] + param ( + [string]$AzureOID, + [string]$Email, + [Parameter(Mandatory = $true)] + $ClientId, + [Parameter(Mandatory = $true)] + $Configuration, + [Parameter(Mandatory = $true)] + $Token + ) + + $Headers = @{ Authorization = "Bearer $($Token.access_token)" } + $BaseUri = "$($Configuration.ResourceURL)/Users?client_id=$ClientId&includeinactive=false&pageinate=false" + + $BuildResult = { + param($MatchedUser) + # Cast to [int] so PowerShell's default [double] deserialisation of JSON numbers + # doesn't serialise back as e.g. "95.0", which Halo rejects. + [pscustomobject]@{ + id = [int]$MatchedUser.id + site_id = [int]$MatchedUser.site_id + } + } + + # Halo's basic ?search= parameter only searches a fixed set of indexed fields (name, email, + # logins...) and notably NOT azureoid. Use ?advanced_search= with filter_type=2 (=) for an + # exact-match query against a specific field. + $TryAdvancedSearch = { + param($FilterName, $FilterValue) + try { + $Filter = ConvertTo-Json -Compress -InputObject @(@{ + filter_name = $FilterName + filter_type = 2 # 2 = exact equality + filter_value = $FilterValue + }) + $EncodedFilter = [System.Uri]::EscapeDataString($Filter) + $Response = Invoke-RestMethod -Uri "$BaseUri&advanced_search=$EncodedFilter" -ContentType 'application/json' -Method GET -Headers $Headers + if ($Response.users) { return $Response.users } + return $Response + } catch { + $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message } else { $_.Exception.Message } + # Some Halo instances don't whitelist these fields for advanced_search even though they + # exist on the user record. That's expected - the email-search fallback handles it. Only + # log unexpected failures. + if ($Message -notmatch 'Invalid advanced search parameter') { + Write-LogMessage -API 'HaloPSATicket' -message "Halo advanced_search failed for $FilterName='$FilterValue' in client ${ClientId}: $Message" -sev Warning + } + return @() + } + } + + $TrySearch = { + param($Term) + try { + $EncodedTerm = [System.Uri]::EscapeDataString($Term) + $Response = Invoke-RestMethod -Uri "$BaseUri&search=$EncodedTerm" -ContentType 'application/json' -Method GET -Headers $Headers + if ($Response.users) { return $Response.users } + return $Response + } catch { + $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message } else { $_.Exception.Message } + Write-LogMessage -API 'HaloPSATicket' -message "Halo user search failed for term '$Term' in client ${ClientId}: $Message" -sev Warning + return @() + } + } + + # HaloPSA contacts can carry the user identity in several fields depending on how AD/Azure AD + # sync is set up. Match against all known candidates so partial integrations still resolve. + $AzureIdFields = @('azureoid', 'aaduserid') + $EmailFields = @('emailaddress', 'networklogin', 'aaduserid') + + # Try AzureOID first via advanced_search - exact-match on each AD identifier field, returning + # the first hit. This is the most reliable path because Halo's azureoid field is the cleanest + # link back to the Entra user. + if ($AzureOID) { + foreach ($Field in $AzureIdFields) { + $Match = (& $TryAdvancedSearch $Field $AzureOID) | Where-Object { $_.id } | Select-Object -First 1 + if ($Match) { return & $BuildResult $Match } + } + } + + # Fall back to email: the basic search indexes email-shaped fields and returns candidates; + # filter client-side against any of the email-bearing fields, and also re-check the AzureOID + # against returned records (handy when a contact has azureoid set but blank email fields). + if ($Email) { + $Results = & $TrySearch $Email + foreach ($User in $Results) { + if ($AzureOID) { + foreach ($Field in $AzureIdFields) { + $Value = $User.$Field + if ($Value -and ($Value -ieq $AzureOID)) { return & $BuildResult $User } + } + } + foreach ($Field in $EmailFields) { + $Value = $User.$Field + if ($Value -and ($Value -ieq $Email)) { return & $BuildResult $User } + } + } + } + + return $null +} diff --git a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 index 0249248c88ec6..e2eb1eaf60743 100644 --- a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 +++ b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 @@ -3,15 +3,39 @@ function New-HaloPSATicket { param ( $title, $description, - $client + $client, + [string]$UserUPN, + [string]$AzureOID, + [string]$DisplayName ) #Get HaloPSA Token based on the config we have. $Table = Get-CIPPTable -TableName Extensionsconfig $Configuration = ((Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json).HaloPSA $TicketTable = Get-CIPPTable -TableName 'PSATickets' $token = Get-HaloToken -configuration $Configuration - # sha hash title - $TitleHash = Get-StringHash -String $title + + # Resolve affected user to a HaloPSA contact when the integration is configured for it. + # Unmatched users fall through to userlookup.id = -1 (the client's General User contact). + $MatchedUser = $null + $UserLinkActive = $Configuration.LinkTicketsToUsers -and ($UserUPN -or $AzureOID) + if ($UserLinkActive) { + $MatchedUser = Get-HaloUser -AzureOID $AzureOID -Email $UserUPN -ClientId $client -Configuration $Configuration -Token $token + if (-not $MatchedUser) { + $UnmatchedLabel = if ($DisplayName) { "$DisplayName ($UserUPN)" } else { $UserUPN } + Write-LogMessage -API 'HaloPSATicket' -message "No HaloPSA contact match for $UserUPN in client $client - falling back to General User" -sev Warning + $description = "$description

Affected user: $UnmatchedLabel - no matching HaloPSA contact found, ticket assigned to General User.

" + } + } + + # When linking is active, include UPN in the consolidation key so per-user tickets don't + # collapse onto each other when the same alert title fires for multiple users. + $HashInput = if ($UserLinkActive -and $UserUPN) { "$title|$UserUPN" } else { $title } + $TitleHash = Get-StringHash -String $HashInput + + # Halo requires a site_id whenever a specific user is set on the ticket; pull it from the + # matched user record. When no user is matched, leave site_id null and let Halo resolve it + # from the General User (id = -1). + $SiteId = if ($MatchedUser) { $MatchedUser.site_id } else { $null } if ($Configuration.ConsolidateTickets) { $ExistingTicket = Get-CIPPAzDataTableEntity @TicketTable -Filter "PartitionKey eq 'HaloPSA' and RowKey eq '$($client)-$($TitleHash)'" @@ -35,12 +59,13 @@ function New-HaloPSATicket { } $body = ConvertTo-Json -Compress -Depth 10 -InputObject @($Object) + $NoteAdded = $false try { if ($PSCmdlet.ShouldProcess('Add note to HaloPSA ticket', 'Add note')) { $Action = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/actions" -ContentType 'application/json; charset=utf-8' -Method Post -Body $body -Headers @{Authorization = "Bearer $($token.access_token)" } Write-Information "Note added to ticket in HaloPSA: $($ExistingTicket.TicketID)" + $NoteAdded = $true } - return "Note added to ticket in HaloPSA: $($ExistingTicket.TicketID)" } catch { $Message = if ($_.ErrorDetails.Message) { @@ -49,10 +74,15 @@ function New-HaloPSATicket { else { $_.Exception.message } - Write-LogMessage -message "Failed to add note to HaloPSA ticket: $Message" -API 'HaloPSATicket' -sev Error -LogData (Get-CippException -Exception $_) - Write-Information "Failed to add note to HaloPSA ticket: $Message" + # Don't return here - if appending a note failed (e.g. permissions on the action, + # invalid outcome_id) we still want to create a fresh ticket so the alert isn't lost. + Write-LogMessage -message "Failed to add note to HaloPSA ticket $($ExistingTicket.TicketID): $Message - falling back to creating a new ticket" -API 'HaloPSATicket' -sev Warning -LogData (Get-CippException -Exception $_) + Write-Information "Failed to add note to HaloPSA ticket: $Message; creating a new ticket instead" Write-Information "Body we tried to ship: $body" - return "Failed to add note to HaloPSA ticket: $Message" + } + + if ($NoteAdded) { + return "Note added to ticket in HaloPSA: $($ExistingTicket.TicketID)" } } } @@ -62,17 +92,29 @@ function New-HaloPSATicket { } } + $UserLookupId = if ($MatchedUser) { $MatchedUser.id } else { -1 } + $UserLookupDisplay = if ($MatchedUser) { + if ($DisplayName) { $DisplayName } else { $UserUPN } + } else { + 'Enter Details Manually' + } + $UserNameValue = if ($MatchedUser) { + if ($DisplayName) { $DisplayName } else { $UserUPN } + } else { + $null + } + $Object = [PSCustomObject]@{ files = $null usertype = 1 userlookup = @{ - id = -1 - lookupdisplay = 'Enter Details Manually' + id = $UserLookupId + lookupdisplay = $UserLookupDisplay } - client_id = ($client | Select-Object -Last 1) + client_id = [int]($client | Select-Object -Last 1) _forcereassign = $true - site_id = $null - user_name = $null + site_id = $SiteId + user_name = $UserNameValue reportedby = $null summary = $title details_html = $description diff --git a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 index f184e81f493e1..c0ad986868841 100644 --- a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 +++ b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 @@ -8,7 +8,7 @@ function Invoke-HuduExtensionSync { $TenantFilter ) try { - Connect-HuduAPI -configuration $Configuration + Connect-HuduAPI -configuration $Configuration | Out-Null $Configuration = $Configuration.Hudu $Tenant = Get-Tenants -TenantFilter $TenantFilter -IncludeErrors $CompanyResult = [PSCustomObject]@{ @@ -32,6 +32,12 @@ function Invoke-HuduExtensionSync { # Get Asset cache $HuduAssetCache = Get-CippTable -tablename 'CacheHuduAssets' + # Get Relations cache - Hudu's relations API has no per-company filter, so this is cached + # globally and shared across every tenant's sync instead of pulling the full relations + # table (30s+ on large instances) on every single sync run. + $HuduRelationsCache = Get-CippTable -tablename 'CacheHuduRelations' + $HuduRelationsCacheTTLMinutes = 15 + # Import license mapping $LicTable = [System.IO.File]::ReadAllText((Join-Path $env:CIPPRootPath 'Config\ConversionTable.csv')) | ConvertFrom-Csv @@ -47,6 +53,18 @@ function Invoke-HuduExtensionSync { # Include mailboxes if needed for Hudu sync $ExtensionCache = Get-CippExtensionReportingData -TenantFilter $Tenant.defaultDomainName -IncludeMailboxes $company_id = $TenantMap.IntegrationId + $HuduCompany = Get-HuduCompanies -Id $company_id + if ($HuduCompany.archived -eq $true) { + Write-Host "Company $($HuduCompany.name) is archived. Skipping sync." + $ReturnObject = [PSCustomObject]@{ + Name = $Tenant.displayName + Users = 0 + Devices = 0 + Errors = [System.Collections.Generic.List[string]]@("Company $($HuduCompany.name) is archived. Skipping sync.") + Logs = [System.Collections.Generic.List[string]]@("Company $($HuduCompany.name) is archived. Skipping sync.") + } + return $ReturnObject + } # If tenant not found in mapping table, return error if (!$TenantMap) { @@ -120,8 +138,50 @@ function Invoke-HuduExtensionSync { $ExcludeSerials = $DefaultSerials } - $HuduRelations = Get-HuduRelations - [System.Collections.ArrayList]$Links = @( + $RelationsCacheMeta = Get-CIPPAzDataTableEntity @HuduRelationsCache -Filter "PartitionKey eq 'CacheMetadata' and RowKey eq 'LastRefresh'" + $RelationsCacheAgeMinutes = if ($RelationsCacheMeta.LastRefresh) { ((Get-Date).ToUniversalTime() - [datetime]$RelationsCacheMeta.LastRefresh).TotalMinutes } else { $null } + + if ($null -ne $RelationsCacheAgeMinutes -and $RelationsCacheAgeMinutes -lt $HuduRelationsCacheTTLMinutes) { + $CachedRelationRows = Get-CIPPAzDataTableEntity @HuduRelationsCache -Filter "PartitionKey eq 'HuduRelation'" + $HuduRelations = foreach ($CachedRelationRow in $CachedRelationRows) { + [PSCustomObject]@{ + id = $CachedRelationRow.RowKey + fromable_type = $CachedRelationRow.FromableType + fromable_id = $CachedRelationRow.FromableId + toable_type = $CachedRelationRow.ToableType + toable_id = $CachedRelationRow.ToableId + } + } + } else { + $HuduRelations = Get-HuduRelations + + $ExistingRelationRows = Get-CIPPAzDataTableEntity @HuduRelationsCache -Filter "PartitionKey eq 'HuduRelation'" + if ($ExistingRelationRows) { + Remove-AzDataTableEntity @HuduRelationsCache -Entity $ExistingRelationRows -Force + } + + $RelationEntities = foreach ($Relation in $HuduRelations) { + [PSCustomObject]@{ + PartitionKey = 'HuduRelation' + RowKey = [string]$Relation.id + FromableType = [string]$Relation.fromable_type + FromableId = [string]$Relation.fromable_id + ToableType = [string]$Relation.toable_type + ToableId = [string]$Relation.toable_id + } + } + if ($RelationEntities) { + Add-CIPPAzDataTableEntity @HuduRelationsCache -Entity $RelationEntities -Force + } + + $RelationsCacheMetaEntity = [PSCustomObject]@{ + PartitionKey = 'CacheMetadata' + RowKey = 'LastRefresh' + LastRefresh = (Get-Date).ToUniversalTime().ToString('o') + } + Add-CIPPAzDataTableEntity @HuduRelationsCache -Entity $RelationsCacheMetaEntity -Force + } + [System.Collections.Generic.List[object]]$Links = @( @{ Title = 'M365 Admin Portal' URL = 'https://admin.cloud.microsoft?delegatedOrg={0}' -f $Tenant.initialDomainName @@ -153,29 +213,26 @@ function Invoke-HuduExtensionSync { Icon = 'fas fa-server' } ) - if($Configuration.IncludeDefenderLink) - { + if ($Configuration.IncludeDefenderLink) { $Links.Add(@{ - Title = 'Defender Portal' - URL = 'https://security.microsoft.com/?tid={0}' -f $Tenant.customerId - Icon = 'fas fa-shield' - }) + Title = 'Defender Portal' + URL = 'https://security.microsoft.com/?tid={0}' -f $Tenant.customerId + Icon = 'fas fa-shield' + }) } - if($Configuration.IncludeComplianceLink) - { + if ($Configuration.IncludeComplianceLink) { $Links.Add(@{ - Title = 'Compliance Portal' - URL = 'https://compliance.microsoft.com/?tid={0}' -f $Tenant.customerId - Icon = 'fas fa-caret-up' - }) + Title = 'Compliance Portal' + URL = 'https://compliance.microsoft.com/?tid={0}' -f $Tenant.customerId + Icon = 'fas fa-caret-up' + }) } - if($Configuration.IncludeParterCenterLink) - { + if ($Configuration.IncludeParterCenterLink) { $Links.Add(@{ - Title = 'Partner Center Portals' - URL = 'https://partner.microsoft.com/dashboard/v2/customers/{0}/servicemanagementpage' -f $Tenant.customerId - Icon = 'fas fa-arrow-up-right-from-square' - }) + Title = 'Partner Center Portals' + URL = 'https://partner.microsoft.com/dashboard/v2/customers/{0}/servicemanagementpage' -f $Tenant.customerId + Icon = 'fas fa-arrow-up-right-from-square' + }) } $FormattedLinks = foreach ($Link in $Links) { @@ -209,7 +266,7 @@ function Invoke-HuduExtensionSync { $post = '' - if($Configuration.HideEmptyRoles) { + if ($Configuration.HideEmptyRoles) { $Roles = $Roles | Where-Object { $_.ParsedMembers } } diff --git a/Modules/CippExtensions/Public/New-CippExtAlert.ps1 b/Modules/CippExtensions/Public/New-CippExtAlert.ps1 index 303eaf4803767..afd594eeff913 100644 --- a/Modules/CippExtensions/Public/New-CippExtAlert.ps1 +++ b/Modules/CippExtensions/Public/New-CippExtAlert.ps1 @@ -20,7 +20,37 @@ function New-CippExtAlert { Write-Host "MappedId: $MappedId" if (!$mappedId) { $MappedId = 1 } Write-Host "MappedId: $MappedId" - New-HaloPSATicket -Title $Alert.AlertTitle -Description $Alert.AlertText -Client $mappedId + + $TicketParams = @{ + Title = $Alert.AlertTitle + Description = $Alert.AlertText + Client = $MappedId + } + + if ($Alert.AffectedUser -and $Configuration.HaloPSA.LinkTicketsToUsers) { + $UPN = $Alert.AffectedUser.UPN + $OID = $Alert.AffectedUser.AzureOID + $Display = $Alert.AffectedUser.DisplayName + + # Best-effort: resolve UPN -> Azure Object ID via Graph if we don't already have it. + # Failure here is non-fatal; Get-HaloUser will still try the email-based lookup. + if (-not $OID -and $UPN -and $Alert.TenantId) { + try { + $EncodedUPN = [System.Uri]::EscapeDataString($UPN) + $GraphUser = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$EncodedUPN`?`$select=id,displayName,userPrincipalName" -tenantid $Alert.TenantId -AsApp $true + if ($GraphUser.id) { $OID = $GraphUser.id } + if (-not $Display -and $GraphUser.displayName) { $Display = $GraphUser.displayName } + } catch { + Write-Information "Could not resolve Graph user for $UPN in tenant $($Alert.TenantId): $($_.Exception.Message)" + } + } + + if ($UPN) { $TicketParams.UserUPN = $UPN } + if ($OID) { $TicketParams.AzureOID = $OID } + if ($Display) { $TicketParams.DisplayName = $Display } + } + + New-HaloPSATicket @TicketParams } } 'Gradient' { diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneExtensionScheduler.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneExtensionScheduler.ps1 index 7dd47505a39c7..0c160259c92ee 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneExtensionScheduler.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneExtensionScheduler.ps1 @@ -42,6 +42,15 @@ function Invoke-NinjaOneExtensionScheduler { 'FunctionName' = 'NinjaOneQueue' } } + + $CveBatch = foreach ($Tenant in $TenantsToProcess) { + [PSCustomObject]@{ + 'NinjaAction' = 'CveSyncTenant' + 'MappedTenant' = $Tenant + 'FunctionName' = 'NinjaOneQueue' + } + } + if (($Batch | Measure-Object).Count -gt 0) { $InputObject = [PSCustomObject]@{ OrchestratorName = 'NinjaOneOrchestrator' @@ -52,6 +61,15 @@ function Invoke-NinjaOneExtensionScheduler { Write-Host "Started permissions orchestration with ID = '$InstanceId'" } + if (($CveBatch | Measure-Object).Count -gt 0) { + $CveInputObject = [PSCustomObject]@{ + OrchestratorName = 'NinjaOneOrchestrator' + Batch = @($CveBatch) + } + $CveInstanceId = Start-CIPPOrchestrator -InputObject $CveInputObject + Write-Host "Started CVE sync orchestration with ID = '$CveInstanceId'" + } + $AddObject = @{ PartitionKey = 'NinjaConfig' RowKey = 'NinjaLastRunTime' @@ -59,7 +77,7 @@ function Invoke-NinjaOneExtensionScheduler { } Add-AzDataTableEntity @Table -Entity $AddObject -Force - Write-LogMessage -API 'NinjaOneSync' -message "NinjaOne Daily Synchronization Queued for $(($TenantsToProcess | Measure-Object).count) Tenants" -Sev 'Info' + Write-LogMessage -API 'NinjaOneSync' -message "NinjaOne Daily Synchronization Queued for $(($TenantsToProcess | Measure-Object).count) Tenants" -Sev 'Info' } else { if ($LastRunTime -lt (Get-Date).AddMinutes(-90)) { @@ -95,7 +113,7 @@ function Invoke-NinjaOneExtensionScheduler { } if (($CatchupTenants | Measure-Object).count -gt 0) { - Write-LogMessage -API 'NinjaOneSync' -message "NinjaOne Synchronization Catchup Queued for $(($CatchupTenants | Measure-Object).count) Tenants" -Sev 'Info' + Write-LogMessage -API 'NinjaOneSync' -message "NinjaOne Synchronization Catchup Queued for $(($CatchupTenants | Measure-Object).count) Tenants" -Sev 'Info' } } diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index a4200a6a7f445..0a79c0e349a2d 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -1846,6 +1846,10 @@ function Invoke-NinjaOneTenantSync { ### CIPP Applied Standards Cards + $ModuleBase = Get-Module CIPPExtensions | Select-Object -ExpandProperty ModuleBase + $CIPPRoot = (Get-Item $ModuleBase).Parent.Parent.FullName + Set-Location $CIPPRoot + try { $StandardsDefinitions = Invoke-RestMethod -Uri 'https://raw.githubusercontent.com/KelvinTegelaar/CIPP/refs/heads/main/src/data/standards.json' $AppliedStandards = Get-CIPPStandards -TenantFilter $Customer.defaultDomainName @@ -2175,6 +2179,95 @@ function Invoke-NinjaOneTenantSync { $Result = Invoke-WebRequest -Uri "https://$($Configuration.Instance)/api/v2/organization/$($MappedTenant.IntegrationId)/custom-fields" -Method PATCH -Headers @{Authorization = "Bearer $($token.access_token)" } -ContentType 'application/json; charset=utf-8' -Body ($NinjaOrgUpdate | ConvertTo-Json -Depth 100) + + # CVE Sync — runs as part of tenant sync if enabled + if ($Configuration.CveSyncEnabled -eq $true) { + try { + $ScanGroupPrefix = $Configuration.CveSyncPrefix ?? '' + $ScanGroupName = "$ScanGroupPrefix$TenantFilter" + $NinjaBaseUrl = "https://$($Configuration.Instance)/api/v2" + + $CveScanGroups = Invoke-RestMethod -Method Get -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers @{ Authorization = "Bearer $($Token.access_token)" } -TimeoutSec 30 -ErrorAction Stop + $ResolvedScanGroup = $CveScanGroups | Where-Object { $_.groupName -eq $ScanGroupName } + + if (-not $ResolvedScanGroup) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync skipped — scan group '$ScanGroupName' not found" -sev 'Warning' + } else { + $ResolvedScanGroupId = $ResolvedScanGroup.id + $DeviceIdHeader = $ResolvedScanGroup.deviceIdHeader + $CveIdHeader = $ResolvedScanGroup.cveIdHeader + + $RawVulns = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'DefenderCVEs' | Where-Object { $_.RowKey -ne 'DefenderCVEs-Count' } + $AllVulns = $RawVulns.Data | ConvertFrom-Json + $CsvRows = [System.Collections.Generic.List[object]]::new() + + if (-not $AllVulns) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message 'CVE sync — no vulnerability data returned' -sev 'Warning' + [void]$CsvRows.Add([PSCustomObject]@{ + $DeviceIdHeader = "" + $CveIdHeader = ""}) + } else { + $ExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' + $AllExceptions = Get-CIPPAzDataTableEntity @ExceptionsTable + $ApplicableExceptions = $AllExceptions | Where-Object { $_.RowKey -eq $TenantFilter -or $_.RowKey -eq 'ALL' } + + if ($ApplicableExceptions) { + $ExceptedCveIds = $ApplicableExceptions | Select-Object -ExpandProperty cveId -Unique + $BeforeCount = $AllVulns.Count + $AllVulns = $AllVulns | Where-Object { $_.cveId -notin $ExceptedCveIds } + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — filtered $($BeforeCount - $AllVulns.Count) excepted CVEs, $($AllVulns.Count) remaining" -sev 'Info' + } + + $SkippedCount = 0 + + foreach ($Item in $AllVulns) { + if ([string]::IsNullOrWhiteSpace($Item.cveId)) { + $SkippedCount++ + continue + } + if ($Item.deviceDetailsJson) { + $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique + foreach ($Dev in $Devices) { + [void]$CsvRows.Add([PSCustomObject]@{ + $DeviceIdHeader = $Dev.deviceName.Trim() + $CveIdHeader = $Item.cveId.Trim() + }) + } + } + } + + if ($SkippedCount -gt 0) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — skipped $SkippedCount rows (missing deviceName or cveId)" -sev 'Warning' + } + } + $CsvBytes = New-VulnCsvBytes -TenantFilter $TenantFilter -Rows $CsvRows -Headers @($DeviceIdHeader, $CveIdHeader) + + if ($CsvBytes -and $CsvBytes.Length -gt 0) { + $UploadUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId/upload" + $PollUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId" + $CveResp = Invoke-NinjaOneVulnCsvUpload -Uri $UploadUri -PollUri $PollUri -CsvBytes $CsvBytes -Headers @{ Authorization = "Bearer $($Token.access_token)" } + + $FinalStatus = $CveResp.status ?? 'unknown' + $ProcessedCount = $CveResp.recordsProcessed ?? '?' + + if ($FinalStatus -eq 'COMPLETE') { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync complete — $($CsvRows.Count) CVEs sent to '$ScanGroupName', $ProcessedCount processed" -sev 'Info' + } elseif ($FinalStatus -eq 'IN_PROGRESS') { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync upload accepted — $($CsvRows.Count) CVEs sent to '$ScanGroupName', still processing (timed out polling)" -sev 'Warning' + } else { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync finished with status '$FinalStatus' for '$ScanGroupName', $ProcessedCount processed" -sev 'Warning' + } + } else { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message 'CVE sync — failed to generate CSV bytes' -sev 'Warning' + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + # Do not rethrow — CVE sync failure should not fail the whole tenant sync + } + } + Write-Information 'Cleaning Users Cache' if (($ParsedUsers | Measure-Object).count -gt 0) { Remove-AzDataTableEntity -Force @UsersTable -Entity ($ParsedUsers | Select-Object PartitionKey, RowKey) diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneVulnCsvUpload.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneVulnCsvUpload.ps1 new file mode 100644 index 0000000000000..f6c65c9788d67 --- /dev/null +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneVulnCsvUpload.ps1 @@ -0,0 +1,126 @@ +function Invoke-NinjaOneVulnCsvUpload { + <# + .SYNOPSIS + Upload CVE CSV to NinjaOne vulnerability scan group via multipart POST, + then poll until processing completes. Retries the full upload+poll cycle + on transient failures or a FAILED processing status. + .PARAMETER Uri + Full NinjaOne API upload URI including scan group ID. + .PARAMETER PollUri + NinjaOne API URI for the scan group (GET) used to poll processing status. + .PARAMETER CsvBytes + UTF-8 encoded CSV payload as a byte array. + .PARAMETER Headers + Hashtable of HTTP headers including Authorization bearer token. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Uri, + [Parameter(Mandatory)][string]$PollUri, + [Parameter(Mandatory)][byte[]]$CsvBytes, + [Parameter(Mandatory)][hashtable]$Headers + ) + + $Boundary = [System.Guid]::NewGuid().ToString() + $LF = "`r`n" + $MaxRetries = 5 + $RetryDelay = 5 + $PollDelay = 10 + $MaxPolls = 18 + $Attempt = 0 + + $BodyLines = @( + "--$Boundary" + 'Content-Disposition: form-data; name="csv"; filename="cve.csv"' + 'Content-Type: text/csv' + '' + ) + + $HeaderText = $BodyLines -join $LF + $HeaderBytes = [System.Text.Encoding]::UTF8.GetBytes($HeaderText + $LF) + + $TrailerText = "$LF--$Boundary--$LF" + $TrailerBytes = [System.Text.Encoding]::UTF8.GetBytes($TrailerText) + + while ($Attempt -le $MaxRetries) { + $Mem = [System.IO.MemoryStream]::new() + try { + $Mem.Write($HeaderBytes, 0, $HeaderBytes.Length) + $Mem.Write($CsvBytes, 0, $CsvBytes.Length) + $Mem.Write($TrailerBytes, 0, $TrailerBytes.Length) + $Mem.Position = 0 + + if ($Attempt -eq 0) { + Write-LogMessage -API 'NinjaOne' -message "Uploading CVE CSV to NinjaOne ($($CsvBytes.Length) bytes)" -sev 'Debug' + } else { + Write-LogMessage -API 'NinjaOne' -message "Retrying CVE CSV upload (attempt $Attempt of $MaxRetries)" -sev 'Warning' + } + + $Resp = Invoke-RestMethod -Method POST -Uri $Uri ` + -Headers $Headers ` + -ContentType "multipart/form-data; boundary=$Boundary" ` + -Body $Mem ` + -ErrorAction Stop + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + + # Do not retry on 404 — scan group not found is a config issue, not transient + if ($_.Exception.Response.StatusCode.value__ -eq 404) { + Write-LogMessage -API 'NinjaOne' -message "CSV upload failed (404 — scan group not found, not retrying): $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + throw + } + + if ($Attempt -lt $MaxRetries) { + Write-LogMessage -API 'NinjaOne' -message "CSV upload failed (attempt $Attempt of $MaxRetries), retrying in ${RetryDelay}s: $($ErrorMessage.NormalizedError)" -sev 'Warning' -LogData $ErrorMessage + Start-Sleep -Seconds $RetryDelay + $Attempt++ + continue + } else { + Write-LogMessage -API 'NinjaOne' -message "CSV upload failed after $MaxRetries retries: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + throw + } + } finally { + $Mem.Dispose() + } + + # Upload accepted — poll until no longer IN_PROGRESS + if ($Resp.status -eq 'IN_PROGRESS') { + Write-LogMessage -API 'NinjaOne' -message "Upload accepted, polling for completion (max $($MaxPolls * $PollDelay)s)" -sev 'Debug' + + $PollCount = 0 + while ($Resp.status -eq 'IN_PROGRESS' -and $PollCount -lt $MaxPolls) { + Start-Sleep -Seconds $PollDelay + $PollCount++ + try { + $Resp = Invoke-RestMethod -Method Get -Uri $PollUri -Headers $Headers -ErrorAction Stop + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'NinjaOne' -message "Poll failed on attempt $PollCount — will retry: $($ErrorMessage.NormalizedError)" -sev 'Warning' -LogData $ErrorMessage + } + } + + if ($Resp.status -eq 'IN_PROGRESS') { + # Timed out waiting — upload succeeded but processing is still running + Write-LogMessage -API 'NinjaOne' -message "Polling timed out after $($MaxPolls * $PollDelay)s — upload accepted by NinjaOne but processing status unknown" -sev 'Warning' + return $Resp + } + } + + # FAILED status — treat as retryable + if ($Resp.status -eq 'FAILED') { + if ($Attempt -lt $MaxRetries) { + Write-LogMessage -API 'NinjaOne' -message "NinjaOne returned FAILED status (attempt $Attempt of $MaxRetries), retrying in ${RetryDelay}s" -sev 'Warning' + Start-Sleep -Seconds $RetryDelay + $Attempt++ + continue + } else { + Write-LogMessage -API 'NinjaOne' -message "NinjaOne returned FAILED status after $MaxRetries retries — giving up" -sev 'Error' + return $Resp + } + } + + # COMPLETE or any other terminal status — return + return $Resp + } +} diff --git a/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 b/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 index 470220016842b..feff03bc7a207 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 @@ -7,9 +7,9 @@ function Push-NinjaOneQueue { Switch ($Item.NinjaAction) { 'StartAutoMapping' { Invoke-NinjaOneOrgMapping } - 'AutoMapTenant' { Invoke-NinjaOneOrgMappingTenant -QueueItem $Item } - 'SyncTenant' { Invoke-NinjaOneTenantSync -QueueItem $Item } - 'SyncTenants' { Invoke-NinjaOneSync } + 'AutoMapTenant' { Invoke-NinjaOneOrgMappingTenant -QueueItem $Item } + 'SyncTenant' { Invoke-NinjaOneTenantSync -QueueItem $Item } + 'SyncTenants' { Invoke-NinjaOneSync } } return $true } diff --git a/Modules/CippExtensions/Public/Sherweb/Remove-SherwebSubscription.ps1 b/Modules/CippExtensions/Public/Sherweb/Remove-SherwebSubscription.ps1 index b24e173dd4881..83bcd58aec88c 100644 --- a/Modules/CippExtensions/Public/Sherweb/Remove-SherwebSubscription.ps1 +++ b/Modules/CippExtensions/Public/Sherweb/Remove-SherwebSubscription.ps1 @@ -15,18 +15,28 @@ function Remove-SherwebSubscription { $Config = $ExtensionConfig.Sherweb $AllowedRoles = $Config.AllowedCustomRoles.value - if ($AllowedRoles -and $Headers.'x-ms-client-principal') { - $UserRoles = Get-CIPPAccessRole -Headers $Headers + if ($AllowedRoles) { + # Resolve caller roles for both interactive users and direct API clients, + # mirroring the principal detection Test-CIPPAccess/Test-CippApiClientRoleGrant use. + if ($Headers.'x-ms-client-principal-idp' -eq 'aad' -and $Headers.'x-ms-client-principal-name' -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + $Client = Get-CippApiClient -AppId $Headers.'x-ms-client-principal-name' + $CallerRoles = if ($Client.Role) { @($Client.Role) } else { @('cipp-api') } + } elseif ($Headers.'x-ms-client-principal') { + $CallerRoles = @(Get-CIPPAccessRole -Headers $Headers) + } else { + $CallerRoles = @() + } + $Allowed = $false - foreach ($Role in $UserRoles) { + foreach ($Role in $CallerRoles) { if ($AllowedRoles -contains $Role) { - Write-Information "User has allowed CIPP role: $Role" + Write-Information "Caller has allowed CIPP role: $Role" $Allowed = $true break } } if (-not $Allowed) { - throw 'This user is not allowed to modify Sherweb Licenses.' + throw 'This caller is not allowed to modify Sherweb Licenses.' } } } diff --git a/Modules/CippExtensions/Public/Sherweb/Set-SherwebSubscription.ps1 b/Modules/CippExtensions/Public/Sherweb/Set-SherwebSubscription.ps1 index 62a2c5ccf2ffa..b2a6766bd8354 100644 --- a/Modules/CippExtensions/Public/Sherweb/Set-SherwebSubscription.ps1 +++ b/Modules/CippExtensions/Public/Sherweb/Set-SherwebSubscription.ps1 @@ -18,18 +18,28 @@ function Set-SherwebSubscription { $Config = $ExtensionConfig.Sherweb $AllowedRoles = $Config.AllowedCustomRoles.value - if ($AllowedRoles -and $Headers.'x-ms-client-principal') { - $UserRoles = Get-CIPPAccessRole -Headers $Headers + if ($AllowedRoles) { + # Resolve caller roles for both interactive users and direct API clients, + # mirroring the principal detection Test-CIPPAccess/Test-CippApiClientRoleGrant use. + if ($Headers.'x-ms-client-principal-idp' -eq 'aad' -and $Headers.'x-ms-client-principal-name' -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + $Client = Get-CippApiClient -AppId $Headers.'x-ms-client-principal-name' + $CallerRoles = if ($Client.Role) { @($Client.Role) } else { @('cipp-api') } + } elseif ($Headers.'x-ms-client-principal') { + $CallerRoles = @(Get-CIPPAccessRole -Headers $Headers) + } else { + $CallerRoles = @() + } + $Allowed = $false - foreach ($Role in $UserRoles) { + foreach ($Role in $CallerRoles) { if ($AllowedRoles -contains $Role) { - Write-Information "User has allowed CIPP role: $Role" + Write-Information "Caller has allowed CIPP role: $Role" $Allowed = $true break } } if (-not $Allowed) { - throw 'This user is not allowed to modify Sherweb subscriptions.' + throw 'This caller is not allowed to modify Sherweb subscriptions.' } } } diff --git a/Shared/CIPPSharp/CIPPRestClient.cs b/Shared/CIPPSharp/CIPPRestClient.cs index 08c5a60fde372..ee6e56bd5a4b5 100644 --- a/Shared/CIPPSharp/CIPPRestClient.cs +++ b/Shared/CIPPSharp/CIPPRestClient.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; +using System.IO.Compression; using System.Linq; using System.Net; using System.Net.Http; @@ -36,6 +37,88 @@ public sealed class HttpResult public Dictionary ResponseHeaders { get; init; } = new(); } + // ===================================================================== + // CIPPResponseHeaders / CIPPHttpResponse / CIPPHttpRequestException + // ===================================================================== + // When a request returns a non-success status, the PowerShell wrapper + // (Invoke-CIPPRestMethod) throws a CIPPHttpRequestException. CIPP's Graph + // helpers were written against Invoke-RestMethod's HttpResponseException + // and read: + // $_.Exception.Response.StatusCode -eq 429 + // $_.Exception.Response.Headers['Retry-After'] + // The pooled client previously threw a bare HttpRequestException with no + // .Response, so those branches were dead. These types restore the expected + // shape: a .Response with a StatusCode (HttpStatusCode, so `-eq 429` works) + // and Headers that support case-insensitive string indexing returning a + // scalar value (so ['Retry-After'] works), matching the old behaviour. + // ===================================================================== + + /// + /// Case-insensitive response-header view exposed to PowerShell. The string + /// indexer returns the (comma-joined) value for a header, or null if absent, + /// mirroring how WebHeaderCollection / HttpResponseHeaders were consumed in + /// CIPP via $response.Headers['Header-Name']. + /// + public sealed class CIPPResponseHeaders + { + private readonly Dictionary _headers; + + public CIPPResponseHeaders(Dictionary? headers) + { + _headers = headers is not null + ? new Dictionary(headers, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + /// $resp.Headers['Retry-After'] -> scalar string (joined), or null. + public string? this[string key] + => key is not null && _headers.TryGetValue(key, out var v) ? string.Join(", ", v) : null; + + public bool Contains(string key) => key is not null && _headers.ContainsKey(key); + public string[]? GetValues(string key) => key is not null && _headers.TryGetValue(key, out var v) ? v : null; + public IEnumerable Keys => _headers.Keys; + public int Count => _headers.Count; + } + + /// + /// Lightweight stand-in for the response object CIPP code reaches through + /// $_.Exception.Response. Carries the status code (as HttpStatusCode so + /// `-eq 429` works), the headers (string-indexable), and the raw body. + /// + public sealed class CIPPHttpResponse + { + public HttpStatusCode StatusCode { get; } + public int StatusCodeValue { get; } + public CIPPResponseHeaders Headers { get; } + public string Content { get; } + + public CIPPHttpResponse(int statusCode, Dictionary? headers, string? content) + { + StatusCode = (HttpStatusCode)statusCode; + StatusCodeValue = statusCode; + Headers = new CIPPResponseHeaders(headers); + Content = content ?? string.Empty; + } + } + + /// + /// HttpRequestException subclass carrying a .Response (CIPPHttpResponse). + /// Subclassing keeps existing `catch [System.Net.Http.HttpRequestException]` + /// and `$_.Exception.Message` / `$_.ErrorDetails.Message` handling intact, + /// while restoring `$_.Exception.Response.StatusCode` / + /// `$_.Exception.Response.Headers['Retry-After']`. + /// + public sealed class CIPPHttpRequestException : HttpRequestException + { + public CIPPHttpResponse Response { get; } + + public CIPPHttpRequestException(string message, int statusCode, Dictionary? headers, string? content) + : base(message, null, (HttpStatusCode)statusCode) + { + Response = new CIPPHttpResponse(statusCode, headers, content); + } + } + // ===================================================================== // CIPPRestClient // ===================================================================== @@ -599,7 +682,33 @@ public static async Task SendAsync( HttpResponseMessage response; try { - response = await client.SendAsync(request, token).ConfigureAwait(false); + // ResponseHeadersRead: do NOT buffer the body inside SendAsync. With the + // default (ResponseContentRead) the body is downloaded and auto-decompressed + // here, so a mislabeled Content-Encoding (EXO error responses declare gzip on + // a plain body) throws InvalidDataException from SendAsync and bypasses the + // defensive body read below — masking the HTTP status the caller needs. + response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cts is not null && cts.IsCancellationRequested) + { + // Our per-request timeout CTS actually fired — this is a genuine + // client-side timeout after timeoutSec. Let it propagate as an + // OperationCanceledException so the PowerShell wrapper reports it + // as a timeout (and the "timed out after {timeoutSec}s" message is true). + TrackTransportError(selection.Pool); + throw; + } + catch (OperationCanceledException ex) + { + // Cancellation was NOT triggered by our timeout token. The server + // reset/closed the request before our timeout elapsed — common for + // slow EXO InvokeCommand cmdlets (Search-UnifiedAuditLog, + // Get-MessageTraceV2) which the service cuts off well under 100s. + // Surface it as a transport error so it is not mislabeled as a + // client timeout and is correctly treated as a retryable failure. + TrackTransportError(selection.Pool); + throw new HttpRequestException( + $"The request to '{uri}' was canceled by the server before completing.", ex); } catch { @@ -622,7 +731,7 @@ public static async Task SendAsync( try { content = response.Content is not null - ? await response.Content.ReadAsStringAsync(token).ConfigureAwait(false) + ? await ReadDecodedContentAsync(response.Content, token).ConfigureAwait(false) : string.Empty; } catch (Exception ex) when (ex is InvalidDataException || ex is IOException) @@ -691,6 +800,46 @@ public static async Task SendAsync( } } + // ----------------------------------------------------------------- + // Content decoding + // ----------------------------------------------------------------- + // Reads the response body as a string, decompressing explicitly based on + // the Content-Encoding header. SocketsHttpHandler.AutomaticDecompression + // normally handles this transparently AND strips Content-Encoding, in + // which case ContentEncoding is empty here and we just read the string. + // But AutomaticDecompression silently no-ops whenever a request carries a + // caller-supplied Accept-Encoding header (HttpClient assumes the caller + // will decode), and some endpoints (e.g. the Teams ConfigAPI) return gzip + // even when unprompted. When Content-Encoding survives, we decode it here + // so callers never receive raw compressed bytes. Idempotent: if the + // handler already decoded, there's nothing left to do. + // ----------------------------------------------------------------- + private static async Task ReadDecodedContentAsync(HttpContent httpContent, CancellationToken token) + { + var encodings = httpContent.Headers.ContentEncoding; + if (encodings is null || encodings.Count == 0) + return await httpContent.ReadAsStringAsync(token).ConfigureAwait(false); + + var raw = await httpContent.ReadAsStreamAsync(token).ConfigureAwait(false); + Stream decoded = raw; + // Content-Encoding lists encodings in the order applied; decode in reverse. + foreach (var enc in encodings.Reverse()) + { + decoded = enc?.Trim().ToLowerInvariant() switch + { + "gzip" or "x-gzip" => new GZipStream(decoded, CompressionMode.Decompress), + "deflate" => new DeflateStream(decoded, CompressionMode.Decompress), + "br" => new BrotliStream(decoded, CompressionMode.Decompress), + "identity" or "" or null => decoded, + _ => decoded, + }; + } + using var reader = new StreamReader(decoded, Encoding.UTF8); + var result = await reader.ReadToEndAsync(token).ConfigureAwait(false); + decoded.Dispose(); + return result; + } + // ================================================================= // Diagnostics // ================================================================= diff --git a/Shared/CIPPSharp/CIPPTestDataCache.cs b/Shared/CIPPSharp/CIPPTestDataCache.cs index d92db61483e75..da2538ec5ba2d 100644 --- a/Shared/CIPPSharp/CIPPTestDataCache.cs +++ b/Shared/CIPPSharp/CIPPTestDataCache.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -14,16 +13,32 @@ namespace CIPP /// Host-scoped, thread-safe LRU cache for test data lookups. The DLL is /// loaded once per Azure Functions host, so every PowerShell worker /// process on that host shares this exact instance. Bounded by both a - /// byte-size cap (default 100 MB) and a short TTL (default 5 minutes) + /// byte-size cap (default 300 MB) and a short TTL (default 5 minutes) /// so that test suites running against a single tenant get fast cache /// hits without accumulating stale Gen2 roots that cause GC thrashing. /// public static class TestDataCache { // ── Configuration ── - private static long _maxBytes = 100L * 1024 * 1024; // 100 MB default + // Defaults are 300 MB / 5 min. Both are overridable per deployment via + // Azure Functions app settings (environment variables) resolved once at + // load, or programmatically via Configure() for tests. The cap now maps to + // real managed-heap bytes (see EstimateValueSize), so 300 MB means ~300 MB. + private static long _maxBytes = 300L * 1024 * 1024; // 300 MB default private static TimeSpan _ttl = TimeSpan.FromMinutes(5); + static TestDataCache() + { + try + { + if (long.TryParse(Environment.GetEnvironmentVariable("CIPPTestCacheMaxMB"), out var maxMB) && maxMB > 0) + _maxBytes = maxMB * 1024L * 1024L; + if (int.TryParse(Environment.GetEnvironmentVariable("CIPPTestCacheTtlSeconds"), out var ttlSec) && ttlSec > 0) + _ttl = TimeSpan.FromSeconds(ttlSec); + } + catch { /* keep defaults if app settings are unreadable */ } + } + // ── State ── private static readonly ConcurrentDictionary _cache = new(); private static readonly LinkedList _lruOrder = new(); // head = oldest @@ -56,7 +71,7 @@ public CacheEntry(object? value, long sizeBytes, DateTime expiresUtc) } /// Configure the cache limits. Call before first use or between test runs. - public static void Configure(long maxBytes = 100L * 1024 * 1024, int ttlSeconds = 300) + public static void Configure(long maxBytes = 300L * 1024 * 1024, int ttlSeconds = 300) { _maxBytes = maxBytes; _ttl = TimeSpan.FromSeconds(ttlSeconds); @@ -258,8 +273,31 @@ private static void TryFireBackgroundSweep() private static PropertyInfo? s_psPropsProp; // PSObject.Properties private static PropertyInfo? s_psPropName; // PSPropertyInfo.Name private static PropertyInfo? s_psPropValue; // PSPropertyInfo.Value - private const int SampleSize = 5; - private const int MaxDepth = 4; + // ── Managed-heap size model (x64 CoreCLR) ── + // The cache stores *parsed PSObject graphs*, whose live heap footprint is + // many times their JSON text size (measured ≈8× on real Graph data). Sizing + // by JSON serialization therefore under-counts by ~8× and lets the byte cap + // balloon far past its nominal limit. Instead we walk the object graph and + // sum approximate per-object heap costs. Constants below are calibrated + // against real GC heap growth (see tools/measure-testcache.ps1). + private const int ObjectHeader = 16; // sync block + method table ptr + private const int RefSlot = 8; // one reference (array/field slot) + private const int StringBaseOverhead = 22; // header(16)+length(4)+terminator(2) + private const int BoxedPrimitive = 24; // header(16)+<=8 payload, aligned + private const int BoxedLarge = 32; // header(16)+16 payload (decimal/Guid) + private const int PSObjectBase = 192; // PSObject wrapper + base + member collections + private const int PSNotePropertyOverhead = 132; // PSNoteProperty + name-lookup entry (excl. name string + value) + private const int DictBase = 80; // buckets + entries baseline + private const int DictEntryOverhead = 48; // per-entry slot incl. capacity slack (excl. key + value) + private const int CollectionBase = 56; // list/array object + backing array baseline + + // Bound the walk on pathological payloads: full-walk small collections for + // accuracy, but stride-sample very large ones so a 100k-item array can't + // turn a single Set() into a multi-second reflection storm. + private const int LargeCollectionThreshold = 512; + private const int LargeCollectionSamples = 256; + private const int MaxDepth = 32; + private const long NodeBudget = 2_000_000; // hard ceiling on nodes visited per Set() private static void EnsurePSResolved() { @@ -289,82 +327,127 @@ private static void EnsurePSResolved() } } + private static long Align8(long bytes) => (bytes + 7) & ~7L; + /// - /// Estimate the serialized size of a cached value. For large collections, - /// samples a few items and extrapolates. Handles PSObject by unwrapping - /// NoteProperties into dictionaries via reflection. + /// Estimate the live managed-heap footprint of a cached value by walking the + /// object graph and summing approximate per-object costs. This tracks real + /// memory (parsed PSObjects, boxed primitives, strings, dictionaries and + /// collections) rather than JSON text size, so the byte cap corresponds to + /// actual process memory. is accepted for call + /// compatibility but the walk recomputes counts itself. /// private static long EstimateValueSize(object? value, int itemCount) { if (value == null) return 0; EnsurePSResolved(); - try { - // Large collection: sample a few items, extrapolate - if (itemCount > SampleSize && value is IEnumerable enumerable) - { - var sample = new List(); - foreach (var item in enumerable) - { - sample.Add(Unwrap(item, 0)); - if (sample.Count >= SampleSize) break; - } - if (sample.Count == 0) return 0; - var sampleBytes = JsonSerializer.SerializeToUtf8Bytes(sample).LongLength; - return sampleBytes * itemCount / sample.Count; - } - - // Small collection or single value: unwrap everything - var unwrapped = Unwrap(value, 0); - return JsonSerializer.SerializeToUtf8Bytes(unwrapped).LongLength; + long budget = NodeBudget; + return SizeOf(value, 0, ref budget); } catch { return 0; } } /// - /// Recursively unwrap PSObject → Dictionary and IEnumerable → List - /// so System.Text.Json can serialize them. + /// Recursively sum the approximate managed-heap bytes of + /// and everything it owns. Returns the object's own heap size; the reference + /// slot that points at it is accounted for by the owning object/collection. /// - private static object? Unwrap(object? value, int depth) + private static long SizeOf(object? value, int depth, ref long budget) { - if (value == null || depth > MaxDepth) return value; + if (value == null) return 0; + if (--budget <= 0 || depth > MaxDepth) return 0; - // PSObject → extract NoteProperties as Dictionary + switch (value) + { + case string s: + return Align8(StringBaseOverhead + 2L * s.Length); + case bool: case byte: case sbyte: case char: + case short: case ushort: case int: case uint: + case long: case ulong: case float: case double: + case DateTime: case DateTimeOffset: case TimeSpan: case Enum: + return BoxedPrimitive; + case decimal: case Guid: + return BoxedLarge; + case byte[] bytes: + return Align8(ObjectHeader + RefSlot + bytes.LongLength); + } + + // PSObject → wrapper cost + each NoteProperty (name string + value + overhead) if (s_psObjectType != null && s_psObjectType.IsInstanceOfType(value)) - return UnwrapPSObject(value, depth); + return SizeOfPSObject(value, depth, ref budget); + + // Dictionary / Hashtable → base + per-entry overhead + keys + values + if (value is IDictionary dict) + { + long total = ObjectHeader + DictBase; + foreach (DictionaryEntry de in dict) + { + total += DictEntryOverhead; + total += SizeOf(de.Key, depth + 1, ref budget); + total += SizeOf(de.Value, depth + 1, ref budget); + if (budget <= 0) break; + } + return total; + } - // Collection → unwrap each element - if (value is IEnumerable enumerable && value is not string && value is not byte[]) + // Other collections (object[], List, …). Stride-sample very large + // ones to bound the walk; full-walk the rest for accuracy. + if (value is IEnumerable enumerable && value is not string) { - var list = new List(); + int count = value is ICollection col ? col.Count : -1; + + if (count > LargeCollectionThreshold && value is IList list) + { + long step = count / (long)LargeCollectionSamples; + if (step < 1) step = 1; + long sampled = 0, sampledBytes = 0; + for (long i = 0; i < count && sampled < LargeCollectionSamples; i += step) + { + sampledBytes += SizeOf(list[(int)i], depth + 1, ref budget); + sampled++; + if (budget <= 0) break; + } + long avgItem = sampled > 0 ? sampledBytes / sampled : 0; + // backing array slots + extrapolated element payloads + return ObjectHeader + CollectionBase + count * (RefSlot + avgItem); + } + + long total = ObjectHeader + CollectionBase; foreach (var item in enumerable) - list.Add(Unwrap(item, depth + 1)); - return list; + { + total += RefSlot; + total += SizeOf(item, depth + 1, ref budget); + if (budget <= 0) break; + } + return total; } - return value; + // Unknown reference type: header plus a small nominal for its fields. + return ObjectHeader + 32; } - private static Dictionary UnwrapPSObject(object psObj, int depth) + private static long SizeOfPSObject(object psObj, int depth, ref long budget) { - var dict = new Dictionary(); + long total = PSObjectBase; if (s_psPropsProp == null || s_psPropName == null || s_psPropValue == null) - return dict; - - if (s_psPropsProp.GetValue(psObj) is not IEnumerable props) return dict; + return total; + if (s_psPropsProp.GetValue(psObj) is not IEnumerable props) return total; foreach (var prop in props) { + if (--budget <= 0) break; + total += PSNotePropertyOverhead; try { - var name = s_psPropName.GetValue(prop)?.ToString(); - if (name != null) - dict[name] = Unwrap(s_psPropValue.GetValue(prop), depth + 1); + if (s_psPropName.GetValue(prop) is string name) + total += Align8(StringBaseOverhead + 2L * name.Length); + total += SizeOf(s_psPropValue.GetValue(prop), depth + 1, ref budget); } catch { /* skip properties that throw on access */ } } - return dict; + return total; } /// diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index 242e6458a8033..c242eca31f457 100644 Binary files a/Shared/CIPPSharp/bin/CIPPSharp.dll and b/Shared/CIPPSharp/bin/CIPPSharp.dll differ diff --git a/Tests/Alerts/Get-CIPPAlertGlobalAdminAllowList.Tests.ps1 b/Tests/Alerts/Get-CIPPAlertGlobalAdminAllowList.Tests.ps1 index 7a8ae25cc5909..c2a9e70906284 100644 --- a/Tests/Alerts/Get-CIPPAlertGlobalAdminAllowList.Tests.ps1 +++ b/Tests/Alerts/Get-CIPPAlertGlobalAdminAllowList.Tests.ps1 @@ -3,13 +3,19 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $AlertPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $AlertPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CIPPAlertGlobalAdminAllowList.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $AlertPath) { throw 'Could not locate Get-CIPPAlertGlobalAdminAllowList.ps1 under Modules/' } # Provide minimal stubs so Mock has commands to replace during tests function New-GraphGetRequest { param($uri, $tenantid, $AsApp) } function Write-AlertTrace { param($cmdletName, $tenantFilter, $data) } function Write-AlertMessage { param($tenant, $message) } function Get-NormalizedError { param($message) $message } + # The error path now normalises via Get-CippException and logs via Write-LogMessage. + function Get-CippException { param($Exception) @{ NormalizedError = $Exception } } + function Write-LogMessage { param($API, $tenant, $message, $sev, $Headers, $LogData) } . $AlertPath } @@ -47,6 +53,12 @@ Describe 'Get-CIPPAlertGlobalAdminAllowList' { param($tenant, $message) $script:CapturedErrorMessage = $message } + + # The function logs failures through Write-LogMessage, so capture the error from there. + Mock -CommandName Write-LogMessage -MockWith { + param($API, $tenant, $message, $sev, $Headers, $LogData) + $script:CapturedErrorMessage = $message + } } It 'emits per-admin alerts when AlertEachAdmin is true' { diff --git a/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 b/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 index f44bb399798e0..7cfd7476d483e 100644 --- a/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 +++ b/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 @@ -122,7 +122,7 @@ Describe 'Get-CIPPAlertIntunePolicyConflicts' { $AppIssue = $CapturedData | Where-Object { $_.Type -eq 'Application' } $AppIssue.FailedDeviceCount | Should -Be 3 - $AppIssue.Message | Should -Match "failed to install on 3 device" + $AppIssue.Message | Should -Match 'failed to install on 3 device' } It 'skips processing when license check fails' { diff --git a/Tests/Build/Add-OpenApiResponseSchemas.Tests.ps1 b/Tests/Build/Add-OpenApiResponseSchemas.Tests.ps1 new file mode 100644 index 0000000000000..ee1d7ebc8fdfd --- /dev/null +++ b/Tests/Build/Add-OpenApiResponseSchemas.Tests.ps1 @@ -0,0 +1,488 @@ +#Requires -Version 7.0 +<# + Pester tests for Add-OpenApiResponseSchemas.ps1. + + Covers the pure core (Resolve-SpecResponse + the schema/source converters) with + hand-built fixtures, plus the sad paths that matter for a generator stage: + malformed input, non-baseline files, missing sources, and operations the stage + must leave untouched. Dot-sources the script so only its functions load. +#> + +BeforeAll { + # Test lives at /Tests/Build/; the script lives at /.build/. + $RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $ScriptPath = Join-Path $RepoRoot '.build' 'Add-OpenApiResponseSchemas.ps1' + . $ScriptPath + + # Minimal spec factory: one path with the given methods, each carrying a bare 200. + function Get-TestSpec { + param([hashtable]$Paths) + return [ordered]@{ openapi = '3.1.0'; paths = $Paths } + } + function Get-Operation { + param( + [switch]$NoOkResponse, + [string]$OperationId + ) + $responses = if ($NoOkResponse) { @{ '500' = @{ description = 'err' } } } else { @{ '200' = @{ description = 'ok' } } } + $operation = @{ responses = $responses } + if ($OperationId) { $operation['operationId'] = $OperationId } + return $operation + } +} + +Describe 'ConvertFrom-ShapeNode' { + Context 'Leaf tokens' { + It 'maps string' { (ConvertFrom-ShapeNode -Node 'string').type | Should -Be 'string' } + It 'maps number' { (ConvertFrom-ShapeNode -Node 'number').type | Should -Be 'number' } + It 'maps bool to boolean' { (ConvertFrom-ShapeNode -Node 'bool').type | Should -Be 'boolean' } + It 'maps datetime to string+format' { + $r = ConvertFrom-ShapeNode -Node 'datetime' + $r.type | Should -Be 'string' + $r.format | Should -Be 'date-time' + } + It 'leaves null permissive (no type)' { (ConvertFrom-ShapeNode -Node 'null').Keys.Count | Should -Be 0 } + It 'leaves truncated permissive (no type)' { (ConvertFrom-ShapeNode -Node 'truncated').Keys.Count | Should -Be 0 } + It 'leaves unknown tokens permissive' { (ConvertFrom-ShapeNode -Node 'mystery').Keys.Count | Should -Be 0 } + } + + Context 'Nested structures' { + It 'maps an array node to type array with typed items' { + $node = @{ _type = 'array'; _element = 'string' } + $r = ConvertFrom-ShapeNode -Node $node + $r.type | Should -Be 'array' + $r.items.type | Should -Be 'string' + } + It 'maps an object node and sorts its properties' { + $node = [ordered]@{ zeta = 'string'; alpha = 'number' } + $r = ConvertFrom-ShapeNode -Node $node + $r.type | Should -Be 'object' + @($r.properties.Keys) | Should -Be @('alpha', 'zeta') + } + It 'maps an array of objects' { + $node = @{ _type = 'array'; _element = @{ id = 'string'; count = 'number' } } + $r = ConvertFrom-ShapeNode -Node $node + $r.items.type | Should -Be 'object' + $r.items.properties.id.type | Should -Be 'string' + } + } +} + +Describe 'ConvertTo-ColumnRecordSchema' { + It 'types every column as string with frontend provenance' { + $cols = [System.Collections.Generic.HashSet[string]]::new() + [void]$cols.Add('mail'); [void]$cols.Add('displayName') + $r = ConvertTo-ColumnRecordSchema -Columns $cols + $r.type | Should -Be 'object' + $r.properties.mail.type | Should -Be 'string' + $r.properties.mail.'x-cipp-field-source' | Should -Be 'frontend' + } + It 'sorts columns for deterministic output' { + $cols = [System.Collections.Generic.HashSet[string]]::new() + [void]$cols.Add('zeta'); [void]$cols.Add('alpha') + $r = ConvertTo-ColumnRecordSchema -Columns $cols + @($r.properties.Keys) | Should -Be @('alpha', 'zeta') + } +} + +Describe 'ConvertTo-ResponseEnvelopeSchema' { + It 'wraps a record schema in the Results/Metadata envelope' { + $record = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } + $r = ConvertTo-ResponseEnvelopeSchema -RecordSchema $record + $r.type | Should -Be 'object' + $r.properties.Results.type | Should -Be 'array' + $r.properties.Results.items.properties.id.type | Should -Be 'string' + $r.properties.Metadata.type | Should -Be 'object' + } +} + + +Describe 'Add-CippOperationId' { + It 'injects the bare endpoint name for a single-method operation with no operationId' { + $spec = Get-TestSpec -Paths @{ '/api/ListMailboxes' = @{ get = (Get-Operation) } } + $r = Add-CippOperationId -Spec $spec + $spec['paths']['/api/ListMailboxes']['get']['operationId'] | Should -Be 'ListMailboxes' + $r.Injected | Should -Be 1 + $r.Unique | Should -Be 1 + } + + It 'keeps single-method endpoint names bare even when they start with their method word' { + $spec = Get-TestSpec -Paths @{ + '/api/PatchUser' = @{ patch = (Get-Operation) } + '/api/ListX' = @{ get = (Get-Operation) } + } + Add-CippOperationId -Spec $spec | Out-Null + $spec['paths']['/api/PatchUser']['patch']['operationId'] | Should -Be 'PatchUser' + $spec['paths']['/api/ListX']['get']['operationId'] | Should -Be 'ListX' + } + + It 'gives a multi-method path two distinct operationIds' { + $spec = Get-TestSpec -Paths @{ '/api/ExecCSPLicense' = @{ get = (Get-Operation); post = (Get-Operation) } } + $r = Add-CippOperationId -Spec $spec + $spec['paths']['/api/ExecCSPLicense']['get']['operationId'] | Should -Be 'GetExecCSPLicense' + $spec['paths']['/api/ExecCSPLicense']['post']['operationId'] | Should -Be 'PostExecCSPLicense' + $r.Unique | Should -Be 2 + } + + It 'preserves a pre-existing operationId' { + $spec = Get-TestSpec -Paths @{ '/api/ListMailboxes' = @{ get = (Get-Operation -OperationId 'AlreadyThere') } } + $r = Add-CippOperationId -Spec $spec + $spec['paths']['/api/ListMailboxes']['get']['operationId'] | Should -Be 'AlreadyThere' + $r.Injected | Should -Be 0 + } + + It 'throws when a synthetic duplicate operationId is present' { + $spec = Get-TestSpec -Paths @{ + '/api/DuplicateA' = @{ get = (Get-Operation -OperationId 'SameOperation') } + '/api/DuplicateB' = @{ post = (Get-Operation -OperationId 'SameOperation') } + } + { Add-CippOperationId -Spec $spec } | Should -Throw '*Duplicate operationId found: SameOperation*' + } + + It 'keeps visibly different endpoint names distinct without hidden normalization' { + $spec = Get-TestSpec -Paths @{ + '/api/User' = @{ get = (Get-Operation) } + '/api/ListUsers' = @{ get = (Get-Operation) } + '/api/List-Users' = @{ get = (Get-Operation) } + } + Add-CippOperationId -Spec $spec | Out-Null + $spec['paths']['/api/User']['get']['operationId'] | Should -Be 'User' + $spec['paths']['/api/ListUsers']['get']['operationId'] | Should -Be 'ListUsers' + $spec['paths']['/api/List-Users']['get']['operationId'] | Should -Be 'List-Users' + } + + It 'throws when disambiguated derivation creates an actual duplicate' { + $spec = Get-TestSpec -Paths @{ + '/api/PatchUser' = @{ patch = (Get-Operation) } + '/api/User' = @{ get = (Get-Operation); patch = (Get-Operation) } + } + { Add-CippOperationId -Spec $spec } | Should -Throw '*Duplicate operationId found: PatchUser*' + } + + It 'throws when two single-method paths have the same bare endpoint name' { + $spec = Get-TestSpec -Paths @{ + '/api/SameName' = @{ get = (Get-Operation) } + 'SameName' = @{ post = (Get-Operation) } + } + { Add-CippOperationId -Spec $spec } | Should -Throw '*Duplicate operationId found: SameName*' + } + + It 'ignores non-method path item keys when injecting operationIds' { + $spec = Get-TestSpec -Paths @{ + '/api/ListThings' = [ordered]@{ + get = (Get-Operation) + parameters = @(@{ name = 'tenant'; in = 'query' }) + summary = 'path summary' + '$ref' = '#/components/pathItems/ListThings' + description = 'path description' + } + } + Add-CippOperationId -Spec $spec | Out-Null + $spec['paths']['/api/ListThings']['get']['operationId'] | Should -Be 'ListThings' + $spec['paths']['/api/ListThings']['parameters'][0].ContainsKey('operationId') | Should -BeFalse + $spec['paths']['/api/ListThings']['summary'] | Should -Be 'path summary' + $spec['paths']['/api/ListThings']['$ref'] | Should -Be '#/components/pathItems/ListThings' + $spec['paths']['/api/ListThings']['description'] | Should -Be 'path description' + } + + It 'yields one unique operationId per operation on the full real spec' { + $specPath = Join-Path $RepoRoot 'openapi.json' + $spec = Get-Content -LiteralPath $specPath -Raw | ConvertFrom-Json -AsHashtable -Depth 100 + $operationTotal = 0 + foreach ($pathEntry in $spec['paths'].GetEnumerator()) { + foreach ($methodEntry in $pathEntry.Value.GetEnumerator()) { + if ($methodEntry.Key -in @('get', 'post', 'put', 'patch', 'delete')) { $operationTotal++ } + } + } + $r = Add-CippOperationId -Spec $spec + Write-Information "Full real spec operationIds: $($r.Unique)" -InformationAction Continue + $r.Operations | Should -Be $operationTotal + $r.Unique | Should -Be $r.Operations + } +} + +Describe 'Resolve-SpecResponse - happy paths' { + It 'types a baseline-backed endpoint and counts it' { + $spec = Get-TestSpec -Paths @{ '/api/ListThings' = @{ get = (Get-Operation) } } + $baseline = @{ ListThings = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $r.Operations | Should -Be 1 + $r.Typed | Should -Be 1 + $schema = $spec['paths']['/api/ListThings']['get']['responses']['200']['content']['application/json']['schema'] + $schema.properties.Results.items.properties.id.type | Should -Be 'string' + } + + It 'prefers the baseline when an endpoint is in both maps' { + $spec = Get-TestSpec -Paths @{ '/api/Both' = @{ get = (Get-Operation) } } + $baseline = @{ Both = [ordered]@{ type = 'object'; properties = [ordered]@{ fromBaseline = @{ type = 'string' } } } } + $cols = [System.Collections.Generic.HashSet[string]]::new(); [void]$cols.Add('fromColumns') + Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{ Both = $cols } | Out-Null + $props = $spec['paths']['/api/Both']['get']['responses']['200']['content']['application/json']['schema'].properties.Results.items.properties + $props.Keys | Should -Contain 'fromBaseline' + $props.Keys | Should -Not -Contain 'fromColumns' + } + + + + It 'does not inject operationIds while typing responses' { + $spec = Get-TestSpec -Paths @{ '/api/ListThings' = @{ get = (Get-Operation) } } + $baseline = @{ ListThings = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } } + Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} | Out-Null + $spec['paths']['/api/ListThings']['get'].ContainsKey('operationId') | Should -BeFalse + } + + It 'types a columns-only endpoint with provenance markers' { + $spec = Get-TestSpec -Paths @{ '/api/ListCols' = @{ get = (Get-Operation) } } + $cols = [System.Collections.Generic.HashSet[string]]::new(); [void]$cols.Add('displayName') + Resolve-SpecResponse -Spec $spec -BaselineMap @{} -ColumnMap @{ ListCols = $cols } | Out-Null + $props = $spec['paths']['/api/ListCols']['get']['responses']['200']['content']['application/json']['schema'].properties.Results.items.properties + $props.displayName.'x-cipp-field-source' | Should -Be 'frontend' + } +} + +Describe 'Resolve-SpecResponse - sad paths and invariants' { + It 'leaves an endpoint with no matching source untouched' { + $spec = Get-TestSpec -Paths @{ '/api/AddUser' = @{ post = (Get-Operation) } } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap @{} -ColumnMap @{} + $r.Operations | Should -Be 1 + $r.Typed | Should -Be 0 + $spec['paths']['/api/AddUser']['post']['responses']['200'].ContainsKey('content') | Should -BeFalse + } + + It 'does not type an operation that has no 200 response' { + $spec = Get-TestSpec -Paths @{ '/api/Weird' = @{ get = (Get-Operation -NoOkResponse) } } + $baseline = @{ Weird = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $r.Typed | Should -Be 0 + } + + It 'does not throw on an operation with no responses block' { + $spec = Get-TestSpec -Paths @{ '/api/Weird' = @{ get = @{} } } + $baseline = @{ Weird = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $r.Operations | Should -Be 1 + $r.Typed | Should -Be 0 + } + + It 'types a baseline-backed endpoint even when the record schema is permissive' { + $spec = Get-TestSpec -Paths @{ '/api/ListUnknown' = @{ get = (Get-Operation) } } + $baseline = @{ ListUnknown = @{} } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $r.Typed | Should -Be 1 + $schema = $spec['paths']['/api/ListUnknown']['get']['responses']['200']['content']['application/json']['schema'] + $schema.properties.Results.items.Keys.Count | Should -Be 0 + } + + It 'counts only get/post/put/patch/delete, ignoring parameters/summary keys' { + $spec = Get-TestSpec -Paths @{ '/api/ListThings' = [ordered]@{ get = (Get-Operation); parameters = @(); summary = 'x' } } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap @{} -ColumnMap @{} + $r.Operations | Should -Be 1 + } + + It 'preserves the operation set (adds nothing, removes nothing)' { + $spec = Get-TestSpec -Paths @{ + '/api/ListA' = @{ get = (Get-Operation) } + '/api/AddB' = @{ post = (Get-Operation) } + } + $before = $spec['paths'].Keys | Sort-Object + Resolve-SpecResponse -Spec $spec -BaselineMap @{} -ColumnMap @{} | Out-Null + ($spec['paths'].Keys | Sort-Object) | Should -Be $before + } + + It 'throws on a spec with no paths' { + { Resolve-SpecResponse -Spec ([ordered]@{ openapi = '3.1.0' }) -BaselineMap @{} -ColumnMap @{} } | + Should -Throw '*no paths*' + } + + It 'is stable on a second core pass (same count, same typed schema)' { + # Production never re-mutates an in-memory spec; it reads fresh each run. The + # guarantee that matters is that re-applying yields the same meaningful output, + # not that an unordered Hashtable serialises in identical key order. + $spec = Get-TestSpec -Paths @{ '/api/ListThings' = @{ get = (Get-Operation) } } + $baseline = @{ ListThings = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } } + $r1 = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $schema1 = $spec['paths']['/api/ListThings']['get']['responses']['200']['content']['application/json']['schema'] | ConvertTo-Json -Depth 100 + $r2 = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $schema2 = $spec['paths']['/api/ListThings']['get']['responses']['200']['content']['application/json']['schema'] | ConvertTo-Json -Depth 100 + $r2.Typed | Should -Be $r1.Typed + $schema2 | Should -Be $schema1 + } +} + +Describe 'Get-ShapeBaselineMap - file ingestion sad paths' { + BeforeEach { + $script:ShapesDir = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-shapes-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $script:ShapesDir -Force | Out-Null + } + AfterEach { Remove-Item -Recurse -Force $script:ShapesDir -ErrorAction SilentlyContinue } + + It 'ingests a valid baseline' { + @{ _metadata = @{ endpoint = 'ListX' }; shape = @{ id = 'string' } } | ConvertTo-Json -Depth 10 | + Set-Content (Join-Path $script:ShapesDir 'ListX.json') + $map = Get-ShapeBaselineMap -ShapesDir $script:ShapesDir + $map.ContainsKey('ListX') | Should -BeTrue + } + + It 'skips a file that lacks _metadata/shape (e.g. test-results.json)' { + @{ results = @(@{ status = 'PASS' }) } | ConvertTo-Json -Depth 10 | + Set-Content (Join-Path $script:ShapesDir 'test-results.json') + $map = Get-ShapeBaselineMap -ShapesDir $script:ShapesDir + $map.Count | Should -Be 0 + } + + It 'returns empty for a missing directory without throwing' { + $map = Get-ShapeBaselineMap -ShapesDir (Join-Path $script:ShapesDir 'does-not-exist') + $map.Count | Should -Be 0 + } +} + +Describe 'Get-FrontendColumnMap - parsing sad paths' { + BeforeEach { + $script:SrcDir = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-src-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $script:SrcDir -Force | Out-Null + } + AfterEach { Remove-Item -Recurse -Force $script:SrcDir -ErrorAction SilentlyContinue } + + It 'extracts columns paired with an api endpoint' { + Set-Content (Join-Path $script:SrcDir 'page.jsx') @' +const simpleColumns = ["displayName", "mail"]; + +'@ + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map['ListMailboxes'] | Should -Contain 'displayName' + $map['ListMailboxes'] | Should -Contain 'mail' + } + + It 'handles mixed single and double quotes in the column array' { + Set-Content (Join-Path $script:SrcDir 'page.jsx') @' +const simpleColumns = ['mail', "displayName"]; +apiUrl="/api/ListThings" +'@ + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map['ListThings'].Count | Should -Be 2 + } + + It 'handles JSX simpleColumns arrays split after the opening brace' { + Set-Content (Join-Path $script:SrcDir 'page.jsx') @' + +'@ + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map['ListSplit'] | Should -Contain 'displayName' + $map['ListSplit'] | Should -Contain 'mail' + } + + + + It 'does not leak scalar ternary branch strings near simpleColumns' { + Set-Content (Join-Path $script:SrcDir 'page.jsx') @' +const statusLabel = enabled ? "yes" : "no"; +const simpleColumns = ["displayName", "mail"]; +apiUrl="/api/ListTernarySafe" +'@ + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map['ListTernarySafe'] | Should -Contain 'displayName' + $map['ListTernarySafe'] | Should -Contain 'mail' + $map['ListTernarySafe'] | Should -Not -Contain 'yes' + $map['ListTernarySafe'] | Should -Not -Contain 'no' + } + + It 'does not capture branch strings when simpleColumns is a ternary of arrays' { + # This lightweight parser only accepts direct array literals. Conditional + # simpleColumns values are ignored rather than risking scalar branch leaks. + Set-Content (Join-Path $script:SrcDir 'page.jsx') @' +const simpleColumns = hasScope + ? ['RowKey', 'Value', 'Description'] + : ['RowKey', 'Value', 'Description', 'Scope']; +const label = hasScope ? "yes" : "no"; +apiUrl="/api/ListCustomVariables" +'@ + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map.ContainsKey('ListCustomVariables') | Should -BeFalse + foreach ($columns in $map.Values) { + $columns | Should -Not -Contain 'yes' + $columns | Should -Not -Contain 'no' + } + } + + It 'ignores a file with simpleColumns but no api endpoint' { + Set-Content (Join-Path $script:SrcDir 'page.jsx') 'const simpleColumns = ["x"];' + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map.Count | Should -Be 0 + } + + It 'does not crash on an empty file' { + Set-Content (Join-Path $script:SrcDir 'empty.jsx') '' + { Get-FrontendColumnMap -SrcDir $script:SrcDir } | Should -Not -Throw + } + + It 'returns empty for a missing src directory without throwing' { + $map = Get-FrontendColumnMap -SrcDir (Join-Path $script:SrcDir 'nope') + $map.Count | Should -Be 0 + } +} + +Describe 'Add-CippResponseSchema - end to end on a temp spec' { + It 'throws when the input spec does not exist' { + { Add-CippResponseSchema -InputSpec 'X:\nope.json' -OutputSpec 'X:\out.json' -FrontendRepoPath '.' } | + Should -Throw '*not found*' + } + + It 'throws when the input spec contains malformed JSON' { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-bad-json-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path (Join-Path $tmp 'Tests' 'Shapes') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $tmp 'src') -Force | Out-Null + $specPath = Join-Path $tmp 'openapi.json' + $outPath = Join-Path $tmp 'out.json' + Set-Content -LiteralPath $specPath -Value '{ "openapi": "3.1.0", "paths": ' + + { Add-CippResponseSchema -InputSpec $specPath -OutputSpec $outPath -FrontendRepoPath $tmp } | + Should -Throw + + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } + + It 'reads, enriches, and writes a real file round-trip' { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-e2e-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $tmp | Out-Null + New-Item -ItemType Directory -Path (Join-Path $tmp 'Tests' 'Shapes') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $tmp 'src') -Force | Out-Null + @{ _metadata = @{ endpoint = 'ListThings' }; shape = @{ id = 'string' } } | ConvertTo-Json -Depth 10 | + Set-Content (Join-Path $tmp 'Tests' 'Shapes' 'ListThings.json') + $specPath = Join-Path $tmp 'openapi.json' + $outPath = Join-Path $tmp 'out.json' + Get-TestSpec -Paths @{ '/api/ListThings' = @{ get = (Get-Operation) } } | ConvertTo-Json -Depth 100 | + Set-Content $specPath + + Add-CippResponseSchema -InputSpec $specPath -OutputSpec $outPath -FrontendRepoPath $tmp | Out-Null + $out = Get-Content $outPath -Raw | ConvertFrom-Json -AsHashtable -Depth 100 + $out['paths']['/api/ListThings']['get']['responses']['200']['content']['application/json']['schema'].properties.Results.items.properties.id.type | + Should -Be 'string' + $out['paths']['/api/ListThings']['get']['operationId'] | Should -Be 'ListThings' + + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } + + It 'is byte-identical when run twice on the same sources (file-level idempotency)' { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-idem-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path (Join-Path $tmp 'Tests' 'Shapes') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $tmp 'src') -Force | Out-Null + @{ _metadata = @{ endpoint = 'ListThings' }; shape = @{ id = 'string'; when = 'datetime' } } | ConvertTo-Json -Depth 10 | + Set-Content (Join-Path $tmp 'Tests' 'Shapes' 'ListThings.json') + $specPath = Join-Path $tmp 'openapi.json' + Get-TestSpec -Paths @{ '/api/ListThings' = @{ get = (Get-Operation) } } | ConvertTo-Json -Depth 100 | + Set-Content $specPath + + $out1 = Join-Path $tmp 'o1.json' + $out2 = Join-Path $tmp 'o2.json' + Add-CippResponseSchema -InputSpec $specPath -OutputSpec $out1 -FrontendRepoPath $tmp | Out-Null + Add-CippResponseSchema -InputSpec $out1 -OutputSpec $out2 -FrontendRepoPath $tmp | Out-Null + (Get-Content $out2 -Raw) | Should -Be (Get-Content $out1 -Raw) + + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } +} diff --git a/Tests/Build/Invoke-BuildTests.ps1 b/Tests/Build/Invoke-BuildTests.ps1 new file mode 100644 index 0000000000000..5c7b7f96b8fd4 --- /dev/null +++ b/Tests/Build/Invoke-BuildTests.ps1 @@ -0,0 +1,46 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Runs static analysis and the OpenAPI response schema Pester suite. + +.DESCRIPTION + Conventional one-command verification for the response schema build stage. + Checks PSScriptAnalyzer warning and error findings for the stage script and + its tests, then runs the focused Pester 5 suite. Exits non-zero on failure. +#> +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$scriptPath = Join-Path $repoRoot '.build' 'Add-OpenApiResponseSchemas.ps1' +$testPath = Join-Path $PSScriptRoot 'Add-OpenApiResponseSchemas.Tests.ps1' + +Write-Information 'Running PSScriptAnalyzer...' -InformationAction Continue +$analysisFindings = @( + foreach ($path in @($scriptPath, $testPath)) { + Invoke-ScriptAnalyzer -Path $path -Severity Warning, Error + } +) + +if ($analysisFindings.Count -gt 0) { + $analysisFindings | Format-Table -AutoSize | Out-String | Write-Information -InformationAction Continue +} + +Write-Information "PSScriptAnalyzer Warning/Error findings: $($analysisFindings.Count)" -InformationAction Continue + +Write-Information 'Running Pester...' -InformationAction Continue +$pesterConfig = New-PesterConfiguration +$pesterConfig.Run.Path = $testPath +$pesterConfig.Run.PassThru = $true +$pesterConfig.Run.Exit = $false +$pesterConfig.Output.Verbosity = 'Detailed' + +$pesterResult = Invoke-Pester -Configuration $pesterConfig + +Write-Information "Pester: Passed=$($pesterResult.PassedCount) Failed=$($pesterResult.FailedCount) Skipped=$($pesterResult.SkippedCount)" -InformationAction Continue + +if ($analysisFindings.Count -gt 0 -or $pesterResult.FailedCount -gt 0) { + exit 1 +} diff --git a/Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 b/Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 index 555008547f998..4d926187dfaf3 100644 --- a/Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 +++ b/Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 @@ -3,7 +3,10 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSetting.ps1' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-AddIntuneReusableSetting.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-AddIntuneReusableSetting.ps1 under Modules/' } class HttpResponseContext { [int]$StatusCode diff --git a/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 b/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 index d29a4530611fb..7889fc9ffbd88 100644 --- a/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 +++ b/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 @@ -3,8 +3,14 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSettingTemplate.ps1' - $MetadataPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1' + # Resolve by name under Modules/ so the tests survive functions moving between modules. + $ModulesRoot = Join-Path $RepoRoot 'Modules' + $FunctionPath = Get-ChildItem -Path $ModulesRoot -Recurse -Filter 'Invoke-AddIntuneReusableSettingTemplate.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-AddIntuneReusableSettingTemplate.ps1 under Modules/' } + $MetadataPath = Get-ChildItem -Path $ModulesRoot -Recurse -Filter 'Remove-CIPPReusableSettingMetadata.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $MetadataPath) { throw 'Could not locate Remove-CIPPReusableSettingMetadata.ps1 under Modules/' } class HttpResponseContext { [int]$StatusCode diff --git a/Tests/Endpoint/Invoke-EditUser.Tests.ps1 b/Tests/Endpoint/Invoke-EditUser.Tests.ps1 new file mode 100644 index 0000000000000..6681b230f4f53 --- /dev/null +++ b/Tests/Endpoint/Invoke-EditUser.Tests.ps1 @@ -0,0 +1,118 @@ +# Pester tests for Invoke-EditUser +# Validates the clear-vs-omit behaviour of the user PATCH body: +# a profile field present in the request (even as null) is forwarded to Graph as a clear, +# while an omitted field is left untouched. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1' + $CorePath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Set-CIPPUser.ps1' + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + # The Functions worker exposes [HttpStatusCode]; map it for standalone test runs. + $Accelerators = [PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ('HttpStatusCode' -as [type])) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + function Get-CippException { param($Exception) $Exception } + # Capture the body of the user PATCH (first call; password reset is a separate call we don't trigger) + function New-GraphPostRequest { + param($uri, $tenantid, $type, $body, [switch]$verbose) + if ($null -eq $script:lastBody) { $script:lastBody = $body } + } + function Add-CIPPScheduledTask { + param($Task, $hidden, $DisallowDuplicateName, $Headers) + $script:lastScheduledTask = $Task + } + + function New-EditRequest { + param([hashtable]$Extra) + $body = [pscustomobject]@{ + id = '11111111-1111-1111-1111-111111111111' + tenantFilter = 'contoso.onmicrosoft.com' + username = 'jdoe' + Domain = 'contoso.com' + displayName = 'John Doe' + } + foreach ($key in $Extra.Keys) { + $body | Add-Member -NotePropertyName $key -NotePropertyValue $Extra[$key] -Force + } + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'EditUser' } + Headers = @{} + Body = $body + } + } + + . $CorePath + . $FunctionPath +} + +Describe 'Invoke-EditUser body construction' { + BeforeEach { + $script:lastBody = $null + } + + It 'clears a field listed in clearProperties' { + $request = New-EditRequest -Extra @{ clearProperties = @('jobTitle') } + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -Match '"jobTitle":null' + } + + It 'omits a field that was neither sent nor listed for clearing' { + $request = New-EditRequest -Extra @{} + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -Not -Match 'jobTitle' + } + + It 'sends a provided value unchanged' { + $request = New-EditRequest -Extra @{ jobTitle = 'Manager' } + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -Match '"jobTitle":"Manager"' + } + + It 'clears a collection field with an empty array' { + $request = New-EditRequest -Extra @{ clearProperties = @('otherMails') } + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -Match '"otherMails":\[\]' + } + + It 'never clears displayName even when listed (Graph rejects it)' { + $request = New-EditRequest -Extra @{ clearProperties = @('displayName') } + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -Not -Match '"displayName":(null|"")' + } +} + +Describe 'Invoke-EditUser scheduling' { + BeforeEach { + $script:lastBody = $null + $script:lastScheduledTask = $null + } + + It 'schedules a Set-CIPPUser task instead of editing immediately when Scheduled.Enabled is set' { + $request = New-EditRequest -Extra @{ Scheduled = @{ Enabled = $true; date = 1234567890 } } + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -BeNullOrEmpty + $script:lastScheduledTask.Command.value | Should -Be 'Set-CIPPUser' + $script:lastScheduledTask.Parameters.UserObj.id | Should -Be $request.Body.id + $script:lastScheduledTask.ScheduledTime | Should -Be 1234567890 + } +} diff --git a/Tests/Endpoint/Invoke-ExecEditMailboxPermissions.Tests.ps1 b/Tests/Endpoint/Invoke-ExecEditMailboxPermissions.Tests.ps1 new file mode 100644 index 0000000000000..f223485d6c55e --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecEditMailboxPermissions.Tests.ps1 @@ -0,0 +1,127 @@ +# Pester tests for Invoke-ExecEditMailboxPermissions +# The endpoint now delegates every request bucket (RemoveFullAccess / AddFullAccess / +# AddFullAccessNoAutoMap / AddSendAs / RemoveSendAs / AddSendOnBehalf / RemoveSendOnBehalf) to +# Set-CIPPMailboxPermission, so these tests assert each bucket maps to the right +# PermissionLevel / Action / AutoMap. The EXO cmdlet mapping, logging, and cache sync are the +# delegate's responsibility and are covered by Set-CIPPMailboxPermission.Tests.ps1. +# +# NOTE: the early `if ($username -eq $null) { exit }` guard is deliberately NOT exercised - `exit` +# inside a dot-sourced function terminates the Pester runspace, so it cannot be tested in-process. +# Every test below supplies a userID. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecEditMailboxPermissions.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ExecEditMailboxPermissions.ps1 under Modules/' } + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + # The function uses the short [HttpStatusCode] (the Functions host supplies `using namespace + # System.Net`). Register a type accelerator so it resolves when the function is dot-sourced here. + $TypeAccelerators = [PowerShell].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ([System.Management.Automation.PSTypeName]'HttpStatusCode').Type) { + $TypeAccelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Set-CIPPMailboxPermission { param($UserId, $AccessUser, $PermissionLevel, $Action, $AutoMap, $TenantFilter, $APIName, $Headers) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + + . $FunctionPath + + # Build a request whose body carries a single bucket of delegates (mirrors the frontend's + # { value = @(...) } shape that the function reads via ($Request.body.).value). + function New-EditRequest { + param([string]$Bucket, [string[]]$Users, [string]$UserID = 'shared@contoso.com') + $body = [pscustomobject]@{ userID = $UserID; tenantfilter = 'contoso.com' } + $body | Add-Member -NotePropertyName $Bucket -NotePropertyValue ([pscustomobject]@{ value = $Users }) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecEditMailboxPermissions' } + Headers = @{ Authorization = 'token' } + Body = $body + } + } +} + +Describe 'Invoke-ExecEditMailboxPermissions' { + BeforeEach { + Mock -CommandName Set-CIPPMailboxPermission -MockWith { "$Action $PermissionLevel for $AccessUser" } + Mock -CommandName Write-LogMessage -MockWith { } + } + + It 'returns OK and passes the mailbox UPN through as the identity' { + $response = Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddFullAccess' -Users @('user@contoso.com')) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $UserId -eq 'shared@contoso.com' -and $TenantFilter -eq 'contoso.com' } + } + + It 'RemoveFullAccess -> FullAccess / Remove' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'RemoveFullAccess' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $PermissionLevel -eq 'FullAccess' -and $Action -eq 'Remove' -and $AccessUser -eq 'user@contoso.com' + } + } + + It 'AddFullAccess -> FullAccess / Add with AutoMap $true' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddFullAccess' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $PermissionLevel -eq 'FullAccess' -and $Action -eq 'Add' -and $AutoMap -eq $true + } + } + + It 'AddFullAccessNoAutoMap -> FullAccess / Add with AutoMap $false' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddFullAccessNoAutoMap' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $PermissionLevel -eq 'FullAccess' -and $Action -eq 'Add' -and $AutoMap -eq $false + } + } + + It 'AddSendAs -> SendAs / Add' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddSendAs' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $PermissionLevel -eq 'SendAs' -and $Action -eq 'Add' } + } + + It 'RemoveSendAs -> SendAs / Remove' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'RemoveSendAs' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $PermissionLevel -eq 'SendAs' -and $Action -eq 'Remove' } + } + + It 'AddSendOnBehalf -> SendOnBehalf / Add' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddSendOnBehalf' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $PermissionLevel -eq 'SendOnBehalf' -and $Action -eq 'Add' } + } + + It 'RemoveSendOnBehalf -> SendOnBehalf / Remove' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'RemoveSendOnBehalf' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $PermissionLevel -eq 'SendOnBehalf' -and $Action -eq 'Remove' } + } + + It 'processes every user in a multi-user bucket and collects their results' { + $response = Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddSendAs' -Users @('a@contoso.com', 'b@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 2 -Exactly -ParameterFilter { $PermissionLevel -eq 'SendAs' -and $Action -eq 'Add' } + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body.Results | Should -Contain 'Add SendAs for a@contoso.com' + $response.Body.Results | Should -Contain 'Add SendAs for b@contoso.com' + } + + It 'surfaces the delegate failure string in Results and still returns OK' { + Mock -CommandName Set-CIPPMailboxPermission -MockWith { 'Failed to Add FullAccess for user@contoso.com on shared@contoso.com: boom' } + + $response = Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddFullAccess' -Users @('user@contoso.com')) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + ($response.Body.Results -join "`n") | Should -Match 'Failed to Add FullAccess for user@contoso.com on shared@contoso.com: boom' + } +} diff --git a/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 b/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 new file mode 100644 index 0000000000000..cf12187b65f0f --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 @@ -0,0 +1,368 @@ +# Pester tests for Invoke-ExecModifyMBPerms +# The endpoint delegates the permission-level -> EXO cmdlet/parameter mapping to +# Set-CIPPMailboxPermission (dot-sourced below and called in -AsCmdletObject mode), so these tests +# focus on what the endpoint owns: the single-operation vs bulk (New-ExoBulkRequest + GUID mapping) +# execution paths, the bulk-failure fallback, the three accepted input shapes, the user-lookup +# fallback, and the request guards. The mapping itself is covered by Set-CIPPMailboxPermission.Tests.ps1. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecModifyMBPerms.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ExecModifyMBPerms.ps1 under Modules/' } + + # Dot-source the REAL Set-CIPPMailboxPermission so -AsCmdletObject produces real mappings - the + # endpoint now delegates the level -> cmdlet mapping to it. Its -AsCmdletObject path only builds a + # hashtable and returns early, so none of the EXO / log stubs below are exercised by it. + $PermissionFunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Set-CIPPMailboxPermission.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $PermissionFunctionPath) { throw 'Could not locate Set-CIPPMailboxPermission.ps1 under Modules/' } + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + # The function uses the short [HttpStatusCode]; register a type accelerator so it resolves here. + $TypeAccelerators = [PowerShell].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ([System.Management.Automation.PSTypeName]'HttpStatusCode').Type) { + $TypeAccelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function New-GraphGetRequest { param($uri, $tenantid) } + function New-ExoRequest { param($Anchor, $tenantid, $cmdlet, $cmdParams) } + function New-ExoBulkRequest { param($tenantid, $cmdletArray, $ReturnWithCommand) } + function Get-CippException { param($Exception) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + function Sync-CIPPMailboxPermissionCache { param($TenantFilter, $MailboxIdentity, $User, $PermissionType, $Action) } + + . $PermissionFunctionPath + . $FunctionPath + + # Build a request in the bulk 'mailboxRequests' shape. + function New-ModifyRequest { + param($Mailboxes) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecModifyMBPerms' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]@{ tenantFilter = 'contoso.com'; mailboxRequests = $Mailboxes } + } + } + + # Build a single mailbox request object with one permission. + function New-Perm { + param($Level, $Modification, $TargetUser = 'user@contoso.com', $AutoMap = $true) + [pscustomobject]@{ + PermissionLevel = $Level + Modification = $Modification + AutoMap = $AutoMap + UserID = @([pscustomobject]@{ value = $TargetUser }) + } + } + function New-Mailbox { + param($UserID = 'shared@contoso.com', $Permissions) + [pscustomobject]@{ userID = $UserID; permissions = $Permissions } + } +} + +Describe 'Invoke-ExecModifyMBPerms' { + BeforeEach { + Mock -CommandName New-GraphGetRequest -MockWith { [pscustomobject]@{ userPrincipalName = 'shared@contoso.com' } } + Mock -CommandName New-ExoRequest -MockWith { } + Mock -CommandName New-ExoBulkRequest -MockWith { + param($tenantid, $cmdletArray, $ReturnWithCommand) + # Echo each operation back as a success keyed by cmdlet name (GUID round-trips so the + # function can map results to its metadata). + $h = @{} + foreach ($c in $cmdletArray) { + $name = $c.CmdletInput.CmdletName + if (-not $h.ContainsKey($name)) { $h[$name] = @() } + $h[$name] += [pscustomobject]@{ OperationGuid = $c.OperationGuid } + } + $h + } + Mock -CommandName Get-CippException -MockWith { [pscustomobject]@{ NormalizedError = 'boom' } } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Sync-CIPPMailboxPermissionCache -MockWith { } + } + + Context 'Single-operation execution and per-level mapping' { + It 'FullAccess Add -> individual New-ExoRequest with Add-MailboxPermission' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Add-MailboxPermission' -and $cmdParams.user -eq 'user@contoso.com' -and + $cmdParams.Identity -eq 'shared@contoso.com' -and $cmdParams.automapping -eq $true + } + Should -Invoke New-ExoBulkRequest -Times 0 -Exactly + $response.Body.Results | Should -Contain 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + } + + It 'FullAccess Remove -> Remove-MailboxPermission' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Remove')) + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Remove-MailboxPermission' } + $response.Body.Results | Should -Contain 'Removed user@contoso.com FullAccess from shared@contoso.com' + } + + It 'SendAs Add -> Add-RecipientPermission with Trustee' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'SendAs' -Modification 'Add')) + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Add-RecipientPermission' -and $cmdParams.Trustee -eq 'user@contoso.com' } + } + + It 'SendOnBehalf Add -> Set-Mailbox with GrantSendonBehalfTo add hashtable' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'SendOnBehalf' -Modification 'Add')) + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-Mailbox' -and $cmdParams.GrantSendonBehalfTo.add -eq 'user@contoso.com' -and + $cmdParams.GrantSendonBehalfTo['@odata.type'] -eq '#Exchange.GenericHashTable' + } + } + + It 'SendOnBehalf Remove -> Set-Mailbox with GrantSendonBehalfTo remove hashtable' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'SendOnBehalf' -Modification 'Remove')) + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Set-Mailbox' -and $cmdParams.GrantSendonBehalfTo.remove -eq 'user@contoso.com' } + } + + It 'default-level Remove () -> Remove-MailboxPermission with that access right' -ForEach @( + @{ Level = 'ReadPermission' } + @{ Level = 'ExternalAccount' } + @{ Level = 'DeleteItem' } + @{ Level = 'ChangePermission' } + @{ Level = 'ChangeOwner' } + ) { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level $Level -Modification 'Remove')) + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Remove-MailboxPermission' -and $cmdParams.accessRights -contains $Level } + } + + It 'default-level Add produces no cmdlet -> OK with a no-op message' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'ReadPermission' -Modification 'Add')) + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 0 -Exactly + $response.Body.Results | Should -Contain 'No valid permission changes to process' + } + } + + Context 'Bulk execution path' { + It 'two operations -> New-ExoBulkRequest with GUID-mapped results, no individual calls' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @( + (New-Perm -Level 'FullAccess' -Modification 'Add') + (New-Perm -Level 'SendAs' -Modification 'Add') + )) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke New-ExoBulkRequest -Times 1 -Exactly + Should -Invoke New-ExoRequest -Times 0 -Exactly + $response.Body.Results | Should -Contain 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + $response.Body.Results | Should -Contain 'Granted user@contoso.com SendAs permissions to shared@contoso.com' + } + + It 'maps a per-operation error from the bulk result to an error string' { + Mock -CommandName New-ExoBulkRequest -MockWith { + param($tenantid, $cmdletArray, $ReturnWithCommand) + $h = @{} + foreach ($c in $cmdletArray) { + $name = $c.CmdletInput.CmdletName + if (-not $h.ContainsKey($name)) { $h[$name] = @() } + $h[$name] += [pscustomobject]@{ OperationGuid = $c.OperationGuid; error = 'kaboom' } + } + $h + } + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @( + (New-Perm -Level 'FullAccess' -Modification 'Add') + (New-Perm -Level 'SendAs' -Modification 'Add') + )) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + ($response.Body.Results -join "`n") | Should -Match 'Error processing FullAccess for user@contoso.com on shared@contoso.com: boom' + } + + It 'falls back to individual New-ExoRequest calls when the bulk request throws' { + Mock -CommandName New-ExoBulkRequest -MockWith { throw 'bulk endpoint down' } + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @( + (New-Perm -Level 'FullAccess' -Modification 'Add') + (New-Perm -Level 'SendAs' -Modification 'Add') + )) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke New-ExoRequest -Times 2 -Exactly + $response.Body.Results | Should -Contain 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + } + } + + Context 'Input shapes' { + It 'accepts the legacy single-mailbox format (userID + permissions on the body)' { + $req = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecModifyMBPerms' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]@{ + tenantFilter = 'contoso.com' + userID = 'shared@contoso.com' + permissions = @(New-Perm -Level 'FullAccess' -Modification 'Add') + } + } + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Add-MailboxPermission' } + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + } + } + + Context 'User lookup' { + It 'falls back to a userPrincipalName filter query when the direct Graph lookup fails' { + Mock -CommandName New-GraphGetRequest -MockWith { [pscustomobject]@{ value = @([pscustomobject]@{ userPrincipalName = 'shared@contoso.com' }) } } -ParameterFilter { $uri -like '*filter*' } + Mock -CommandName New-GraphGetRequest -MockWith { throw 'direct lookup failed' } + + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke New-GraphGetRequest -ParameterFilter { $uri -like '*filter*' } + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Add-MailboxPermission' } + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + } + + It 'records a "could not find user" message when both lookups fail' { + # Graph fails for 'ghost' on both the direct and filter lookups; a second valid mailbox + # keeps CmdletArray non-empty so the specific message is not discarded by the + # "No valid permission changes" guard (which returns only a generic string). + Mock -CommandName New-GraphGetRequest -MockWith { throw 'not found' } -ParameterFilter { $uri -like '*ghost@contoso.com*' } + + $req = New-ModifyRequest -Mailboxes @( + (New-Mailbox -UserID 'ghost@contoso.com' -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + (New-Mailbox -UserID 'valid@contoso.com' -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + ) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + ($response.Body.Results -join "`n") | Should -Match 'Could not find user ghost@contoso.com' + } + } + + Context 'Cache sync' { + # The endpoint executes cmdlets itself (bypassing Set-CIPPMailboxPermission's execute-mode + # sync), so it must sync the reporting-DB cache for every operation that succeeded. + It 'syncs the cache after a successful single Remove' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Remove')) + + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { + $TenantFilter -eq 'contoso.com' -and $MailboxIdentity -eq 'shared@contoso.com' -and + $User -eq 'user@contoso.com' -and $PermissionType -eq 'FullAccess' -and $Action -eq 'Remove' + } + } + + It 'syncs with the resolved UPN when the request identifies the mailbox by object id' { + # Cached permission rows are keyed by mailbox UPN, so the sync must use what the + # Graph lookup resolved (BeforeEach mock: shared@contoso.com), not the raw request id. + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -UserID '11111111-2222-3333-4444-555555555555' -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Remove')) + + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { + $MailboxIdentity -eq 'shared@contoso.com' + } + } + + It 'syncs with Action Add for additions' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'SendAs' -Modification 'Add')) + + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { + $PermissionType -eq 'SendAs' -and $Action -eq 'Add' + } + } + + It 'syncs every successful operation in a bulk request' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @( + (New-Perm -Level 'FullAccess' -Modification 'Remove') + (New-Perm -Level 'SendOnBehalf' -Modification 'Remove') + )) + + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { $PermissionType -eq 'FullAccess' } + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { $PermissionType -eq 'SendOnBehalf' } + } + + It 'does not sync operations that failed in the bulk response' { + # FullAccess Remove (Remove-MailboxPermission) succeeds; SendAs Remove + # (Remove-RecipientPermission) comes back with an error attached. + Mock -CommandName New-ExoBulkRequest -MockWith { + param($tenantid, $cmdletArray, $ReturnWithCommand) + $h = @{} + foreach ($c in $cmdletArray) { + $name = $c.CmdletInput.CmdletName + if (-not $h.ContainsKey($name)) { $h[$name] = @() } + $entry = [pscustomobject]@{ OperationGuid = $c.OperationGuid } + if ($name -eq 'Remove-RecipientPermission') { + $entry | Add-Member -NotePropertyName error -NotePropertyValue 'denied' + } + $h[$name] += $entry + } + $h + } + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @( + (New-Perm -Level 'FullAccess' -Modification 'Remove') + (New-Perm -Level 'SendAs' -Modification 'Remove') + )) + + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { $PermissionType -eq 'FullAccess' } + } + + It 'does not sync non-cacheable permission levels' { + # Comma-joined levels are split per operation; ReadPermission executes but has no + # cache representation, so only the FullAccess half syncs. + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'FullAccess, ReadPermission' -Modification 'Remove')) + + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { $PermissionType -eq 'FullAccess' } + } + } + + Context 'Guards' { + It 'returns BadRequest when no mailbox requests are provided' { + $req = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecModifyMBPerms' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]@{ tenantFilter = 'contoso.com' } + } + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $response.Body.Results | Should -Contain 'No mailbox requests provided' + } + + It 'skips a mailbox request that is missing its userID' { + # A second valid mailbox keeps CmdletArray non-empty so the "Skipped" message survives + # (the empty-array guard would otherwise replace Results with a generic string). + $req = New-ModifyRequest -Mailboxes @( + (New-Mailbox -UserID '' -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + (New-Mailbox -UserID 'valid@contoso.com' -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + ) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke New-ExoRequest -Times 1 -Exactly + ($response.Body.Results -join "`n") | Should -Match 'Skipped mailbox with missing userID' + } + } +} diff --git a/Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 b/Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 index 3813c7de946b9..095fb82996528 100644 --- a/Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 +++ b/Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 @@ -3,7 +3,10 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettingTemplates.ps1' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ListIntuneReusableSettingTemplates.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ListIntuneReusableSettingTemplates.ps1 under Modules/' } class HttpResponseContext { [int]$StatusCode diff --git a/Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 b/Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 index 5a66f62a43a44..730b9ee2f4364 100644 --- a/Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 +++ b/Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 @@ -1,66 +1,82 @@ # Pester tests for Invoke-ListIntuneReusableSettings -# Validates listing and filtering of live reusable settings +# Validates listing of live reusable settings, the report-DB branch, and tenant validation. BeforeAll { + # Resolve by name under Modules/ so the test survives the function moving between modules. $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettings.ps1' + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ListIntuneReusableSettings.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ListIntuneReusableSettings.ps1 under Modules/' } + # Azure Functions binding types do not exist outside the Functions host - fake them. class HttpResponseContext { [int]$StatusCode [object]$Body } - Add-Type -AssemblyName System.Net.Http - - function Write-LogMessage { param($headers, $API, $message, $sev, $LogData) } - function Get-CippException { param($Exception) $Exception } - function New-GraphGETRequest { param($uri, $tenantid) } + # Stub every CIPP helper the function calls so Pester's Mock has a command to replace. + function Get-CippException { param($Exception) @{ NormalizedError = $Exception } } + function Get-CIPPIntuneReusableSettingsReport { param($TenantFilter) } + function New-GraphGetRequest { param($uri, $tenantid) } + function Write-LogMessage { param($headers, $API, $message, $Sev, $LogData) } . $FunctionPath } Describe 'Invoke-ListIntuneReusableSettings' { BeforeEach { - $script:lastUri = $null + $script:logs = @() + Mock -CommandName Write-LogMessage -MockWith { $script:logs += $message } + Mock -CommandName Get-CippException -MockWith { param($Exception) @{ NormalizedError = "$Exception" } } } - It 'returns reusable settings with raw JSON when tenantFilter is provided' { - Mock -CommandName New-GraphGETRequest -MockWith { + It 'returns OK and the live Graph results on the happy path' { + Mock -CommandName New-GraphGetRequest -MockWith { @( - [pscustomobject]@{ id = 'one'; displayName = 'A Item'; description = 'A description'; version = 1 }, - [pscustomobject]@{ id = 'two'; displayName = 'Z Item'; description = 'Z description'; version = 2 } + [pscustomobject]@{ id = 'setting-1'; displayName = 'Reusable A' }, + [pscustomobject]@{ id = 'setting-2'; displayName = 'Reusable B' } ) } - $request = [pscustomobject]@{ query = @{ tenantFilter = 'contoso.onmicrosoft.com' } } + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListIntuneReusableSettings' } + Headers = @{ Authorization = 'token' } + Query = [pscustomobject]@{ tenantFilter = 'contoso.onmicrosoft.com' } + } + $response = Invoke-ListIntuneReusableSettings -Request $request -TriggerMetadata $null $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) - $response.Body.Count | Should -Be 2 - $response.Body[0].displayName | Should -Be 'A Item' + $response.Body | Should -HaveCount 2 + # The function enriches each item with a compact RawJSON copy. $response.Body[0].RawJSON | Should -Not -BeNullOrEmpty + Should -Invoke New-GraphGetRequest -ParameterFilter { $uri -like '*reusablePolicySettings*' -and $tenantid -eq 'contoso.onmicrosoft.com' } -Times 1 } - It 'requests a specific setting when ID is provided' { - Mock -CommandName New-GraphGETRequest -MockWith { - param($uri, $tenantid) - $script:lastUri = $uri - @([pscustomobject]@{ id = 'beta'; displayName = 'Beta' }) + It 'reads from the reporting DB when UseReportDB is true' { + Mock -CommandName Get-CIPPIntuneReusableSettingsReport -MockWith { + @([pscustomobject]@{ id = 'cached-1'; displayName = 'Cached' }) + } + Mock -CommandName New-GraphGetRequest -MockWith { throw 'live Graph should not be called' } + + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListIntuneReusableSettings' } + Headers = @{ Authorization = 'token' } + Query = [pscustomobject]@{ tenantFilter = 'contoso.onmicrosoft.com'; UseReportDB = 'true' } } - $request = [pscustomobject]@{ query = @{ tenantFilter = 'contoso.onmicrosoft.com'; ID = 'beta' } } $response = Invoke-ListIntuneReusableSettings -Request $request -TriggerMetadata $null - $lastUri | Should -Match '/reusablePolicySettings/beta' - $response.Body.Count | Should -Be 1 - $response.Body[0].displayName | Should -Be 'Beta' - $response.Body[0].RawJSON | Should -Match '"id":"beta"' + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Get-CIPPIntuneReusableSettingsReport -Times 1 + Should -Invoke New-GraphGetRequest -Times 0 } It 'returns BadRequest when tenantFilter is missing' { - $request = [pscustomobject]@{ query = @{} } + $request = [pscustomobject]@{ Body = [pscustomobject]@{} ; Query = [pscustomobject]@{} } $response = Invoke-ListIntuneReusableSettings -Request $request -TriggerMetadata $null $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $response.Body.Results | Should -Match 'tenantFilter is required' } } diff --git a/Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 b/Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 index da1acacfab221..290ab4bb0a710 100644 --- a/Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 +++ b/Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 @@ -2,8 +2,14 @@ # Validates deletion and required parameters BeforeAll { + # Locate the function by name under Modules/ so the test survives the function being + # moved between modules (it has already moved from CIPPCore to CIPPHTTP once). $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveIntuneReusableSetting.ps1' + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-RemoveIntuneReusableSetting.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { + throw 'Could not locate Invoke-RemoveIntuneReusableSetting.ps1 under Modules/' + } class HttpResponseContext { [int]$StatusCode diff --git a/Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 b/Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 index e39bb1dfa0b7f..0e12a26d892d2 100644 --- a/Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 +++ b/Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 @@ -3,7 +3,10 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveIntuneReusableSettingTemplate.ps1' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-RemoveIntuneReusableSettingTemplate.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-RemoveIntuneReusableSettingTemplate.ps1 under Modules/' } class HttpResponseContext { [int]$StatusCode @@ -15,6 +18,8 @@ BeforeAll { function Remove-AzDataTableEntity { param([switch]$Force, $Entity) $script:lastRemoved = $Entity; $script:lastForce = $Force } function Write-LogMessage { param($Headers, $API, $message, $sev, $LogData) $script:logs += $message } function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = $Exception } } + # The ID is sanitised for OData before the table lookup; stub it to pass the value through. + function ConvertTo-CIPPODataFilterValue { param($Value, $Type) $Value } . $FunctionPath } diff --git a/Tests/Invoke-CippTests.ps1 b/Tests/Invoke-CippTests.ps1 new file mode 100644 index 0000000000000..e6c672ee710f6 --- /dev/null +++ b/Tests/Invoke-CippTests.ps1 @@ -0,0 +1,101 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Runs the CIPP-API Pester test suite. + +.DESCRIPTION + Thin, opinionated wrapper around Pester 5 so the backend tests can be run with a + single command regardless of the caller's current directory. Pester only discovers + files named '*.Tests.ps1', so the non-Pester helper scripts that also live under + Tests/ (Test-ODataFilterInjection.ps1, Test-SchedulerBlocklist.ps1) are ignored + automatically. + +.PARAMETER Path + Limit the run to a specific test file or folder (relative to the repo root or absolute). + Defaults to the entire Tests/ directory. + +.PARAMETER Tag + Only run It/Describe blocks carrying one of these Pester tags. + +.PARAMETER CI + Emit a NUnit result file (Tests/TestResults.xml). Use this from automation / GitHub Actions. + Note: a non-zero exit on failure is the DEFAULT (not gated on -CI) - see -NoExitCode. + +.PARAMETER NoExitCode + Do not set the process exit code from the test result. Use this in an interactive session + where a failing run should not terminate your shell. By default the script exits with the + failed-test count so scripts/agents relying on process status see red as red. + +.PARAMETER Coverage + Also collect code coverage over Modules/**/Public and write Tests/coverage.xml. + +.EXAMPLE + pwsh CIPP-API/Tests/Invoke-CippTests.ps1 + Runs the whole suite with detailed console output. + +.EXAMPLE + pwsh CIPP-API/Tests/Invoke-CippTests.ps1 -Path Tests/Endpoint -CI + Runs only the Endpoint tests and produces a CI result file + exit code. +#> +[CmdletBinding()] +param( + [string[]]$Path, + [string[]]$Tag, + [switch]$CI, + [switch]$NoExitCode, + [switch]$Coverage +) + +$ErrorActionPreference = 'Stop' + +# Tests/ is one level under the repo root. +$TestsRoot = $PSScriptRoot +$RepoRoot = Split-Path -Parent $TestsRoot + +# Pester 5 is required for the Should -Invoke / -ParameterFilter syntax the suite uses. +$pester = Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version -ge [version]'5.0.0' } | Sort-Object Version -Descending | Select-Object -First 1 +if (-not $pester) { + throw "Pester 5+ is required but was not found. Install it with: Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser" +} +Import-Module $pester -ErrorAction Stop + +# Resolve -Path entries against the repo root when they are not already absolute, +# so callers can pass repo-relative paths like 'Tests/Endpoint' from anywhere. +$runPaths = if ($Path) { + foreach ($p in $Path) { + if ([System.IO.Path]::IsPathRooted($p)) { $p } + elseif (Test-Path -LiteralPath (Join-Path $RepoRoot $p)) { Join-Path $RepoRoot $p } + else { $p } + } +} else { + $TestsRoot +} + +$config = New-PesterConfiguration +$config.Run.Path = $runPaths +$config.Run.PassThru = $true +$config.Output.Verbosity = 'Detailed' + +if ($Tag) { + $config.Filter.Tag = $Tag +} + +if ($CI) { + $config.TestResult.Enabled = $true + $config.TestResult.OutputFormat = 'NUnitXml' + $config.TestResult.OutputPath = Join-Path $TestsRoot 'TestResults.xml' +} + +if ($Coverage) { + $config.CodeCoverage.Enabled = $true + $config.CodeCoverage.Path = Join-Path $RepoRoot 'Modules' + $config.CodeCoverage.OutputPath = Join-Path $TestsRoot 'coverage.xml' +} + +$result = Invoke-Pester -Configuration $config + +# Surface a real exit code by default so agents / pipelines that key off process status +# see a red suite as red. -NoExitCode opts out for interactive shells. +if (-not $NoExitCode) { + exit $result.FailedCount +} diff --git a/Tests/New-CippTest.ps1 b/Tests/New-CippTest.ps1 new file mode 100644 index 0000000000000..cdfdb80cfe377 --- /dev/null +++ b/Tests/New-CippTest.ps1 @@ -0,0 +1,263 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Scaffolds a Pester test for a CIPP-API backend function. + +.DESCRIPTION + Locates a function by name under Modules/, parses it with the PowerShell AST, and + emits a starter .Tests.ps1 under Tests// that already contains: + * a move-resilient path resolver (find the function by filename, never hardcode a module), + * stub functions for every CIPP helper the function calls (so Pester Mock can replace them), + * fake HttpResponseContext / HttpRequestContext classes for HTTP endpoints, + * a Describe block with placeholder It cases (happy path + one per required field), + each marked with # TODO where a human or Claude fills in real assertions. + + It deliberately does NOT try to write meaningful assertions - that requires reading the + function's intent. The goal is to remove the ~30 lines of boilerplate and dependency + guesswork, then hand a runnable skeleton to the author. + +.PARAMETER FunctionName + The function to scaffold a test for, e.g. Invoke-ListIntuneReusableSettings. + +.PARAMETER Force + Overwrite an existing test file. + +.PARAMETER Area + Override the auto-detected Tests/ subfolder (Endpoint, Standards, Alerts, Private). + +.EXAMPLE + pwsh CIPP-API/Tests/New-CippTest.ps1 -FunctionName Invoke-ListIntuneReusableSettings +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$FunctionName, + + [switch]$Force, + + [ValidateSet('Endpoint', 'Standards', 'Alerts', 'Private')] + [string]$Area +) + +$ErrorActionPreference = 'Stop' + +$TestsRoot = $PSScriptRoot +$RepoRoot = Split-Path -Parent $TestsRoot +$ModulesRoot = Join-Path $RepoRoot 'Modules' + +# --- 1. Locate the function file by name (this is what avoids hardcoded, rot-prone paths) --- +$matches = @(Get-ChildItem -Path $ModulesRoot -Recurse -Filter "$FunctionName.ps1" -File -ErrorAction SilentlyContinue) +if ($matches.Count -eq 0) { + throw "No file named '$FunctionName.ps1' found under $ModulesRoot. Check the function name." +} +if ($matches.Count -gt 1) { + $list = ($matches.FullName | ForEach-Object { " $_" }) -join "`n" + throw "Multiple files named '$FunctionName.ps1' found - cannot disambiguate:`n$list" +} +$FunctionFile = $matches[0].FullName + +# --- 2. Parse with the AST --- +$tokens = $null +$parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($FunctionFile, [ref]$tokens, [ref]$parseErrors) + +$funcAst = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $FunctionName + }, $true) +if (-not $funcAst) { + throw "File '$FunctionFile' does not define a function called '$FunctionName'." +} + +# Endpoint detection: HTTP entrypoints take $Request and $TriggerMetadata. +$paramNames = @() +if ($funcAst.Body.ParamBlock) { + $paramNames = $funcAst.Body.ParamBlock.Parameters.Name.VariablePath.UserPath +} +$isEndpoint = ($paramNames -contains 'Request' -and $paramNames -contains 'TriggerMetadata') + +# --- 3. Discover called CIPP helpers (to emit as stubbable functions) --- +# Only stub commands that look like CIPP helpers; never stub PowerShell built-ins. +$helperPattern = '(?i)(cipp|graph.*request|azdatatable|write-logmessage|write-alert|write-standardsalert|get-normalizederror)' +$commandAsts = $funcAst.FindAll({ + param($node) $node -is [System.Management.Automation.Language.CommandAst] + }, $true) +$helpers = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($c in $commandAsts) { + $name = $c.GetCommandName() + if ($name -and $name -ne $FunctionName -and $name -match $helperPattern) { + [void]$helpers.Add($name) + } +} + +# --- 4. Discover input fields read from $Request.Body.* / $Request.Query.* --- +# Track which container each field comes from so the happy-path request seeds the right bag. +$bodyFields = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$queryFields = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +if ($isEndpoint) { + $memberAsts = $funcAst.FindAll({ + param($node) $node -is [System.Management.Automation.Language.MemberExpressionAst] + }, $true) + foreach ($m in $memberAsts) { + $inner = $m.Expression + if ($inner -is [System.Management.Automation.Language.MemberExpressionAst] -and + $inner.Expression -is [System.Management.Automation.Language.VariableExpressionAst] -and + $inner.Expression.VariablePath.UserPath -eq 'Request' -and + $inner.Member.Value -in @('Body', 'Query') -and + $m.Member -is [System.Management.Automation.Language.StringConstantExpressionAst]) { + $field = $m.Member.Value + if ($inner.Member.Value -eq 'Body') { [void]$bodyFields.Add($field) } + else { [void]$queryFields.Add($field) } + } + } +} +$usesBody = $bodyFields.Count -gt 0 +$usesQuery = $queryFields.Count -gt 0 + +# --- 5. Discover required-field guards from ' is required' literals --- +$requiredFields = [System.Collections.Generic.List[string]]::new() +$strings = $funcAst.FindAll({ + param($node) $node -is [System.Management.Automation.Language.StringConstantExpressionAst] + }, $true) +foreach ($s in $strings) { + if ($s.Value -match '^(\w+)\s+is required') { + if (-not $requiredFields.Contains($Matches[1])) { $requiredFields.Add($Matches[1]) } + } +} + +# --- 6. Determine the Area (Tests/ subfolder) --- +if (-not $Area) { + $rel = $FunctionFile.Substring($ModulesRoot.Length).Replace('\', '/') + $Area = switch -Regex ($rel) { + 'Entrypoints/HTTP Functions' { 'Endpoint'; break } + '/Standards/' { 'Standards'; break } + '/Alerts/' { 'Alerts'; break } + default { 'Private' } + } +} +$OutDir = Join-Path $TestsRoot $Area +$OutFile = Join-Path $OutDir "$FunctionName.Tests.ps1" +if ((Test-Path $OutFile) -and -not $Force) { + throw "Test already exists: $OutFile (use -Force to overwrite)." +} + +# --- 7. Build the test file content --- +$nl = "`n" +$sb = [System.Text.StringBuilder]::new() +[void]$sb.Append("# Pester tests for $FunctionName$nl") +[void]$sb.Append("# Scaffolded by New-CippTest.ps1 - replace every # TODO with real assertions.$nl$nl") +[void]$sb.Append("BeforeAll {$nl") +[void]$sb.Append(" # Resolve by name under Modules/ so the test survives the function moving between modules.$nl") +[void]$sb.Append(" `$RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent `$PSCommandPath))$nl") +[void]$sb.Append(" `$FunctionPath = Get-ChildItem -Path (Join-Path `$RepoRoot 'Modules') -Recurse -Filter '$FunctionName.ps1' -File -ErrorAction SilentlyContinue |$nl") +[void]$sb.Append(" Select-Object -First 1 -ExpandProperty FullName$nl") +[void]$sb.Append(" if (-not `$FunctionPath) { throw 'Could not locate $FunctionName.ps1 under Modules/' }$nl$nl") + +if ($isEndpoint) { + [void]$sb.Append(" # Azure Functions binding types do not exist outside the Functions host - fake them.$nl") + [void]$sb.Append(" class HttpResponseContext {$nl [int]`$StatusCode$nl [object]`$Body$nl }$nl$nl") +} + +if ($helpers.Count -gt 0) { + [void]$sb.Append(" # Stub every CIPP helper the function calls so Pester's Mock has a command to replace.$nl") + foreach ($h in $helpers) { + [void]$sb.Append(" function $h { }$nl") + } + [void]$sb.Append($nl) +} + +[void]$sb.Append(" . `$FunctionPath$nl") +[void]$sb.Append("}$nl$nl") + +[void]$sb.Append("Describe '$FunctionName' {$nl") +[void]$sb.Append(" BeforeEach {$nl") +foreach ($h in $helpers) { + [void]$sb.Append(" Mock -CommandName $h -MockWith { } # TODO: return realistic data / capture args$nl") +} +[void]$sb.Append(" }$nl$nl") + +if ($isEndpoint) { + # Render a container literal (Body/Query) seeded with the fields it actually exposes. + function Format-Container { + param([System.Collections.Generic.SortedSet[string]]$Fields) + $pairs = foreach ($f in $Fields) { + if ($f -ieq 'tenantFilter') { "$f = 'contoso.onmicrosoft.com'" } else { "$f = 'TODO'" } + } + '[pscustomobject]@{ ' + ($pairs -join '; ') + ' }' + } + + # Happy-path case with a sample request built from discovered input fields. + [void]$sb.Append(" It 'returns OK on the happy path' {$nl") + [void]$sb.Append(" `$request = [pscustomobject]@{$nl") + [void]$sb.Append(" Params = @{ CIPPEndpoint = '$($FunctionName -replace '^Invoke-', '')' }$nl") + [void]$sb.Append(" Headers = @{ Authorization = 'token' }$nl") + if ($usesBody) { + [void]$sb.Append(" Body = $(Format-Container $bodyFields)$nl") + } + if ($usesQuery) { + [void]$sb.Append(" Query = $(Format-Container $queryFields)$nl") + } + [void]$sb.Append(" }$nl$nl") + [void]$sb.Append(" `$response = $FunctionName -Request `$request -TriggerMetadata `$null$nl$nl") + [void]$sb.Append(" `$response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK)$nl") + [void]$sb.Append(" # TODO: assert `$response.Body and Should -Invoke the helpers with -ParameterFilter$nl") + [void]$sb.Append(" }$nl") + + # Negative cases: endpoints validate required fields in order, so an all-empty request + # would always trip the FIRST guard. Instead start from a baseline that satisfies every + # guard, then drop ONLY the field under test so its specific guard is the one that fires. + function Get-FieldContainer { + param([string]$Field) + if ($bodyFields.Contains($Field)) { 'Body' } elseif ($queryFields.Contains($Field)) { 'Query' } else { 'Body' } + } + function Format-RequiredRequest { + param([string]$Omit) + $bodyPairs = [System.Collections.Generic.List[string]]::new() + $queryPairs = [System.Collections.Generic.List[string]]::new() + foreach ($f in $requiredFields) { + if ($f -ieq $Omit) { continue } + $val = if ($f -ieq 'tenantFilter') { "'contoso.onmicrosoft.com'" } else { "'TODO'" } + if ((Get-FieldContainer $f) -eq 'Body') { $bodyPairs.Add("$f = $val") } else { $queryPairs.Add("$f = $val") } + } + $b = '[pscustomobject]@{' + $(if ($bodyPairs.Count) { ' ' + ($bodyPairs -join '; ') + ' ' } else { '' }) + '}' + $q = '[pscustomobject]@{' + $(if ($queryPairs.Count) { ' ' + ($queryPairs -join '; ') + ' ' } else { '' }) + '}' + "Body = $b ; Query = $q" + } + + foreach ($field in $requiredFields) { + [void]$sb.Append($nl) + [void]$sb.Append(" It 'returns BadRequest when $field is missing' {$nl") + [void]$sb.Append(" # Baseline has every other required field populated; only $field is dropped.$nl") + [void]$sb.Append(" `$request = [pscustomobject]@{ $(Format-RequiredRequest -Omit $field) }$nl") + [void]$sb.Append(" `$response = $FunctionName -Request `$request -TriggerMetadata `$null$nl$nl") + [void]$sb.Append(" `$response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest)$nl") + [void]$sb.Append(" `$response.Body.Results | Should -Match '$field is required'$nl") + [void]$sb.Append(" }$nl") + } +} else { + # Non-endpoint function: emit a single placeholder invocation case. + $callParams = ($paramNames | ForEach-Object { "-$_ `$null" }) -join ' ' + [void]$sb.Append(" It 'does the expected thing' {$nl") + [void]$sb.Append(" # TODO: call with realistic arguments and assert behaviour$nl") + [void]$sb.Append(" # $FunctionName $callParams$nl") + [void]$sb.Append(" `$true | Should -BeTrue # TODO: replace$nl") + [void]$sb.Append(" }$nl") +} + +[void]$sb.Append("}$nl") + +# --- 8. Write it out --- +if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir -Force | Out-Null } +Set-Content -Path $OutFile -Value $sb.ToString() -Encoding utf8 -NoNewline + +Write-Host "Scaffolded: $OutFile" -ForegroundColor Green +Write-Host " Function : $FunctionFile" +Write-Host " Area : $Area | Endpoint: $isEndpoint" +Write-Host " Helpers : $(if ($helpers.Count) { $helpers -join ', ' } else { '(none detected)' })" +if ($isEndpoint) { + Write-Host " Body : $(if ($bodyFields.Count) { $bodyFields -join ', ' } else { '(none)' })" + Write-Host " Query : $(if ($queryFields.Count) { $queryFields -join ', ' } else { '(none)' })" + Write-Host " Required : $(if ($requiredFields.Count) { $requiredFields -join ', ' } else { '(none)' })" +} +Write-Host "Next: fill in the # TODOs, then run:" -ForegroundColor Cyan +Write-Host " pwsh $($MyInvocation.MyCommand.Path -replace 'New-CippTest','Invoke-CippTests') -Path `"$([System.IO.Path]::GetRelativePath($RepoRoot, $OutFile))`"" diff --git a/Tests/Private/Remove-CIPPMailboxPermissions.Tests.ps1 b/Tests/Private/Remove-CIPPMailboxPermissions.Tests.ps1 new file mode 100644 index 0000000000000..cb6a7508c0dfa --- /dev/null +++ b/Tests/Private/Remove-CIPPMailboxPermissions.Tests.ps1 @@ -0,0 +1,147 @@ +# Pester tests for Remove-CIPPMailboxPermissions +# Covers the per-level single-mailbox removal branches (SendOnBehalf -> Set-Mailbox + GrantSendonBehalfTo, +# SendAs -> Remove-RecipientPermission, FullAccess -> Remove-MailboxPermission), cache-sync gating, +# the "already removed" message selection, the -UseCache report-driven path, and the error path. +# +# NOTE: the 'AllUsers' branch uses ForEach-Object -Parallel, which runs each iteration in a separate +# runspace that re-imports the real CIPPCore/AzBobbyTables modules. Pester mocks live in the test +# runspace and cannot cross that boundary, so that branch is intentionally NOT unit-tested here. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Remove-CIPPMailboxPermissions.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Remove-CIPPMailboxPermissions.ps1 under Modules/' } + + function New-ExoRequest { param($Anchor, $tenantid, $cmdlet, $cmdParams, $Select) } + function Get-CippException { param($Exception) } + function Get-CIPPMailboxPermissionReport { param($TenantFilter, [switch]$ByUser) } + function Sync-CIPPMailboxPermissionCache { param($TenantFilter, $MailboxIdentity, $User, $PermissionType, $Action) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + + . $FunctionPath +} + +Describe 'Remove-CIPPMailboxPermissions' { + BeforeEach { + # A successful EXO removal returns a truthy response that does NOT contain an error substring. + # (The branches use `$result -notlike '*error*'`; note that with a $null response, + # $null -notlike '' is $false, which would route to the "already removed" message.) + Mock -CommandName New-ExoRequest -MockWith { 'OK' } + Mock -CommandName Get-CippException -MockWith { [pscustomobject]@{ NormalizedError = 'boom' } } + Mock -CommandName Get-CIPPMailboxPermissionReport -MockWith { } + Mock -CommandName Sync-CIPPMailboxPermissionCache -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + } + + Context 'Single-mailbox removal branches' { + It 'removes SendOnBehalf via Set-Mailbox with a GrantSendonBehalfTo remove hashtable' { + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('SendOnBehalf') + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-Mailbox' -and + $cmdParams.GrantSendonBehalfTo.remove -eq 'user@contoso.com' -and + $cmdParams.GrantSendonBehalfTo['@odata.type'] -eq '#Exchange.GenericHashTable' + } + $result | Should -Match "Removed SendOnBehalf permissions for user@contoso.com from shared@contoso.com's mailbox\." + } + + It 'removes SendAs via Remove-RecipientPermission and syncs the cache' { + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('SendAs') + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Remove-RecipientPermission' -and $cmdParams.Trustee -eq 'user@contoso.com' + } + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { + $PermissionType -eq 'SendAs' -and $Action -eq 'Remove' + } + $result | Should -Match "Removed SendAs permissions for user@contoso.com from shared@contoso.com's mailbox\." + } + + It 'reports SendAs as already-removed when EXO says the ACE is not present' { + Mock -CommandName New-ExoRequest -MockWith { "can't remove the ACL because the ACE isn't present" } + + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('SendAs') + + # cache is still synced regardless of whether the permission existed + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { $PermissionType -eq 'SendAs' } + $result | Should -Match "were already removed or don't exist" + } + + It 'removes FullAccess via Remove-MailboxPermission and syncs the cache' { + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('FullAccess') + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Remove-MailboxPermission' -and + $cmdParams.user -eq 'user@contoso.com' -and + $cmdParams.accessRights -contains 'FullAccess' + } + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { $PermissionType -eq 'FullAccess' } + $result | Should -Match "Removed FullAccess permissions for user@contoso.com from shared@contoso.com's mailbox\." + } + + It 'reports FullAccess as already-removed when EXO says the ACE does not exist' { + Mock -CommandName New-ExoRequest -MockWith { "can't remove because the ACE doesn't exist on the object." } + + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('FullAccess') + + $result | Should -Match "were already removed or don't exist" + } + + It 'processes multiple permission levels in one call' { + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('FullAccess', 'SendAs', 'SendOnBehalf') + + Should -Invoke New-ExoRequest -Times 3 -Exactly + $result.Count | Should -Be 3 + } + } + + Context '-UseCache path' { + It 'removes every cached permission for the user via recursion and returns per-mailbox results' { + Mock -CommandName Get-CIPPMailboxPermissionReport -MockWith { + [pscustomobject]@{ + User = 'user@contoso.com' + MailboxCount = 2 + Permissions = @( + [pscustomobject]@{ MailboxUPN = 'sharedA@contoso.com'; AccessRights = 'FullAccess' } + [pscustomobject]@{ MailboxUPN = 'sharedB@contoso.com'; AccessRights = 'SendAs' } + ) + } + } + + $result = Remove-CIPPMailboxPermissions -AccessUser 'user@contoso.com' -TenantFilter 'contoso.com' -UseCache + + Should -Invoke Get-CIPPMailboxPermissionReport -Times 1 -Exactly + # one EXO call per recursive removal (FullAccess + SendAs) + Should -Invoke New-ExoRequest -Times 2 -Exactly + $result.Count | Should -Be 2 + } + + It 'returns an informational message when no cached permissions exist' { + Mock -CommandName Get-CIPPMailboxPermissionReport -MockWith { } + + $result = Remove-CIPPMailboxPermissions -AccessUser 'user@contoso.com' -TenantFilter 'contoso.com' -UseCache + + Should -Invoke New-ExoRequest -Times 0 -Exactly + $result | Should -Be 'No mailbox permissions found for user@contoso.com in cached data' + } + } + + Context 'Error path' { + It 'returns a failure string and logs an error when New-ExoRequest throws' { + Mock -CommandName New-ExoRequest -MockWith { throw 'EXO down' } + + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('FullAccess') + + $result | Should -Be 'Could not remove mailbox permissions for shared@contoso.com. Error: boom' + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $Sev -eq 'Error' } + } + } +} diff --git a/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 b/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 new file mode 100644 index 0000000000000..c5402c179623e --- /dev/null +++ b/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 @@ -0,0 +1,129 @@ +# Pester tests for Set-CIPPAuthenticationPolicy +# Validates that method-specific sub-settings are written to the Graph PATCH body + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1' + + # Mock returns whatever the current test stored, simulating the existing Graph config + function New-GraphGetRequest { param($Uri, $tenantid, $AsApp) return $script:mockCurrentInfo } + function New-GraphPostRequest { + param($tenantid, $Uri, $Type, $Body, $ContentType, $AsApp) + $script:lastBody = $Body + } + function Write-LogMessage { param($headers, $API, $tenant, $message, $sev, $LogData) } + function Get-CippException { param($Exception) $Exception } + + . $FunctionPath +} + +Describe 'Set-CIPPAuthenticationPolicy' { + BeforeEach { + $script:lastBody = $null + $script:mockCurrentInfo = $null + } + + It 'writes Temporary Access Pass sub-settings to the PATCH body' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + isUsableOnce = $true + minimumLifetimeInMinutes = 10 + maximumLifetimeInMinutes = 20 + defaultLifetimeInMinutes = 15 + defaultLength = 8 + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'TemporaryAccessPass' ` + -Enabled $true -TAPisUsableOnce $false -TAPDefaultLength 12 -TAPDefaultLifeTime 90 + + $body = $script:lastBody | ConvertFrom-Json + $body.state | Should -Be 'enabled' + $body.isUsableOnce | Should -Be $false + $body.defaultLength | Should -Be 12 + $body.defaultLifetimeInMinutes | Should -Be 90 + } + + It 'honors the FIDO2 attestation/self-service parameters instead of forcing true' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + isAttestationEnforced = $true + isSelfServiceRegistrationAllowed = $true + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'FIDO2' ` + -Enabled $true -FIDO2AttestationEnforced $false -FIDO2SelfServiceRegistration $false + + $body = $script:lastBody | ConvertFrom-Json + $body.isAttestationEnforced | Should -Be $false + $body.isSelfServiceRegistrationAllowed | Should -Be $false + } + + It 'defaults FIDO2 to enforced/allowed when no parameters are passed' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + isAttestationEnforced = $false + isSelfServiceRegistrationAllowed = $false + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'FIDO2' -Enabled $true + + $body = $script:lastBody | ConvertFrom-Json + $body.isAttestationEnforced | Should -Be $true + $body.isSelfServiceRegistrationAllowed | Should -Be $true + } + + It 'scopes the method to all users when GroupIds contains all_users' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + includeTargets = @() + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'softwareOath' ` + -Enabled $true -GroupIds @('all_users') + + $body = $script:lastBody | ConvertFrom-Json + $body.includeTargets[0].targetType | Should -Be 'group' + $body.includeTargets[0].id | Should -Be 'all_users' + } + + It 'stamps SMS isUsableForSignIn onto every include-target' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + includeTargets = @( + [pscustomobject]@{ targetType = 'group'; id = 'all_users' } + ) + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'SMS' ` + -Enabled $true -SmsIsUsableForSignIn $true + + $body = $script:lastBody | ConvertFrom-Json + $body.includeTargets[0].isUsableForSignIn | Should -Be $true + } + + It 'clears Email exclude targets when an empty group list is supplied' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + excludeTargets = @([pscustomobject]@{ targetType = 'group'; id = 'old-group' }) + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'Email' ` + -Enabled $true -EmailExcludeGroupIds @() + + $body = $script:lastBody | ConvertFrom-Json + @($body.excludeTargets).Count | Should -Be 0 + } + + It 'writes the Voice isOfficePhoneAllowed setting to the PATCH body' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + isOfficePhoneAllowed = $false + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'Voice' ` + -Enabled $true -VoiceIsOfficePhoneAllowed $true + + $body = $script:lastBody | ConvertFrom-Json + $body.isOfficePhoneAllowed | Should -Be $true + } +} diff --git a/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 b/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 new file mode 100644 index 0000000000000..d837ba181dabe --- /dev/null +++ b/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 @@ -0,0 +1,79 @@ +# Pester tests for Set-CIPPMailboxAccess +# Set-CIPPMailboxAccess now delegates each grant to Set-CIPPMailboxPermission (FullAccess / Add), so +# these tests cover the per-user fan-out, extraction of frontend objects with a .value property, +# AutoMap pass-through, and that one user's failure does not stop the rest (the delegate returns an +# error string rather than throwing). The EXO cmdlet mapping itself is covered by +# Set-CIPPMailboxPermission.Tests.ps1. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Set-CIPPMailboxAccess.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Set-CIPPMailboxAccess.ps1 under Modules/' } + + function Set-CIPPMailboxPermission { param($UserId, $AccessUser, $PermissionLevel, $Action, $AutoMap, $TenantFilter, $APIName, $Headers) } + + . $FunctionPath +} + +Describe 'Set-CIPPMailboxAccess' { + BeforeEach { + Mock -CommandName Set-CIPPMailboxPermission -MockWith { "Granted $AccessUser FullAccess to $UserId with automapping $AutoMap" } + } + + It 'delegates a single user to Set-CIPPMailboxPermission as a FullAccess Add' { + $result = Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -Automap $true -TenantFilter 'contoso.com' -AccessRights @('FullAccess') + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $UserId -eq 'shared@contoso.com' -and + $AccessUser -eq 'user@contoso.com' -and + $PermissionLevel -eq 'FullAccess' -and + $Action -eq 'Add' -and + $AutoMap -eq $true -and + $TenantFilter -eq 'contoso.com' + } + $result | Should -Contain 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + } + + It 'processes an array of users, one delegate call per user' { + $result = Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser @('a@contoso.com', 'b@contoso.com') ` + -Automap $true -TenantFilter 'contoso.com' -AccessRights @('FullAccess') + + Should -Invoke Set-CIPPMailboxPermission -Times 2 -Exactly + $result.Count | Should -Be 2 + } + + It 'extracts the .value property from frontend objects' { + $accessUsers = @([pscustomobject]@{ value = 'picked@contoso.com'; label = 'Picked User' }) + + Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser $accessUsers ` + -Automap $true -TenantFilter 'contoso.com' -AccessRights @('FullAccess') + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $AccessUser -eq 'picked@contoso.com' } + } + + It 'passes AutoMap through to the delegate when disabled' { + Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -Automap $false -TenantFilter 'contoso.com' -AccessRights @('FullAccess') + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $AutoMap -eq $false } + } + + It 'continues to the next user when one user returns a failure string' { + Mock -CommandName Set-CIPPMailboxPermission -MockWith { + if ($AccessUser -eq 'bad@contoso.com') { + 'Failed to Add FullAccess for bad@contoso.com on shared@contoso.com: boom' + } else { + "Granted $AccessUser FullAccess to shared@contoso.com with automapping True" + } + } + + $result = Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser @('bad@contoso.com', 'good@contoso.com') ` + -Automap $true -TenantFilter 'contoso.com' -AccessRights @('FullAccess') + + Should -Invoke Set-CIPPMailboxPermission -Times 2 -Exactly + ($result -join "`n") | Should -Match 'Failed to Add FullAccess for bad@contoso.com on shared@contoso.com: boom' + ($result -join "`n") | Should -Match 'Granted good@contoso.com FullAccess to shared@contoso.com' + } +} diff --git a/Tests/Private/Set-CIPPMailboxPermission.Tests.ps1 b/Tests/Private/Set-CIPPMailboxPermission.Tests.ps1 new file mode 100644 index 0000000000000..761057d51287c --- /dev/null +++ b/Tests/Private/Set-CIPPMailboxPermission.Tests.ps1 @@ -0,0 +1,185 @@ +# Pester tests for Set-CIPPMailboxPermission +# Covers the permission-level -> EXO cmdlet/parameter mapping (via -AsCmdletObject, no execution), +# the execute path (New-ExoRequest + logging), cache-sync gating, and the error path. + +BeforeAll { + # Resolve by name under Modules/ so the test survives the function moving between modules. + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Set-CIPPMailboxPermission.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Set-CIPPMailboxPermission.ps1 under Modules/' } + + # Stub every CIPP helper the function calls so Pester's Mock has a command to replace. + function New-ExoRequest { param($Anchor, $tenantid, $cmdlet, $cmdParams) } + function Get-CippException { param($Exception) } + function Sync-CIPPMailboxPermissionCache { param($TenantFilter, $MailboxIdentity, $User, $PermissionType, $Action) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + + . $FunctionPath +} + +Describe 'Set-CIPPMailboxPermission' { + + Context '-AsCmdletObject mapping matrix (no execution)' { + + It 'maps FullAccess Add to Add-MailboxPermission with automapping and InheritanceType' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'FullAccess' -Action 'Add' -AutoMap $true -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Add-MailboxPermission' + $result.Parameters.Identity | Should -Be 'shared@contoso.com' + $result.Parameters.user | Should -Be 'user@contoso.com' + $result.Parameters.accessRights | Should -Be @('FullAccess') + $result.Parameters.automapping | Should -BeTrue + $result.Parameters.InheritanceType | Should -Be 'all' + $result.Parameters.Confirm | Should -BeFalse + $result.ExpectedResult | Should -Be 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + } + + It 'passes automapping through as $false when AutoMap is disabled' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'FullAccess' -Action 'Add' -AutoMap $false -TenantFilter 'contoso.com' -AsCmdletObject + + $result.Parameters.automapping | Should -BeFalse + $result.ExpectedResult | Should -Match 'automapping False' + } + + It 'maps FullAccess Remove to Remove-MailboxPermission' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'FullAccess' -Action 'Remove' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Remove-MailboxPermission' + $result.Parameters.accessRights | Should -Be @('FullAccess') + $result.Parameters.Keys | Should -Not -Contain 'automapping' + $result.ExpectedResult | Should -Be 'Removed user@contoso.com FullAccess from shared@contoso.com' + } + + It 'maps SendAs Add to Add-RecipientPermission with Trustee' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendAs' -Action 'Add' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Add-RecipientPermission' + $result.Parameters.Trustee | Should -Be 'user@contoso.com' + $result.Parameters.accessRights | Should -Be @('SendAs') + $result.ExpectedResult | Should -Be 'Granted user@contoso.com SendAs permissions to shared@contoso.com' + } + + It 'maps SendAs Remove to Remove-RecipientPermission with Trustee' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendAs' -Action 'Remove' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Remove-RecipientPermission' + $result.Parameters.Trustee | Should -Be 'user@contoso.com' + $result.ExpectedResult | Should -Be 'Removed user@contoso.com SendAs permissions from shared@contoso.com' + } + + It 'maps SendOnBehalf Add to Set-Mailbox with GrantSendonBehalfTo add hashtable' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendOnBehalf' -Action 'Add' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Set-Mailbox' + $result.Parameters.GrantSendonBehalfTo['@odata.type'] | Should -Be '#Exchange.GenericHashTable' + $result.Parameters.GrantSendonBehalfTo.add | Should -Be 'user@contoso.com' + $result.Parameters.GrantSendonBehalfTo.Keys | Should -Not -Contain 'remove' + $result.ExpectedResult | Should -Be 'Granted user@contoso.com SendOnBehalf permissions to shared@contoso.com' + } + + It 'maps SendOnBehalf Remove to Set-Mailbox with GrantSendonBehalfTo remove hashtable' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendOnBehalf' -Action 'Remove' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Set-Mailbox' + $result.Parameters.GrantSendonBehalfTo.remove | Should -Be 'user@contoso.com' + $result.Parameters.GrantSendonBehalfTo.Keys | Should -Not -Contain 'add' + $result.ExpectedResult | Should -Be 'Removed user@contoso.com SendOnBehalf permissions from shared@contoso.com' + } + + It 'maps default-level Remove () to Remove-MailboxPermission with that access right' -ForEach @( + @{ Level = 'ReadPermission' } + @{ Level = 'ExternalAccount' } + @{ Level = 'DeleteItem' } + @{ Level = 'ChangePermission' } + @{ Level = 'ChangeOwner' } + ) { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel $Level -Action 'Remove' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Remove-MailboxPermission' + $result.Parameters.accessRights | Should -Be @($Level) + $result.ExpectedResult | Should -Be "Removed user@contoso.com $Level from shared@contoso.com" + } + + It 'returns an unsupported-action string for default-level Add ()' -ForEach @( + @{ Level = 'ReadPermission' } + @{ Level = 'ExternalAccount' } + @{ Level = 'DeleteItem' } + @{ Level = 'ChangePermission' } + @{ Level = 'ChangeOwner' } + ) { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel $Level -Action 'Add' -TenantFilter 'contoso.com' -AsCmdletObject + + $result | Should -Be "Add action is not supported for $Level" + } + } + + Context 'Execute path' { + BeforeEach { + Mock -CommandName New-ExoRequest -MockWith { } + Mock -CommandName Sync-CIPPMailboxPermissionCache -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CippException -MockWith { [pscustomobject]@{ NormalizedError = 'boom' } } + } + + It 'invokes New-ExoRequest with the mapped cmdlet/params anchored on the mailbox and returns the result string' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'FullAccess' -Action 'Add' -TenantFilter 'contoso.com' + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Add-MailboxPermission' -and + $Anchor -eq 'shared@contoso.com' -and + $tenantid -eq 'contoso.com' -and + $cmdParams.user -eq 'user@contoso.com' + } + $result | Should -Be 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + } + + It 'logs an Info message on success' { + Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendAs' -Action 'Add' -TenantFilter 'contoso.com' + + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $Sev -eq 'Info' } + } + + It 'syncs the cache for cached permission types ()' -ForEach @( + @{ Level = 'FullAccess' } + @{ Level = 'SendAs' } + @{ Level = 'SendOnBehalf' } + ) { + Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel $Level -Action 'Add' -TenantFilter 'contoso.com' + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { + $PermissionType -eq $Level -and $Action -eq 'Add' + } + } + + It 'does not sync the cache for non-cached permission types' { + Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'ReadPermission' -Action 'Remove' -TenantFilter 'contoso.com' + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 0 -Exactly + } + + It 'returns a failure string and logs an error when New-ExoRequest throws' { + Mock -CommandName New-ExoRequest -MockWith { throw 'EXO down' } + + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'FullAccess' -Action 'Add' -TenantFilter 'contoso.com' + + $result | Should -Be 'Failed to Add FullAccess for user@contoso.com on shared@contoso.com: boom' + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $Sev -eq 'Error' } + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 0 -Exactly + } + } +} diff --git a/Tests/Private/Set-CIPPMailboxVacation.Tests.ps1 b/Tests/Private/Set-CIPPMailboxVacation.Tests.ps1 new file mode 100644 index 0000000000000..f1aeeacced3ce --- /dev/null +++ b/Tests/Private/Set-CIPPMailboxVacation.Tests.ps1 @@ -0,0 +1,132 @@ +# Pester tests for Set-CIPPMailboxVacation +# Covers the mailbox-permission loop (delegating to Set-CIPPMailboxPermission), the calendar-permission +# loop (Set-CIPPCalendarPermission), hashtable vs PSCustomObject entry access, missing-field skips, +# Action propagation, and the calendar error path. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Set-CIPPMailboxVacation.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Set-CIPPMailboxVacation.ps1 under Modules/' } + + function Set-CIPPMailboxPermission { param($UserId, $AccessUser, $PermissionLevel, $Action, $AutoMap, $TenantFilter, $APIName, $Headers) } + function Set-CIPPCalendarPermission { param($TenantFilter, $UserID, $FolderName, $APIName, $Headers, $RemoveAccess, $UserToGetPermissions, $Permissions, $CanViewPrivateItems) } + function Get-CippException { param($Exception) } + + . $FunctionPath +} + +Describe 'Set-CIPPMailboxVacation' { + BeforeEach { + Mock -CommandName Set-CIPPMailboxPermission -MockWith { 'mailbox-perm-result' } + Mock -CommandName Set-CIPPCalendarPermission -MockWith { 'calendar-perm-result' } + Mock -CommandName Get-CippException -MockWith { [pscustomobject]@{ NormalizedError = 'boom' } } + } + + Context 'Mailbox permissions' { + It 'forwards each mailbox permission to Set-CIPPMailboxPermission with the requested Action' { + $perms = @( + [pscustomobject]@{ UserId = 'shared@contoso.com'; AccessUser = 'user@contoso.com'; PermissionLevel = 'FullAccess'; AutoMap = $true } + ) + + $results = Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -MailboxPermissions $perms + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $UserId -eq 'shared@contoso.com' -and + $AccessUser -eq 'user@contoso.com' -and + $PermissionLevel -eq 'FullAccess' -and + $Action -eq 'Add' -and + $AutoMap -eq $true -and + $TenantFilter -eq 'contoso.com' + } + $results | Should -Contain 'mailbox-perm-result' + } + + It 'propagates the Remove action to the delegate cmdlet' { + $perms = @([pscustomobject]@{ UserId = 'shared@contoso.com'; AccessUser = 'user@contoso.com'; PermissionLevel = 'SendAs' }) + + Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Remove' -MailboxPermissions $perms + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $Action -eq 'Remove' } + } + + It 'accepts hashtable entries as well as PSCustomObject entries' { + $perms = @(@{ UserId = 'shared@contoso.com'; AccessUser = 'user@contoso.com'; PermissionLevel = 'FullAccess' }) + + Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -MailboxPermissions $perms + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $UserId -eq 'shared@contoso.com' } + } + + It 'defaults AutoMap to $true when not supplied' { + $perms = @([pscustomobject]@{ UserId = 'shared@contoso.com'; AccessUser = 'user@contoso.com'; PermissionLevel = 'FullAccess' }) + + Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -MailboxPermissions $perms + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $AutoMap -eq $true } + } + + It 'skips entries with missing required fields and records a skip message' { + $perms = @([pscustomobject]@{ UserId = 'shared@contoso.com'; PermissionLevel = 'FullAccess' }) # no AccessUser + + $results = Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -MailboxPermissions $perms + + Should -Invoke Set-CIPPMailboxPermission -Times 0 -Exactly + $results | Should -Contain 'Skipped mailbox permission with missing fields' + } + } + + Context 'Calendar permissions' { + It 'forwards Add calendar permissions with delegate, permissions and private-items flag' { + $cal = @( + [pscustomobject]@{ UserID = 'shared@contoso.com'; UserToGetPermissions = 'user@contoso.com'; FolderName = 'Calendar'; Permissions = 'Editor'; CanViewPrivateItems = $true } + ) + + $results = Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -CalendarPermissions $cal + + Should -Invoke Set-CIPPCalendarPermission -Times 1 -Exactly -ParameterFilter { + $UserID -eq 'shared@contoso.com' -and + $UserToGetPermissions -eq 'user@contoso.com' -and + $Permissions -eq 'Editor' -and + $CanViewPrivateItems -eq $true + } + $results | Should -Contain 'calendar-perm-result' + } + + It 'uses RemoveAccess when the action is Remove' { + $cal = @([pscustomobject]@{ UserID = 'shared@contoso.com'; UserToGetPermissions = 'user@contoso.com' }) + + Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Remove' -CalendarPermissions $cal + + Should -Invoke Set-CIPPCalendarPermission -Times 1 -Exactly -ParameterFilter { + $RemoveAccess -eq 'user@contoso.com' + } + } + + It 'defaults the calendar folder name to Calendar' { + $cal = @([pscustomobject]@{ UserID = 'shared@contoso.com'; UserToGetPermissions = 'user@contoso.com' }) + + Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -CalendarPermissions $cal + + Should -Invoke Set-CIPPCalendarPermission -Times 1 -Exactly -ParameterFilter { $FolderName -eq 'Calendar' } + } + + It 'skips calendar entries with missing required fields' { + $cal = @([pscustomobject]@{ UserID = 'shared@contoso.com' }) # no delegate + + $results = Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -CalendarPermissions $cal + + Should -Invoke Set-CIPPCalendarPermission -Times 0 -Exactly + $results | Should -Contain 'Skipped calendar permission with missing fields' + } + + It 'records a failure message when the calendar permission throws' { + Mock -CommandName Set-CIPPCalendarPermission -MockWith { throw 'cal down' } + $cal = @([pscustomobject]@{ UserID = 'shared@contoso.com'; UserToGetPermissions = 'user@contoso.com' }) + + $results = Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -CalendarPermissions $cal + + $results | Should -Match 'Failed calendar permission for user@contoso.com on shared@contoso.com: boom' + } + } +} diff --git a/Tests/Reports/Get-CIPPSharedMailboxAccountEnabledReport.Tests.ps1 b/Tests/Reports/Get-CIPPSharedMailboxAccountEnabledReport.Tests.ps1 new file mode 100644 index 0000000000000..991df44fd8206 --- /dev/null +++ b/Tests/Reports/Get-CIPPSharedMailboxAccountEnabledReport.Tests.ps1 @@ -0,0 +1,141 @@ +# Pester tests for Get-CIPPSharedMailboxAccountEnabledReport +# Verifies the cached Mailboxes + Users join, accountEnabled filtering, payload shape, and AllTenants fan-out + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $ReportPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPSharedMailboxAccountEnabledReport.ps1' + + # Minimal stubs so Mock has commands to replace during tests + function Get-CIPPDbItem { param($TenantFilter, $Type) } + function Get-Tenants { param([switch]$IncludeErrors) } + function Write-LogMessage { param($API, $tenant, $message, $sev) } + + . $ReportPath + + function New-DbItem { + param($PartitionKey, $RowKey, $Data, $Timestamp) + [pscustomobject]@{ + PartitionKey = $PartitionKey + RowKey = $RowKey + Timestamp = $Timestamp + Data = ($Data | ConvertTo-Json -Depth 5 -Compress) + } + } +} + +Describe 'Get-CIPPSharedMailboxAccountEnabledReport' { + BeforeEach { + $script:Tenant = 'contoso.onmicrosoft.com' + + $script:SharedMailbox = @{ UPN = 'shared@contoso.com'; recipientTypeDetails = 'SharedMailbox' } + $script:RegularMailbox = @{ UPN = 'user@contoso.com'; recipientTypeDetails = 'UserMailbox' } + + $script:EnabledUser = @{ + userPrincipalName = 'shared@contoso.com' + displayName = 'Shared Mailbox' + givenName = 'Shared' + surname = 'Mailbox' + accountEnabled = $true + assignedLicenses = @(@{ skuId = 'sku-1' }) + id = 'user-id-shared' + onPremisesSyncEnabled = $false + } + $script:RegularUser = @{ + userPrincipalName = 'user@contoso.com' + displayName = 'Regular User' + accountEnabled = $true + id = 'user-id-regular' + onPremisesSyncEnabled = $false + } + + $script:Now = Get-Date + + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-Tenants -MockWith { @([pscustomobject]@{ defaultDomainName = 'contoso.onmicrosoft.com' }) } + } + + It 'joins a shared mailbox to its user and returns the live payload shape' { + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Mailboxes' } -MockWith { + @( + New-DbItem -PartitionKey $script:Tenant -RowKey 'Mailboxes-Count' -Data @{ Count = 2 } -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey '1' -Data $script:SharedMailbox -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey '2' -Data $script:RegularMailbox -Timestamp $script:Now + ) + } + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Users' } -MockWith { + @( + New-DbItem -PartitionKey $script:Tenant -RowKey 'Users-Count' -Data @{ Count = 2 } -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey 'u1' -Data $script:EnabledUser -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey 'u2' -Data $script:RegularUser -Timestamp $script:Now + ) + } + + $Result = Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $script:Tenant + + @($Result).Count | Should -Be 1 + $Result[0].UserPrincipalName | Should -Be 'shared@contoso.com' + $Result[0].id | Should -Be 'user-id-shared' + $Result[0].accountEnabled | Should -BeTrue + $Result[0].onPremisesSyncEnabled | Should -BeFalse + $Result[0].CacheTimestamp | Should -Not -BeNullOrEmpty + # Must not leak the regular (non-shared) mailbox + $Result.UserPrincipalName | Should -Not -Contain 'user@contoso.com' + } + + It 'excludes shared mailboxes whose user account is disabled' { + $script:EnabledUser.accountEnabled = $false + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Mailboxes' } -MockWith { + @( + New-DbItem -PartitionKey $script:Tenant -RowKey 'Mailboxes-Count' -Data @{ Count = 1 } -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey '1' -Data $script:SharedMailbox -Timestamp $script:Now + ) + } + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Users' } -MockWith { + @(New-DbItem -PartitionKey $script:Tenant -RowKey 'u1' -Data $script:EnabledUser -Timestamp $script:Now) + } + + $Result = Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $script:Tenant + + @($Result).Count | Should -Be 0 + } + + It 'returns an empty result (no throw) when the cache holds no enabled shared mailboxes' { + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Mailboxes' } -MockWith { + @( + New-DbItem -PartitionKey $script:Tenant -RowKey 'Mailboxes-Count' -Data @{ Count = 1 } -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey '1' -Data $script:RegularMailbox -Timestamp $script:Now + ) + } + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Users' } -MockWith { + @(New-DbItem -PartitionKey $script:Tenant -RowKey 'u2' -Data $script:RegularUser -Timestamp $script:Now) + } + + { Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $script:Tenant } | Should -Not -Throw + @(Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $script:Tenant).Count | Should -Be 0 + } + + It 'throws when no mailbox data is cached' { + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Mailboxes' } -MockWith { @() } + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Users' } -MockWith { @() } + + { Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $script:Tenant } | Should -Throw '*Sync the report data first*' + } + + It 'adds a Tenant column for AllTenants' { + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Mailboxes' } -MockWith { + @( + New-DbItem -PartitionKey $script:Tenant -RowKey 'Mailboxes-Count' -Data @{ Count = 1 } -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey '1' -Data $script:SharedMailbox -Timestamp $script:Now + ) + } + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Users' } -MockWith { + @(New-DbItem -PartitionKey $script:Tenant -RowKey 'u1' -Data $script:EnabledUser -Timestamp $script:Now) + } + + $Result = Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter 'AllTenants' + + @($Result).Count | Should -Be 1 + $Result[0].Tenant | Should -Be 'contoso.onmicrosoft.com' + $Result[0].UserPrincipalName | Should -Be 'shared@contoso.com' + } +} diff --git a/Tests/Security/Invoke-ExecSetSecurityIncident.Tests.ps1 b/Tests/Security/Invoke-ExecSetSecurityIncident.Tests.ps1 new file mode 100644 index 0000000000000..8c12a28ca6ff6 --- /dev/null +++ b/Tests/Security/Invoke-ExecSetSecurityIncident.Tests.ps1 @@ -0,0 +1,129 @@ +# Pester tests for Invoke-ExecSetSecurityIncident +# Validates the PATCH body built for severity changes and resolving comments, +# and that free-text comments produce valid JSON (regression for the body builder). + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecSetSecurityIncident.ps1' + + # The Functions worker exposes [HttpStatusCode] as an accelerator; register it for tests. + ([PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')).GetMethod('Add').Invoke( + $null, @('HttpStatusCode', [System.Net.HttpStatusCode])) + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + function New-GraphPOSTRequest { param($uri, $type, $tenantid, $body, $asApp) $script:lastPatch = @{ Uri = $uri; Type = $type; Tenant = $tenantid; Body = $body } } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) $script:logs += $message } + function Get-CippException { param($Exception) @{ NormalizedError = $Exception.Exception.Message } } + + . $FunctionPath +} + +Describe 'Invoke-ExecSetSecurityIncident' { + BeforeEach { + $script:lastPatch = $null + $script:logs = @() + } + + It 'sends only the chosen severity, leaving the assignee untouched' { + # The severity action omits Assigned, so no assignedTo should be written. + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSetSecurityIncident' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + GUID = 'incident-1' + Severity = [pscustomobject]@{ value = 'high'; label = 'High' } + } + } + + $response = Invoke-ExecSetSecurityIncident -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $lastPatch.Type | Should -Be 'PATCH' + $lastPatch.Uri | Should -Match '/security/incidents/incident-1' + $parsed = $lastPatch.Body | ConvertFrom-Json + $parsed.severity | Should -Be 'high' + $parsed.PSObject.Properties.Name | Should -Not -Contain 'assignedTo' + } + + It 'assigns to the calling user when the AssignToSelf flag is set' { + $principal = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('{"userDetails":"caller@contoso.com"}')) + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSetSecurityIncident' } + Headers = @{ 'x-ms-client-principal' = $principal } + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + GUID = 'incident-1' + AssignToSelf = $true + } + } + + $response = Invoke-ExecSetSecurityIncident -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + ($lastPatch.Body | ConvertFrom-Json).assignedTo | Should -Be 'caller@contoso.com' + } + + It 'sends status resolved together with the resolving comment' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSetSecurityIncident' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + GUID = 'incident-2' + Status = 'resolved' + Comment = 'Closed after review' + } + } + + $response = Invoke-ExecSetSecurityIncident -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $parsed = $lastPatch.Body | ConvertFrom-Json + $parsed.status | Should -Be 'resolved' + $parsed.resolvingComment | Should -Be 'Closed after review' + } + + It 'produces valid JSON when the comment contains quotes and newlines' { + $comment = "Line one with a `"quote`"`nLine two with a backslash \" + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSetSecurityIncident' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + GUID = 'incident-3' + Status = 'resolved' + Comment = $comment + } + } + + $response = Invoke-ExecSetSecurityIncident -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + { $lastPatch.Body | ConvertFrom-Json } | Should -Not -Throw + ($lastPatch.Body | ConvertFrom-Json).resolvingComment | Should -Be $comment + } + + It 'refuses to update a redirected incident' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSetSecurityIncident' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + GUID = 'incident-4' + Status = 'resolved' + Redirected = 1 + } + } + + $response = Invoke-ExecSetSecurityIncident -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body.Results | Should -Match 'Refused to update' + $lastPatch | Should -BeNullOrEmpty + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 index 9777672690c5f..82e43e1d0d4b7 100644 --- a/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 +++ b/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 @@ -3,7 +3,10 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $StandardPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $StandardPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-CIPPStandardReusableSettingsTemplate.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardReusableSettingsTemplate.ps1 under Modules/' } function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $RequiredCapabilities) } function Get-CippTable { param($tablename) } diff --git a/host.json b/host.json index 9de15356dcb97..a88d17f4bdc9d 100644 --- a/host.json +++ b/host.json @@ -16,7 +16,7 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.8", + "defaultVersion": "10.6.0", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } diff --git a/redocly.yaml b/redocly.yaml new file mode 100644 index 0000000000000..481010ed7caed --- /dev/null +++ b/redocly.yaml @@ -0,0 +1,5 @@ +apis: + enriched: + root: ./openapi.enriched.json +extends: + - recommended diff --git a/version_latest.txt b/version_latest.txt index 0b09579a68d16..d1dd3f904c728 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.8 +10.6.0