mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ce1f2b036 | |||
| 673f045212 | |||
| df640e3684 | |||
| 59bea9d888 | |||
| e2c91afda2 | |||
| eb47c4d69a | |||
| 91d56f2bbb | |||
| 12b847360c | |||
| 2e736f40ad | |||
| 9c3e5ecb30 | |||
| 27ce316206 | |||
| 7280b61d28 | |||
| 79f1ecd378 | |||
| b6791d37e0 | |||
| 4f1b4f8c03 | |||
| 869580c9df | |||
| 1a96cd20da | |||
| 8e637cf86a | |||
| 93c6e1facb | |||
| 4a4bbc056a | |||
| c59b366560 | |||
| d0aded5401 | |||
| 8f9c683b7c | |||
| e82878af57 | |||
| d60e858e95 | |||
| f6ce9ae0a0 | |||
| b4f26401f5 | |||
| 2b5004fb33 | |||
| 00f4cb1f26 | |||
| e091e50862 | |||
| 27ce07b111 | |||
| f06fe5afc0 | |||
| 37e920237c | |||
| 7279650772 | |||
| d8ab70b166 | |||
| fb8703a4c6 | |||
| ac3fa8b2b6 | |||
| efcb4f9ca1 | |||
| 7a7a31e8e3 | |||
| 3fdcbabd48 | |||
| b2e8c7627f | |||
| bd08d13011 | |||
| 10c69f592e | |||
| fe0b2dc7fe | |||
| a0f007fd43 | |||
| 1b27babd17 | |||
| 100d2242fa | |||
| 089b1b38fd | |||
| ac465839a5 | |||
| ef280b7bb5 | |||
| 233737f333 | |||
| 909904e928 |
@@ -30,6 +30,7 @@
|
||||
- Stage explicit file lists; never `git add -A` or a directory. Pushing is the user's call.
|
||||
- New API responses need a typed DTO plus the parity fact. MCP vocabularies are generated from the wire types, never retyped.
|
||||
- Every route belongs in the route policy catalog; every new event type goes into the MCP resource catalog.
|
||||
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
|
||||
- The about, terms and privacy pages under `src/Web/autopilot-monitor-web/app/` state facts customers rely on: verify every claim against the code before writing it and update them in the same change as the fact they describe.
|
||||
|
||||
## Code Quality
|
||||
|
||||
+1
-1
Submodule .claude/commands updated: 315ad0e736...4eeede1e1b
@@ -1,8 +1,8 @@
|
||||
# CodeQL configuration.
|
||||
#
|
||||
# The default query suites stay active — this file only filters out two C# queries
|
||||
# that fire structurally on this codebase, where the flagged behavior is the feature,
|
||||
# not a defect:
|
||||
# The default query suites stay active — this file only filters out queries that
|
||||
# fire structurally on this codebase, where the flagged behavior is the feature
|
||||
# or the protection exists in a form the query cannot see:
|
||||
#
|
||||
# cs/exposure-of-sensitive-information
|
||||
# Audit and operational logs record WHO performed an action (actor UPN / email)
|
||||
@@ -15,10 +15,20 @@
|
||||
# key. The IP is an identifier for throttling, not a secret at rest; storing it in
|
||||
# the in-memory / table rate-limit key is by design.
|
||||
#
|
||||
# js/missing-rate-limiting
|
||||
# Only the MCP server has Express routes. Every one that does work is throttled by
|
||||
# our own limiters (oauthRateLimit / oauthTokenRateLimit on /oauth/*, accessGuard
|
||||
# on /mcp); /health and the OAuth discovery documents return static JSON.
|
||||
# The query only recognizes limiters from npm packages (express-rate-limit and
|
||||
# similar) and flags a route handler as soon as it calls a function named
|
||||
# like *auth*, *login* or *verify*. So it reports throttled and unthrottled
|
||||
# routes the same way, and every alert it ever raised was a false positive. The
|
||||
# coverage check it stood in for is a test: every OAuth router route must carry
|
||||
# a limiter or be named as exempt (MCP oauth-rate-limit.test.ts).
|
||||
#
|
||||
# Genuine one-off false positives from OTHER queries (e.g. an open-redirect the taint
|
||||
# model can't see is allow-listed, a rate-limited route it doesn't recognize) are
|
||||
# dismissed individually in the Security tab so those queries stay sharp — they are
|
||||
# NOT suppressed here.
|
||||
# model can't see is allow-listed) are dismissed individually in the Security tab so
|
||||
# those queries stay sharp — they are NOT suppressed here.
|
||||
#
|
||||
# Log-injection (cs/log-forging) is deliberately NOT filtered: user-controlled values
|
||||
# are sanitized at the sink via Helpers/LogSanitizer, so the query stays useful for
|
||||
@@ -29,3 +39,5 @@ query-filters:
|
||||
id: cs/exposure-of-sensitive-information
|
||||
- exclude:
|
||||
id: cs/cleartext-storage-of-sensitive-information
|
||||
- exclude:
|
||||
id: js/missing-rate-limiting
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
version: 2
|
||||
|
||||
# Cooldown: a version is proposed only once it has been public for 7 days, so a
|
||||
# release that is yanked or found malicious within its first week never reaches a
|
||||
# PR. Security updates are exempt from the cooldown by Dependabot's own rules.
|
||||
#
|
||||
# Grouping: every ecosystem gets one batched PR for minor/patch version
|
||||
# updates and one batched PR for security updates. Groups apply to version
|
||||
# updates only by default, so the security-updates groups are declared
|
||||
@@ -10,6 +14,8 @@ updates:
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
dotnet:
|
||||
@@ -23,6 +29,8 @@ updates:
|
||||
directory: /src/Web/autopilot-monitor-web
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
web:
|
||||
@@ -41,6 +49,8 @@ updates:
|
||||
directory: /src/McpServer/autopilot-monitor-mcp
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
mcp:
|
||||
@@ -55,6 +65,22 @@ updates:
|
||||
- dependency-name: '*'
|
||||
update-types: ['version-update:semver-major']
|
||||
|
||||
# Validator tooling for rules/ (ajv-cli); combine-rules.yml installs it from this
|
||||
# lockfile with contents:write in hand, so the pins must not go stale.
|
||||
- package-ecosystem: npm
|
||||
directory: /rules/scripts
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
rules-tooling:
|
||||
patterns: ['*']
|
||||
rules-tooling-security:
|
||||
applies-to: security-updates
|
||||
patterns: ['*']
|
||||
|
||||
# Every action is referenced by commit SHA with the version in a trailing comment;
|
||||
# action-pins.yml fails any workflow change that reintroduces a mutable tag. Dependabot
|
||||
# is what keeps those pins current: it rewrites the SHA and the comment together, so the
|
||||
@@ -63,6 +89,8 @@ updates:
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
actions:
|
||||
patterns: ['*']
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
name: Action Pin Gate
|
||||
name: Workflow gates
|
||||
|
||||
# Two checks over the workflow files themselves:
|
||||
# * verify-pins -- every third-party action is referenced by a full commit SHA plus a
|
||||
# version comment (stricter than zizmor's unpinned-uses audit).
|
||||
# * zizmor -- static analysis of every workflow (permissions, credential
|
||||
# persistence, template injection, ...). Configuration and the
|
||||
# reasoned ignores live in zizmor.yml at the repository root; the
|
||||
# same run is expected locally before a workflow change is committed.
|
||||
#
|
||||
# Every third-party action must be referenced by a full commit SHA, never by a tag.
|
||||
# Tags are mutable: whoever controls an action repository can repoint v4 at new code,
|
||||
# and that code runs inside jobs that hold the code-signing identity, the storage
|
||||
@@ -8,7 +16,8 @@ name: Action Pin Gate
|
||||
#
|
||||
# Lives in its own workflow rather than in ci.yml: CI deliberately ignores changes to
|
||||
# the other workflow files, so a guard placed there would stay silent for exactly the
|
||||
# commits it needs to inspect.
|
||||
# commits it needs to inspect. Dependabot's action-pin PRs are the main customer: they
|
||||
# rewrite workflow files without anyone running zizmor locally.
|
||||
#
|
||||
# Like CI, this reports rather than blocks: main is deliberately unprotected, so a red
|
||||
# run is a signal to act on, not a merge barrier. Making it binding means adding
|
||||
@@ -22,10 +31,10 @@ name: Action Pin Gate
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**']
|
||||
paths: ['.github/workflows/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**']
|
||||
paths: ['.github/workflows/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -37,15 +46,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Every third-party action is pinned to a SHA
|
||||
run: |
|
||||
set -uo pipefail
|
||||
failed=0
|
||||
while IFS=: read -r file line ref; do
|
||||
# Reusable workflows from this repository move with the commit that calls
|
||||
# them, so there is nothing to pin.
|
||||
case "$ref" in ./*) continue ;; esac
|
||||
# Reusable workflows from this repository (workspace-relative ./ or the
|
||||
# self-repository form $/) move with the commit that calls them, so there is
|
||||
# nothing to pin.
|
||||
case "$ref" in ./*|\$/*) continue ;; esac
|
||||
|
||||
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+#[[:space:]]*[^[:space:]] ]]; then
|
||||
printf 'ok %s:%s %s\n' "$file" "$line" "$ref"
|
||||
@@ -68,3 +80,24 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "All action references are pinned to commit SHAs."
|
||||
|
||||
zizmor:
|
||||
name: zizmor audit
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # SARIF upload: findings land in the Code scanning tab next to CodeQL
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# zizmor itself is version-pinned like every other tool here; bump it together with
|
||||
# the local install so the gate and the pre-commit run agree on the finding set.
|
||||
# Informational findings (template expansion of our own step outputs in pwsh) are
|
||||
# visible locally but do not fail the gate.
|
||||
- uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
with:
|
||||
version: 1.30.1
|
||||
min-severity: low
|
||||
|
||||
@@ -19,6 +19,8 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Pester 5+
|
||||
shell: powershell
|
||||
|
||||
@@ -73,6 +73,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Every released build is signed; a missing variable stops the run before a build
|
||||
# number is burnt.
|
||||
@@ -91,7 +93,7 @@ jobs:
|
||||
# are bound to the ref, so this job must never be given an `environment:` -- that would
|
||||
# change the OIDC subject claim and break both logins.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -114,7 +116,7 @@ jobs:
|
||||
# well inside the token lifetime. Only the legacy mirror still needs a SAS: that account
|
||||
# lives in the pre-migration tenant, where this principal has no identity.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -41,6 +41,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# The loader executes as SYSTEM on every enrolling device — a parse error or
|
||||
# non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet.
|
||||
@@ -86,7 +88,7 @@ jobs:
|
||||
# are bound to the ref, so this job must never be given an `environment:` -- that would
|
||||
# change the OIDC subject claim and break both logins.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -105,7 +107,7 @@ jobs:
|
||||
|
||||
# The blob upload below is authorized by this federated identity, not by a SAS.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -24,6 +24,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -57,6 +58,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -97,6 +99,8 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
with:
|
||||
@@ -128,6 +132,8 @@ jobs:
|
||||
working-directory: src/Web/autopilot-monitor-web
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
@@ -162,6 +168,8 @@ jobs:
|
||||
working-directory: src/McpServer/autopilot-monitor-mcp
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
@@ -196,6 +204,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
@@ -217,6 +227,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
|
||||
@@ -17,6 +17,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -48,6 +49,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -103,6 +105,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
if: matrix.language == 'csharp'
|
||||
@@ -110,7 +114,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -121,6 +125,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -30,11 +30,16 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# Exact versions and no lifecycle scripts: this job holds contents:write and pushes to
|
||||
# Lockfile and no lifecycle scripts: this job holds contents:write and pushes to
|
||||
# main, so an install-time script from a hijacked package would run with a token that
|
||||
# can rewrite the repository. ajv is pure JS and needs no install hooks.
|
||||
- name: Install ajv-cli
|
||||
run: npm install -g --ignore-scripts ajv-cli@5.0.0 ajv-formats@3.0.1
|
||||
# can rewrite the repository. rules/scripts/package-lock.json pins ajv-cli, ajv-formats
|
||||
# and every transitive package (Dependabot keeps it current); ajv is pure JS and
|
||||
# needs no install hooks.
|
||||
- name: Install ajv-cli from the lockfile
|
||||
working-directory: rules/scripts
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Validate Gather Rules
|
||||
run: |
|
||||
|
||||
@@ -20,9 +20,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -27,6 +27,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# The published customer documentation lives in its own PUBLIC repo and is
|
||||
# baked into the image as the search_docs corpus (see the Dockerfile). No
|
||||
@@ -38,6 +40,7 @@ jobs:
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
path: mcp-docs
|
||||
|
||||
@@ -46,7 +49,7 @@ jobs:
|
||||
run: echo "sha=$(git -C mcp-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -77,7 +80,7 @@ jobs:
|
||||
"short_sha=$shortSha" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Login to ACR
|
||||
run: az acr login --name ${{ env.ACR_NAME }}
|
||||
run: az acr login --name "$ACR_NAME"
|
||||
|
||||
- name: Build and push container image
|
||||
run: |
|
||||
@@ -87,17 +90,17 @@ jobs:
|
||||
--build-arg BUILD_NUMBER=${{ steps.version.outputs.build_number }} \
|
||||
--build-arg BUILD_COMMIT=${{ steps.version.outputs.short_sha }} \
|
||||
--build-arg BUILD_UTC=${{ steps.version.outputs.build_utc }} \
|
||||
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }} \
|
||||
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:latest \
|
||||
-t "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME:$IMAGE_TAG" \
|
||||
-t "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME:latest" \
|
||||
.
|
||||
docker push ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }} --all-tags
|
||||
docker push "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME" --all-tags
|
||||
|
||||
- name: Deploy to Container App
|
||||
run: |
|
||||
az containerapp update \
|
||||
--name ${{ env.CONTAINER_APP_NAME }} \
|
||||
--resource-group ${{ env.RESOURCE_GROUP }} \
|
||||
--image ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }}
|
||||
--name "$CONTAINER_APP_NAME" \
|
||||
--resource-group "$RESOURCE_GROUP" \
|
||||
--image "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME:$IMAGE_TAG"
|
||||
|
||||
# `az containerapp update` returns once the revision is provisioned, not once
|
||||
# it serves traffic — and with minReplicas=0 the first request also pays a cold
|
||||
|
||||
@@ -37,13 +37,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
|
||||
|
||||
# Only the version manifest at the very end needs Azure; logging in here rather than
|
||||
# next to that step keeps a credential problem from surfacing after a live deploy.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -58,6 +59,7 @@ jobs:
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
path: whats-new-docs
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -3,6 +3,9 @@ name: IndexNow Notify (Bing/Yandex)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
# The job only talks to public endpoints; it never needs a repository token.
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
name: Federated login + read check
|
||||
steps:
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -25,5 +25,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# $/ is the self-repository form: this repository at the running commit.
|
||||
gates:
|
||||
uses: ./.github/workflows/bootstrap-script-gates.yml
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
@@ -26,7 +26,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC federated login for the storage writes (secret-less)
|
||||
|
||||
# Shared with build-agent.yml: a script publish and an agent release both touch
|
||||
# version.json, and the bootstrapVersion read-modify-write must not interleave with the
|
||||
@@ -46,15 +45,24 @@ env:
|
||||
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
|
||||
|
||||
jobs:
|
||||
# $/ is the self-repository form: this repository at the running commit, never a
|
||||
# path some earlier step could have written to the workspace.
|
||||
gates:
|
||||
uses: ./.github/workflows/bootstrap-script-gates.yml
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
publish:
|
||||
name: Publish to the download alias
|
||||
needs: gates
|
||||
runs-on: windows-latest
|
||||
# Job-scoped: only the publish job federates to Azure, the gates job never holds an
|
||||
# OIDC token it does not need.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC federated login for the storage writes (secret-less)
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check code-signing configuration
|
||||
shell: pwsh
|
||||
@@ -70,7 +78,7 @@ jobs:
|
||||
# both federated credentials are bound to the ref, so this job must never carry a
|
||||
# GitHub `environment:`.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -91,7 +99,7 @@ jobs:
|
||||
# runs the same publisher with -DryRun and deliberately without a login -- a dry run
|
||||
# must never need Azure credentials.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
+1
-1
Submodule infra updated: 8a8f8c66dd...be0165a223
+1
-1
Submodule internal updated: b54f43f711...0458cc6c08
@@ -0,0 +1,3 @@
|
||||
# ajv-cli declares ts-node as a peer dependency it never needs for JSON schemas; do not
|
||||
# pull ts-node + typescript into a job that pushes to main.
|
||||
legacy-peer-deps=true
|
||||
Generated
+315
@@ -0,0 +1,315 @@
|
||||
{
|
||||
"name": "autopilot-monitor-rules-tooling",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "autopilot-monitor-rules-tooling",
|
||||
"devDependencies": {
|
||||
"ajv-cli": "5.0.0",
|
||||
"ajv-formats": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-cli": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv-cli/-/ajv-cli-5.0.0.tgz",
|
||||
"integrity": "sha512-LY4m6dUv44HTyhV+u2z5uX4EhPYTM38Iv1jdgDJJJCyOOuqB8KtZEGjPZ2T+sh5ZIJrXUfgErYx/j3gLd3+PlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0",
|
||||
"fast-json-patch": "^2.0.0",
|
||||
"glob": "^7.1.0",
|
||||
"js-yaml": "^3.14.0",
|
||||
"json-schema-migrate": "^2.0.0",
|
||||
"json5": "^2.1.3",
|
||||
"minimist": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"ajv": "dist/index.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ts-node": ">=9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ts-node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-patch": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz",
|
||||
"integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.15.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
|
||||
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-migrate": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-2.0.0.tgz",
|
||||
"integrity": "sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "autopilot-monitor-rules-tooling",
|
||||
"private": true,
|
||||
"description": "Validator tooling for rules/ - combine-rules.yml installs from the lockfile so nothing unpinned reaches a job that pushes to main.",
|
||||
"devDependencies": {
|
||||
"ajv-cli": "5.0.0",
|
||||
"ajv-formats": "3.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-json-patch": "3.1.1"
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,9 @@
|
||||
Cloud PCs are never Autopilot-registered, so this enables agent monitoring
|
||||
of Cloud PC first-connect enrollment)
|
||||
- IntuneDeviceBinding -> DeviceManagementManagedDevices.Read.All
|
||||
(cert-to-device binding: verifies that the Intune device id carried in the
|
||||
agent's client certificate is a device THIS tenant actually enrolled --
|
||||
currently a Global-Admin-only preview that records telemetry and does not
|
||||
block enrollment)
|
||||
(Intune Enrollment Validation: accepts devices enrolled in THIS tenant's
|
||||
Intune, matched by the Intune device id in the agent's client certificate,
|
||||
without Autopilot registration, corporate identifier or device association)
|
||||
- All -> every optional permission above, in one run
|
||||
(also works with -Revoke to remove all optional grants at once)
|
||||
|
||||
|
||||
+15
@@ -186,5 +186,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
DateTime resolved;
|
||||
Assert.False(sut.TryResolveUtc(Source, UtcNow, out resolved));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(3610.39, true, 4)] // session 4377911b: +1 h on a 10 s script
|
||||
[InlineData(61203.0, true, 68)] // 17 h on a 3 s script
|
||||
[InlineData(7205.66, true, 8)] // session cbaed57b: +2 h
|
||||
[InlineData(-3596.0, true, 4)] // the same error on the start end
|
||||
[InlineData(26.4, false, 0)] // a run time below one grid step
|
||||
[InlineData(1030.0, false, 1)] // 17 min 10 s: residual past the tolerance
|
||||
[InlineData(900.0, true, 1)] // exactly one step — a genuine 15-minute run reads the same, the caller needs mixed provenance too
|
||||
public void A_grid_sized_span_is_the_signature_of_an_offset_error(double seconds, bool onGrid, int expectedSteps)
|
||||
{
|
||||
int steps;
|
||||
Assert.Equal(onGrid, CmTraceOffsetCalibrator.IsOnOffsetGrid(TimeSpan.FromSeconds(seconds), out steps));
|
||||
if (onGrid) Assert.Equal(expectedSteps, steps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -304,5 +304,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
tracker.HandlePlatformScriptStarted("policyZ");
|
||||
Assert.Single(started);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Emitted_marker_is_capped_at_the_clock_so_the_next_run_still_opens_a_slot()
|
||||
{
|
||||
// Session 4377911b: the result line was re-read after an overwrite rewind and resolved
|
||||
// in the reader's zone, one hour ahead of the clock. Stored uncapped, that stamp made
|
||||
// the genuine second run's start line read as a late line of the first run and its
|
||||
// result a duplicate.
|
||||
using var h = new Harness();
|
||||
h.Start("d94468af", T0.AddSeconds(-10));
|
||||
h.Result("d94468af", T0.AddHours(1));
|
||||
h.Exit("d94468af", T0.AddSeconds(-1));
|
||||
h.Flush();
|
||||
Assert.Single(h.Completed);
|
||||
|
||||
// The second run, two minutes later on the clock — still "before" the uncapped stamp.
|
||||
h.Now = T0.AddMinutes(2);
|
||||
h.Start("d94468af", h.Now.AddSeconds(-5));
|
||||
h.Result("d94468af", h.Now.AddSeconds(-1));
|
||||
h.Exit("d94468af", h.Now.AddSeconds(-2));
|
||||
h.Flush();
|
||||
|
||||
Assert.Equal(2, h.Completed.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -154,5 +154,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.False(tracker2.TryClaimScriptTimeoutSuspected("policyF")); // cross-restart dedup
|
||||
Assert.True(tracker2.TryClaimScriptTimeoutSuspected("policyG")); // other policies unaffected
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_provenance_of_a_pending_script_survives_restart()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var observedAt = new DateTime(2026, 9, 17, 13, 20, 7, DateTimeKind.Utc);
|
||||
var start = new CmTraceLineProvenance
|
||||
{
|
||||
SourceLocalTs = new DateTime(2026, 9, 17, 15, 19, 57, DateTimeKind.Unspecified),
|
||||
Origin = CmTraceOffsetOrigin.LineAnchored,
|
||||
OffsetMinutes = 120,
|
||||
MeasuredWriterOffsetMinutes = 120,
|
||||
SourceFileName = "IntuneManagementExtension.log",
|
||||
};
|
||||
var exit = new CmTraceLineProvenance
|
||||
{
|
||||
SourceLocalTs = new DateTime(2026, 9, 17, 15, 20, 7, DateTimeKind.Unspecified),
|
||||
Origin = CmTraceOffsetOrigin.None,
|
||||
OffsetMinutes = 60,
|
||||
SourceFileName = "AgentExecutor.log",
|
||||
};
|
||||
|
||||
var tracker1 = BuildTracker(tmp, out _);
|
||||
tracker1.SeedPendingPlatformScriptForTesting("policyH", exitCode: 0, exitObservedAtUtc: observedAt,
|
||||
startedAtUtc: observedAt.AddSeconds(-10), startedAtProvenance: start, exitProvenance: exit);
|
||||
tracker1.SaveStateForTest();
|
||||
|
||||
var tracker2 = BuildTracker(tmp, out var emitted2);
|
||||
tracker2.LoadStateForTest();
|
||||
tracker2.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
|
||||
var script = Assert.Single(emitted2);
|
||||
Assert.Equal(CmTraceOffsetOrigin.LineAnchored, script.StartedAtProvenance.Origin);
|
||||
Assert.Equal(120, script.StartedAtProvenance.OffsetMinutes);
|
||||
Assert.Equal(start.SourceLocalTs, script.StartedAtProvenance.SourceLocalTs);
|
||||
Assert.Equal(DateTimeKind.Unspecified, script.StartedAtProvenance.SourceLocalTs!.Value.Kind); // no zone was ever attached
|
||||
Assert.Equal("AgentExecutor.log", script.ExitProvenance.SourceFileName);
|
||||
Assert.Equal(60, script.ExitProvenance.OffsetMinutes);
|
||||
Assert.Null(script.ResultProvenance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+137
-2
@@ -59,9 +59,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
public void ClassifyEnforcementState_bands(int state, string expected)
|
||||
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyEnforcementState(state));
|
||||
|
||||
// IME AppInstallStatus values (StatusServiceReports\..\Status), stable IME 1.50 .. 1.106.
|
||||
// 1001/1003 sit inside the 1000 band but are NOT terminal — IME maps every InProgress
|
||||
// enforcement state to 1001 (verified in the decompiled 1.106 StatusServiceHelpers).
|
||||
[Theory]
|
||||
[InlineData(1000, "installed")]
|
||||
[InlineData(1999, "installed")]
|
||||
[InlineData(1002, "installed")]
|
||||
[InlineData(1004, "installed")]
|
||||
[InlineData(1001, "installing")]
|
||||
[InlineData(1003, "installing")]
|
||||
[InlineData(1005, null)]
|
||||
[InlineData(1999, null)]
|
||||
[InlineData(3000, "failed")]
|
||||
[InlineData(2000, "notApplicable")]
|
||||
[InlineData(2999, "notApplicable")]
|
||||
@@ -72,6 +80,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
public void ClassifyStatusServiceStatus_bands(int status, string? expected)
|
||||
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyStatusServiceStatus(status));
|
||||
|
||||
[Fact]
|
||||
public void TryParseRegistryDateTime_reads_both_ime_shapes_as_utc()
|
||||
{
|
||||
// ExecutionDeadlineTime: DateTime.ToString(InvariantCulture), UTC without a kind marker.
|
||||
var deadline = ImeRegistryAppStateObserver.TryParseRegistryDateTime("09/23/2026 18:00:00");
|
||||
Assert.Equal(new DateTime(2026, 9, 23, 18, 0, 0, DateTimeKind.Utc), deadline);
|
||||
Assert.Equal(DateTimeKind.Utc, deadline!.Value.Kind);
|
||||
|
||||
// DeferUntilTime: round-trip "o" with trailing Z.
|
||||
var until = ImeRegistryAppStateObserver.TryParseRegistryDateTime("2026-09-23T10:30:00.0000000Z");
|
||||
Assert.Equal(new DateTime(2026, 9, 23, 10, 30, 0, DateTimeKind.Utc), until);
|
||||
Assert.Equal(DateTimeKind.Utc, until!.Value.Kind);
|
||||
|
||||
Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime(null));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime(""));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime("not a date"));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime(42));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadBool_parses_registry_strings_only()
|
||||
{
|
||||
Assert.True(ImeRegistryAppStateObserver.TryReadBool("True"));
|
||||
Assert.False(ImeRegistryAppStateObserver.TryReadBool("false"));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TryReadBool("yes"));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TryReadBool(1));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TryReadBool(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TerminalOutcome_prefers_enforcement_state_then_status_service()
|
||||
{
|
||||
@@ -88,6 +125,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 3000)));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 2500)));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry()));
|
||||
// Installing (1001) / InstallingPendingReboot (1003) share the 1000 band but are not terminal.
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 1001)));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 2009, statusServiceStatus: 1003)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangedFields_reports_wait_state_triggers_only()
|
||||
{
|
||||
var prev = Entry(enforcementState: 2009);
|
||||
var next = Entry(enforcementState: 2009,
|
||||
installDeadlineUtc: T0.AddHours(2),
|
||||
deferralsUsed: 1, deferralMaxDeferrals: 3, deferUntilUtc: T0.AddMinutes(30), deferralAutoDeferred: false);
|
||||
|
||||
Assert.Equal(new[] { "installDeadline", "deferralsUsed", "deferUntil" },
|
||||
ImeRegistryAppStateObserver.ChangedFields(prev, next));
|
||||
|
||||
// MaxDeferrals / AutoDeferred alone are data, not a transition.
|
||||
var sameCounters = Entry(enforcementState: 2009,
|
||||
installDeadlineUtc: T0.AddHours(2),
|
||||
deferralsUsed: 1, deferralMaxDeferrals: 5, deferUntilUtc: T0.AddMinutes(30), deferralAutoDeferred: true);
|
||||
Assert.Empty(ImeRegistryAppStateObserver.ChangedFields(next, sameCounters));
|
||||
|
||||
// A cleared deadline is a transition too (IME clears it when the next download starts).
|
||||
Assert.Equal(new[] { "installDeadline" },
|
||||
ImeRegistryAppStateObserver.ChangedFields(
|
||||
Entry(installDeadlineUtc: T0.AddHours(2)), Entry()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescribeWaitState_texts()
|
||||
{
|
||||
Assert.Equal(string.Empty, ImeRegistryAppStateObserver.DescribeWaitState(Entry(enforcementState: 2009)));
|
||||
Assert.Equal(", downloaded, install deadline 2026-08-18 10:00 UTC",
|
||||
ImeRegistryAppStateObserver.DescribeWaitState(Entry(installDeadlineUtc: T0.AddHours(2))));
|
||||
Assert.Equal(", deferred by user 1/3 until 2026-08-18 08:30 UTC",
|
||||
ImeRegistryAppStateObserver.DescribeWaitState(Entry(
|
||||
deferralsUsed: 1, deferralMaxDeferrals: 3, deferUntilUtc: T0.AddMinutes(30), deferralAutoDeferred: false)));
|
||||
Assert.Equal(", auto-deferred 2/3",
|
||||
ImeRegistryAppStateObserver.DescribeWaitState(Entry(
|
||||
deferralsUsed: 2, deferralMaxDeferrals: 3, deferralAutoDeferred: true)));
|
||||
// Zero deferrals used is not a deferral; a missing max falls back to the bare count.
|
||||
Assert.Equal(string.Empty, ImeRegistryAppStateObserver.DescribeWaitState(Entry(deferralsUsed: 0, deferralMaxDeferrals: 3)));
|
||||
Assert.Equal(", deferred by user 1", ImeRegistryAppStateObserver.DescribeWaitState(Entry(deferralsUsed: 1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -184,6 +264,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.Equal("success", evt.Payload["enforcementClass"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wait_state_changes_emit_state_events_with_data_and_text()
|
||||
{
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: null);
|
||||
var current = Snapshot((App1, 2009, null));
|
||||
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
|
||||
// Download finished, IME parks the app until its install deadline (1.106 ExecutionDeadlineTime).
|
||||
var withDeadline = Snapshot((App1, 2009, null));
|
||||
withDeadline.Entries.Values.Single().InstallDeadlineUtc = T0.AddHours(2);
|
||||
current = withDeadline;
|
||||
observer.Tick("registry_change");
|
||||
|
||||
// A required app hits a running process and the user defers (1.105 app-in-use deferral).
|
||||
var deferred = Snapshot((App1, 2014, null));
|
||||
var entry = deferred.Entries.Values.Single();
|
||||
entry.InstallDeadlineUtc = T0.AddHours(2);
|
||||
entry.DeferralsUsed = 1;
|
||||
entry.DeferralMaxDeferrals = 3;
|
||||
entry.DeferUntilUtc = T0.AddMinutes(30);
|
||||
entry.DeferralAutoDeferred = false;
|
||||
current = deferred;
|
||||
observer.Tick("registry_change");
|
||||
}
|
||||
|
||||
var events = Events(sink, SharedEventTypes.RegistryAppState);
|
||||
Assert.Equal(2, events.Count);
|
||||
|
||||
var deadlineEvent = events[0];
|
||||
Assert.Equal("installDeadline", deadlineEvent.Payload!["changedFields"]);
|
||||
Assert.Equal("2026-08-18T10:00:00.0000000Z", deadlineEvent.Payload["installDeadlineUtc"]);
|
||||
Assert.DoesNotContain("deferralsUsed", deadlineEvent.Payload.Keys);
|
||||
Assert.Contains("-> inProgress, downloaded, install deadline 2026-08-18 10:00 UTC",
|
||||
deadlineEvent.Payload[SignalPayloadKeys.Message]);
|
||||
|
||||
var deferralEvent = events[1];
|
||||
Assert.Equal("enforcementState,deferralsUsed,deferUntil", deferralEvent.Payload!["changedFields"]);
|
||||
Assert.Equal("2014", deferralEvent.Payload["enforcementState"]);
|
||||
Assert.Equal("1", deferralEvent.Payload["deferralsUsed"]);
|
||||
Assert.Equal("3", deferralEvent.Payload["deferralMaxDeferrals"]);
|
||||
Assert.Equal("2026-08-18T08:30:00.0000000Z", deferralEvent.Payload["deferUntilUtc"]);
|
||||
Assert.Equal("false", deferralEvent.Payload["deferralAutoDeferred"]);
|
||||
Assert.Contains("deferred by user 1/3 until 2026-08-18 08:30 UTC", deferralEvent.Payload[SignalPayloadKeys.Message]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonEmpty_baseline_emits_one_summary_with_counts()
|
||||
{
|
||||
@@ -331,7 +459,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
|
||||
private static AppRegistryEntry Entry(
|
||||
int? enforcementState = null, long? errorCode = null, int? exitCode = null,
|
||||
int? statusServiceStatus = null, bool espTracked = false, string? espPhase = null)
|
||||
int? statusServiceStatus = null, bool espTracked = false, string? espPhase = null,
|
||||
DateTime? installDeadlineUtc = null, int? deferralsUsed = null, int? deferralMaxDeferrals = null,
|
||||
DateTime? deferUntilUtc = null, bool? deferralAutoDeferred = null)
|
||||
{
|
||||
var entry = new AppRegistryEntry($"{ImeRegistrySnapshot.DeviceContext}|{App1}", ImeRegistrySnapshot.DeviceContext, App1);
|
||||
entry.EnforcementState = enforcementState;
|
||||
@@ -340,6 +470,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
entry.StatusServiceStatus = statusServiceStatus;
|
||||
entry.EspTracked = espTracked;
|
||||
entry.EspPhase = espPhase;
|
||||
entry.InstallDeadlineUtc = installDeadlineUtc;
|
||||
entry.DeferralsUsed = deferralsUsed;
|
||||
entry.DeferralMaxDeferrals = deferralMaxDeferrals;
|
||||
entry.DeferUntilUtc = deferUntilUtc;
|
||||
entry.DeferralAutoDeferred = deferralAutoDeferred;
|
||||
return entry;
|
||||
}
|
||||
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
{
|
||||
/// <summary>
|
||||
/// A script run duration is the span between two CMTrace lines, each resolved on its own zone
|
||||
/// belief. When the beliefs differ the span is a whole number of offset-grid steps plus the run
|
||||
/// time, not the run time: session 4377911b read a 10 s script as 3610 s (start line-anchored
|
||||
/// +120, result re-read after an overwrite rewind in the reader's zone +60 and rejected as
|
||||
/// ahead of the clock), a fleet tenant read 2–9 s scripts as 17 h every day. The adapter judges
|
||||
/// the pair from the provenance bound to each line and omits the span rather than lie.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerAdapterDurationProvenanceTests
|
||||
{
|
||||
private const string ServiceLog = "IntuneManagementExtension.log";
|
||||
private const string ExecutorLog = "AgentExecutor.log";
|
||||
|
||||
private static CmTraceLineProvenance Line(
|
||||
DateTime resolvedUtc, int offsetMinutes, CmTraceOffsetOrigin origin,
|
||||
string file = ServiceLog, string? anchor = null, int? measured = null) =>
|
||||
new CmTraceLineProvenance
|
||||
{
|
||||
// The writer's local time is the resolved value with the applied offset put back.
|
||||
SourceLocalTs = DateTime.SpecifyKind(resolvedUtc.AddMinutes(offsetMinutes), DateTimeKind.Unspecified),
|
||||
Origin = origin,
|
||||
OffsetMinutes = offsetMinutes,
|
||||
EraAnchorKind = anchor,
|
||||
MeasuredWriterOffsetMinutes = measured,
|
||||
SourceFileName = file,
|
||||
};
|
||||
|
||||
private static ScriptExecutionState Platform(string policyId, DateTime startedAtUtc, CmTraceLineProvenance start,
|
||||
DateTime? resultObservedAtUtc = null, CmTraceLineProvenance? result = null, string outcome = "Success", int exitCode = 0) =>
|
||||
new ScriptExecutionState
|
||||
{
|
||||
PolicyId = policyId,
|
||||
ScriptType = "platform",
|
||||
ExitCode = exitCode,
|
||||
Result = outcome,
|
||||
ResultSource = "ime_policy_result",
|
||||
StartedAtUtc = startedAtUtc,
|
||||
StartedAtProvenance = start,
|
||||
ResultObservedAtUtc = resultObservedAtUtc,
|
||||
ResultProvenance = result,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Rejected_result_line_against_a_line_anchored_start_omits_the_span()
|
||||
{
|
||||
// Session 4377911b, script d94468af: start 15:19:57.005 local, self-anchored (+120);
|
||||
// result 15:20:07.391 local, re-read after a rewind and resolved in the reader's
|
||||
// zone (+60) → 14:20:07Z, one hour ahead of the clock. The raw pair reads 3610 s.
|
||||
var clockNow = new DateTime(2026, 9, 17, 13, 20, 7, 437, DateTimeKind.Utc);
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var startUtc = new DateTime(2026, 9, 17, 13, 19, 57, 5, DateTimeKind.Utc);
|
||||
var resultRaw = new DateTime(2026, 9, 17, 14, 20, 7, 391, DateTimeKind.Utc);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("d94468af",
|
||||
startUtc, Line(startUtc, 120, CmTraceOffsetOrigin.LineAnchored, measured: 120),
|
||||
resultRaw, Line(resultRaw, 60, CmTraceOffsetOrigin.None, measured: 120)));
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.Equal(clockNow, info.OccurredAtUtc); // stamp: clamped to the clock
|
||||
Assert.Equal("true", info.Payload!["derivedTimestamp"]);
|
||||
Assert.Equal(resultRaw.ToString("o"), info.Payload["rejectedSourceTimestamp"]);
|
||||
Assert.False(info.Payload.ContainsKey("durationSeconds"));
|
||||
Assert.False(info.Payload.ContainsKey("durationBasis"));
|
||||
Assert.Equal("rejected-endpoint", info.Payload["durationSuppressedReason"]);
|
||||
Assert.Equal("unverified", info.Payload["durationProvenance"]);
|
||||
// The end line's own provenance and the start line's, side by side.
|
||||
Assert.Equal("reader-zone-fallback", info.Payload["sourceOffsetOrigin"]);
|
||||
Assert.Equal("60", info.Payload["sourceOffsetMinutes"]);
|
||||
Assert.Equal("120", info.Payload["measuredWriterOffsetMinutes"]);
|
||||
Assert.Equal("2026-09-17T15:20:07.3910000", info.Payload["sourceLocalTs"]);
|
||||
Assert.Equal("line-anchored", info.Payload["startSourceOffsetOrigin"]);
|
||||
Assert.Equal("120", info.Payload["startSourceOffsetMinutes"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejected_era_anchored_result_of_a_failed_script_omits_the_span_and_the_timeout_verdict()
|
||||
{
|
||||
// Fleet shape (script_failed, daily in one tenant): result era-anchored at −420 (the
|
||||
// OOBE default zone's era) on a line written under +600 → 17 h ahead of the clock;
|
||||
// the 61203 s "run" of a 3 s script fired script_timeout_suspected every day.
|
||||
var clockNow = new DateTime(2026, 9, 21, 1, 30, 13, DateTimeKind.Utc);
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var startUtc = clockNow.AddSeconds(-8);
|
||||
var resultRaw = clockNow.AddSeconds(-5).AddHours(17);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("88d406eb",
|
||||
startUtc, Line(startUtc, 600, CmTraceOffsetOrigin.LineAnchored),
|
||||
resultRaw, Line(resultRaw, -420, CmTraceOffsetOrigin.EraAnchored, anchor: "bootstrap-policy-result", measured: 600),
|
||||
outcome: "Failed"));
|
||||
|
||||
var failed = f.InfoEvent(SharedEventTypes.ScriptFailed);
|
||||
Assert.Equal("rejected-endpoint", failed.Payload!["durationSuppressedReason"]);
|
||||
Assert.False(failed.Payload.ContainsKey("durationSeconds"));
|
||||
Assert.Equal("era-anchored", failed.Payload["sourceOffsetOrigin"]);
|
||||
Assert.Equal("bootstrap-policy-result", failed.Payload["sourceOffsetAnchor"]);
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ScriptTimeoutSuspected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mixed_pair_on_the_offset_grid_omits_the_span_even_when_nothing_was_rejected()
|
||||
{
|
||||
// Session cbaed57b: start from AgentExecutor.log in the reader's zone (+120, two
|
||||
// hours too early), result era-anchored at 0 in the service log → 7205 s.
|
||||
var clockNow = new DateTime(2026, 9, 11, 8, 48, 25, DateTimeKind.Utc);
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var resultUtc = clockNow.AddSeconds(-1);
|
||||
var startUtc = resultUtc.AddSeconds(-7205);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("846cea22",
|
||||
startUtc, Line(startUtc, 120, CmTraceOffsetOrigin.None, file: ExecutorLog),
|
||||
resultUtc, Line(resultUtc, 0, CmTraceOffsetOrigin.EraAnchored, anchor: "bootstrap-policy-result")));
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.False(info.Payload!.ContainsKey("derivedTimestamp"));
|
||||
Assert.False(info.Payload.ContainsKey("durationSeconds"));
|
||||
Assert.Equal("offset-grid", info.Payload["durationSuppressedReason"]);
|
||||
Assert.Equal("unverified", info.Payload["durationProvenance"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mixed_pair_off_the_grid_keeps_the_span_as_unverified()
|
||||
{
|
||||
var clockNow = ImeLogTrackerAdapterFixture.DefaultClockStart;
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var resultUtc = clockNow.AddSeconds(-1);
|
||||
var startUtc = resultUtc.AddSeconds(-26);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("f6f67f79",
|
||||
startUtc, Line(startUtc, 120, CmTraceOffsetOrigin.LineAnchored),
|
||||
resultUtc, Line(resultUtc, 120, CmTraceOffsetOrigin.None)));
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.Equal("26.00", info.Payload!["durationSeconds"]);
|
||||
Assert.Equal("script_runtime", info.Payload["durationBasis"]);
|
||||
Assert.Equal("unverified", info.Payload["durationProvenance"]);
|
||||
Assert.False(info.Payload.ContainsKey("durationSuppressedReason"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_line_anchored_ends_on_the_grid_are_a_verified_fifteen_minute_run()
|
||||
{
|
||||
var clockNow = ImeLogTrackerAdapterFixture.DefaultClockStart;
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var resultUtc = clockNow.AddSeconds(-1);
|
||||
var startUtc = resultUtc.AddSeconds(-900);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("11111111",
|
||||
startUtc, Line(startUtc, 120, CmTraceOffsetOrigin.LineAnchored),
|
||||
resultUtc, Line(resultUtc, 120, CmTraceOffsetOrigin.LineAnchored)));
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.Equal("900.00", info.Payload!["durationSeconds"]);
|
||||
Assert.Equal("verified", info.Payload["durationProvenance"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_fallback_ends_from_the_same_file_share_their_error_and_stay_measurable()
|
||||
{
|
||||
var clockNow = ImeLogTrackerAdapterFixture.DefaultClockStart;
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var resultUtc = clockNow.AddSeconds(-1);
|
||||
var startUtc = resultUtc.AddSeconds(-900);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("22222222",
|
||||
startUtc, Line(startUtc, 60, CmTraceOffsetOrigin.None),
|
||||
resultUtc, Line(resultUtc, 60, CmTraceOffsetOrigin.None)));
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.Equal("900.00", info.Payload!["durationSeconds"]);
|
||||
Assert.Equal("verified", info.Payload["durationProvenance"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_fallback_ends_from_different_files_are_different_writers()
|
||||
{
|
||||
// Same reader zone applied to both, but the executor child process and the service
|
||||
// can hold different beliefs (cbaed57b) — a grid-sized span is not trusted.
|
||||
var clockNow = ImeLogTrackerAdapterFixture.DefaultClockStart;
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var resultUtc = clockNow.AddSeconds(-1);
|
||||
var startUtc = resultUtc.AddSeconds(-900);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("33333333",
|
||||
startUtc, Line(startUtc, 60, CmTraceOffsetOrigin.None, file: ExecutorLog),
|
||||
resultUtc, Line(resultUtc, 60, CmTraceOffsetOrigin.None)));
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.False(info.Payload!.ContainsKey("durationSeconds"));
|
||||
Assert.Equal("offset-grid", info.Payload["durationSuppressedReason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_provenance_the_pair_is_taken_as_before()
|
||||
{
|
||||
// States from before the field (persisted by an older agent) and synthetic events:
|
||||
// no snapshot on either end, the 30-minute Failed run still reads as a timeout.
|
||||
var clockNow = ImeLogTrackerAdapterFixture.DefaultClockStart;
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = clockNow.AddSeconds(-1);
|
||||
adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState
|
||||
{
|
||||
PolicyId = "44444444",
|
||||
ScriptType = "platform",
|
||||
ExitCode = 0,
|
||||
Result = "Failed",
|
||||
StartedAtUtc = clockNow.AddSeconds(-1801),
|
||||
});
|
||||
|
||||
var failed = f.InfoEvent(SharedEventTypes.ScriptFailed);
|
||||
Assert.Equal("1800.00", failed.Payload!["durationSeconds"]);
|
||||
Assert.False(failed.Payload.ContainsKey("durationProvenance"));
|
||||
Assert.False(failed.Payload.ContainsKey("durationSuppressedReason"));
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.ScriptTimeoutSuspected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Negative_grid_sized_span_from_a_future_shifted_start_is_named_not_dropped_silently()
|
||||
{
|
||||
var clockNow = ImeLogTrackerAdapterFixture.DefaultClockStart;
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var resultUtc = clockNow.AddSeconds(-1);
|
||||
var startUtc = resultUtc.AddSeconds(3595); // resolved one hour ahead of its real instant
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("55555555",
|
||||
startUtc, Line(startUtc, 60, CmTraceOffsetOrigin.None),
|
||||
resultUtc, Line(resultUtc, 120, CmTraceOffsetOrigin.LineAnchored)));
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.False(info.Payload!.ContainsKey("durationSeconds"));
|
||||
Assert.Equal("offset-grid", info.Payload["durationSuppressedReason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Health_script_cycle_is_judged_the_same_way()
|
||||
{
|
||||
// HS-SCRIPT-START self-anchored, HS-NEW-RESULT re-read in the reader's zone and
|
||||
// rejected: the cycle duration is omitted, the compliance verdict stays.
|
||||
var clockNow = ImeLogTrackerAdapterFixture.DefaultClockStart;
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var startUtc = clockNow.AddSeconds(-40);
|
||||
var resultRaw = clockNow.AddSeconds(-2).AddHours(1);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState
|
||||
{
|
||||
PolicyId = "66666666",
|
||||
ScriptType = "remediation",
|
||||
ScriptPart = "detection",
|
||||
ExitCode = 0,
|
||||
ComplianceResult = "True",
|
||||
StartedAtUtc = startUtc,
|
||||
StartedAtProvenance = Line(startUtc, 120, CmTraceOffsetOrigin.LineAnchored),
|
||||
ResultObservedAtUtc = resultRaw,
|
||||
ResultProvenance = Line(resultRaw, 60, CmTraceOffsetOrigin.None),
|
||||
DurationBasis = "cycle_including_reporting_latency",
|
||||
});
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.Equal("True", info.Payload!["complianceResult"]);
|
||||
Assert.False(info.Payload.ContainsKey("durationSeconds"));
|
||||
Assert.Equal("rejected-endpoint", info.Payload["durationSuppressedReason"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
@@ -45,5 +46,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Equal("ime_policy_result", info.Payload["resultSource"]);
|
||||
Assert.Equal("92.00", info.Payload["durationSeconds"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Held_completion_carries_the_provenance_of_its_result_line()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var startTs = ClockNow.AddSeconds(-95);
|
||||
var resultTs = ClockNow.AddSeconds(-3);
|
||||
|
||||
// Start and result lines both self-anchored (+120).
|
||||
f.Tracker.LastMatchedLogTimestamp = startTs;
|
||||
f.Tracker.LastMatchedSourceLocalTimestamp = DateTime.SpecifyKind(startTs.AddMinutes(120), DateTimeKind.Unspecified);
|
||||
f.Tracker.LastMatchedSourceOffsetOrigin = CmTraceOffsetOrigin.LineAnchored;
|
||||
f.Tracker.LastMatchedSourceOffsetMinutes = 120;
|
||||
f.Tracker.HandlePlatformScriptStarted("376ee51a");
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = resultTs;
|
||||
f.Tracker.LastMatchedSourceLocalTimestamp = DateTime.SpecifyKind(resultTs.AddMinutes(120), DateTimeKind.Unspecified);
|
||||
f.Tracker.LastMatchedPatternId = "PS-SCRIPT-RESULT";
|
||||
f.Tracker.CompletePlatformScriptFromImeResultForTesting("376ee51a", "Success", resultTs);
|
||||
|
||||
// By the time the held completion is emitted, the last matched line is an unrelated
|
||||
// one resolved in the reader's zone.
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddSeconds(-1);
|
||||
f.Tracker.LastMatchedSourceLocalTimestamp = DateTime.SpecifyKind(ClockNow.AddSeconds(-1).AddMinutes(60), DateTimeKind.Unspecified);
|
||||
f.Tracker.LastMatchedSourceOffsetOrigin = CmTraceOffsetOrigin.None;
|
||||
f.Tracker.LastMatchedSourceOffsetMinutes = 60;
|
||||
f.Tracker.LastMatchedPatternId = "IME-DOWNLOADING";
|
||||
f.Tracker.RecordPlatformScriptExitCodeForTesting("376ee51a", 0, resultTs.AddMilliseconds(-8));
|
||||
f.Tracker.FlushPendingPlatformScriptResults(f.Clock.UtcNow);
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.Equal("line-anchored", info.Payload!["sourceOffsetOrigin"]);
|
||||
Assert.Equal("120", info.Payload["sourceOffsetMinutes"]);
|
||||
Assert.Equal(resultTs.AddMinutes(120).ToString("yyyy-MM-ddTHH:mm:ss.fffffff"), info.Payload["sourceLocalTs"]);
|
||||
Assert.Equal("line-anchored", info.Payload["startSourceOffsetOrigin"]);
|
||||
Assert.Equal("120", info.Payload["startSourceOffsetMinutes"]);
|
||||
Assert.Equal("92.00", info.Payload["durationSeconds"]);
|
||||
Assert.Equal("verified", info.Payload["durationProvenance"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// How ONE CMTrace line's UTC value was arrived at, captured at the line itself.
|
||||
/// <para>
|
||||
/// The tracker exposes the provenance of the line it matched last. A consumer that acts later —
|
||||
/// a platform-script result held for its executor end block, a run duration spanning a start
|
||||
/// line and a result line — must describe ITS line, not whichever line happened to be matched
|
||||
/// last, so the snapshot travels on <see cref="ScriptExecutionState"/> (persisted as a plain
|
||||
/// DTO; null on state files written before it existed).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class CmTraceLineProvenance
|
||||
{
|
||||
/// <summary>
|
||||
/// The line's local time exactly as the writer wrote it (Kind Unspecified, no zone). Null
|
||||
/// when the value did not come from a CMTrace line at all (clock fallback).
|
||||
/// </summary>
|
||||
public DateTime? SourceLocalTs { get; set; }
|
||||
|
||||
/// <summary>How the offset applied to <see cref="SourceLocalTs"/> was obtained.</summary>
|
||||
public CmTraceOffsetOrigin Origin { get; set; }
|
||||
|
||||
/// <summary>The offset that was applied, in minutes (local = UTC + offset).</summary>
|
||||
public int? OffsetMinutes { get; set; }
|
||||
|
||||
/// <summary>For <see cref="CmTraceOffsetOrigin.EraAnchored"/>: which anchor established the era's offset.</summary>
|
||||
public string EraAnchorKind { get; set; }
|
||||
|
||||
/// <summary>What the calibrator measured for the source file — observational, never applied.</summary>
|
||||
public int? MeasuredWriterOffsetMinutes { get; set; }
|
||||
|
||||
/// <summary>Name of the log file the line was read from — the closest thing to a writer identity the tracker has.</summary>
|
||||
public string SourceFileName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the applied offset was ASSUMED for this line rather than declared by the
|
||||
/// writer or measured against the agent clock: the reader's own zone (fallback), a writer
|
||||
/// era's anchor, or the retired per-file calibration. A clock-derived value (no source
|
||||
/// timestamp) sits on the true UTC axis and is not an assumption.
|
||||
/// </summary>
|
||||
public bool IsAssumed()
|
||||
{
|
||||
if (!SourceLocalTs.HasValue) return false;
|
||||
switch (Origin)
|
||||
{
|
||||
case CmTraceOffsetOrigin.Bias:
|
||||
case CmTraceOffsetOrigin.LineAnchored:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -159,6 +159,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a span between two resolved lines is the signature of a wrong offset on one
|
||||
/// of them rather than of elapsed time: a zone error is always a whole number of grid
|
||||
/// steps, so a span within <see cref="MaxGridResidual"/> of k × <see cref="OffsetGrid"/>
|
||||
/// (k ≥ 1) reads as an offset error plus a run time of seconds. Sign is ignored — a wrong
|
||||
/// offset on the START end produces the same magnitude with the opposite sign.
|
||||
/// </summary>
|
||||
internal static bool IsOnOffsetGrid(TimeSpan span, out int gridSteps)
|
||||
{
|
||||
var magnitude = span.Duration();
|
||||
gridSteps = (int)Math.Round(magnitude.TotalMinutes / OffsetGrid.TotalMinutes, MidpointRounding.AwayFromZero);
|
||||
if (gridSteps < 1) return false;
|
||||
|
||||
var nearest = TimeSpan.FromMinutes(gridSteps * OffsetGrid.TotalMinutes);
|
||||
return (magnitude - nearest).Duration() <= MaxGridResidual;
|
||||
}
|
||||
|
||||
public bool TryCalibrate(string sourceKey, DateTime localTimestamp, DateTime agentUtcNow)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sourceKey)) return false;
|
||||
|
||||
+59
-23
@@ -252,8 +252,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// duration when the consolidated [HS] new result line arrives (the slot above is
|
||||
// cleared by the early-signal HS-COMPLIANCE handler, so timing lives in its own
|
||||
// map). Prefer the source CMTrace timestamp; latest start wins on a policy re-run.
|
||||
var startedAtUtc = LastMatchedLogTimestamp ?? DateTime.UtcNow;
|
||||
_healthScriptStartTimes[id] = startedAtUtc;
|
||||
var startedAtUtc = LastMatchedLogTimestamp ?? UtcNowProvider();
|
||||
_healthScriptStartTimes[id] = (startedAtUtc, CaptureLastMatchedProvenance());
|
||||
_logger.Info($"ImeLogTracker: health script started: {id}");
|
||||
|
||||
// A replayed line of a previous enrollment bypasses the recurrence gate: the
|
||||
@@ -281,6 +281,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
internal void HandlePlatformScriptStarted(string id, string source = null)
|
||||
{
|
||||
var currentStartTs = LastMatchedLogTimestamp ?? UtcNowProvider();
|
||||
var currentStartProvenance = CaptureLastMatchedProvenance();
|
||||
|
||||
// A start line older than the result this policy already emitted is the late start
|
||||
// line of THAT run: its AgentExecutor block surfaced after the IME result — the two
|
||||
@@ -323,6 +324,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// The slot was opened by its result while the start block was still hidden;
|
||||
// the recovered start line dates the run.
|
||||
pendingSlot.StartedAtUtc = currentStartTs;
|
||||
pendingSlot.StartedAtProvenance = currentStartProvenance;
|
||||
_stateDirty = true;
|
||||
}
|
||||
}
|
||||
@@ -343,6 +345,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// Set only at slot creation: the start line fires twice (agentexecutor + ime
|
||||
// source) but the earliest wins.
|
||||
StartedAtUtc = currentStartTs,
|
||||
StartedAtProvenance = currentStartProvenance,
|
||||
};
|
||||
// Live "running" indicator — same signal health scripts emit. Gated on slot
|
||||
// creation so the duplicate start line (agentexecutor + ime source) doesn't
|
||||
@@ -433,7 +436,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
var script = GetCurrentScriptForLineUpdate(parameters, out var ownerPolicyId);
|
||||
if (script != null)
|
||||
ApplyExitCode(script, exitCode, LastMatchedLogTimestamp ?? UtcNowProvider());
|
||||
ApplyExitCode(script, exitCode, LastMatchedLogTimestamp ?? UtcNowProvider(), CaptureLastMatchedProvenance());
|
||||
else if (ownerPolicyId != null)
|
||||
_logger.Debug($"ImeLogTracker: exit code {exitCode} belongs to platform script {ownerPolicyId}, whose completion was already emitted — dropped");
|
||||
// An exit code means the executor just wrote its end block and IME is about to
|
||||
@@ -442,15 +445,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
RequestOverwriteCheck();
|
||||
}
|
||||
|
||||
private void ApplyExitCode(ScriptExecutionState script, int exitCode, DateTime observedAtUtc)
|
||||
private void ApplyExitCode(ScriptExecutionState script, int exitCode, DateTime observedAtUtc, CmTraceLineProvenance exitProvenance)
|
||||
{
|
||||
script.ExitCode = exitCode;
|
||||
// Stamp when we learned the script's exit code so the deadline-based fallback
|
||||
// (FlushPendingPlatformScriptResults) can fire if IME never logs its authoritative
|
||||
// PS-SCRIPT-RESULT line in time. Prefer the source CMTrace timestamp (already
|
||||
// UTC-normalized by the parser) so replayed log content is dated correctly.
|
||||
// UTC-normalized by the parser) so replayed log content is dated correctly. The
|
||||
// line's provenance rides along: the fallback emit is bound to this line.
|
||||
if (string.Equals(script.ScriptType, "platform", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
script.ExitObservedAtUtc = observedAtUtc;
|
||||
script.ExitProvenance = exitProvenance;
|
||||
}
|
||||
_logger.Debug($"ImeLogTracker: script exit code {exitCode} for {script.PolicyId}");
|
||||
}
|
||||
|
||||
@@ -500,7 +507,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
|
||||
CompletePlatformScriptFromImeResult(id, result, LastMatchedLogTimestamp, LastMatchedPatternId);
|
||||
CompletePlatformScriptFromImeResult(id, result, LastMatchedLogTimestamp, LastMatchedPatternId, CaptureLastMatchedProvenance());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -514,7 +521,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// line reopened the slot, produced a fallback duplicate (session e7f3c910).
|
||||
/// <see cref="FlushPendingPlatformScriptResults"/> emits the held completion.
|
||||
/// </summary>
|
||||
internal void CompletePlatformScriptFromImeResult(string policyId, string result, DateTime? resultLineTimestampUtc, string patternId)
|
||||
internal void CompletePlatformScriptFromImeResult(string policyId, string result, DateTime? resultLineTimestampUtc, string patternId, CmTraceLineProvenance resultProvenance)
|
||||
{
|
||||
// Dedup: the deadline-based fallback (FlushPendingPlatformScriptResults) may already have
|
||||
// emitted this script from its AgentExecutor exit code because IME's authoritative
|
||||
@@ -559,6 +566,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
script.Result = result;
|
||||
script.ResultSource = "ime_policy_result";
|
||||
script.ResultObservedAtUtc = resultLineTimestampUtc ?? UtcNowProvider();
|
||||
script.ResultProvenance = resultProvenance;
|
||||
script.ResultPatternId = patternId;
|
||||
_stateDirty = true;
|
||||
|
||||
@@ -584,7 +592,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
_logger.Info($"ImeLogTracker: platform script completed: {script.PolicyId}, result={script.Result}, exit={script.ExitCode}{(note != null ? $" ({note})" : string.Empty)}");
|
||||
EmitScriptEvent(script);
|
||||
_platformScriptResultEmitted[script.PolicyId] = script.ResultObservedAtUtc ?? script.ExitObservedAtUtc ?? UtcNowProvider();
|
||||
// The run's source stamp, capped at the clock: a result line cannot be read before it
|
||||
// was written, so a stamp ahead of the clock is a wrong zone assumption on that line
|
||||
// (a rewound result resolved in the reader's zone, session 4377911b: +1 h). Left
|
||||
// uncapped, the marker made the genuine NEXT run's start line read as a late line of
|
||||
// this run and its result a duplicate.
|
||||
var runStamp = script.ResultObservedAtUtc ?? script.ExitObservedAtUtc ?? UtcNowProvider();
|
||||
var nowUtc = UtcNowProvider();
|
||||
_platformScriptResultEmitted[script.PolicyId] = runStamp > nowUtc ? nowUtc : runStamp;
|
||||
_pendingPlatformScripts.Remove(script.PolicyId);
|
||||
_stateDirty = true;
|
||||
}
|
||||
@@ -681,7 +696,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
/// <summary>Test seam: inject a pending platform script as if AgentExecutor.log had reported
|
||||
/// its start + exit code, without an IME PS-SCRIPT-RESULT line.</summary>
|
||||
internal void SeedPendingPlatformScriptForTesting(string policyId, int? exitCode, DateTime? exitObservedAtUtc, string stdout = null, DateTime? startedAtUtc = null)
|
||||
internal void SeedPendingPlatformScriptForTesting(string policyId, int? exitCode, DateTime? exitObservedAtUtc, string stdout = null, DateTime? startedAtUtc = null,
|
||||
CmTraceLineProvenance startedAtProvenance = null, CmTraceLineProvenance exitProvenance = null)
|
||||
{
|
||||
// Mirrors a fresh execution start: a new run clears any prior emitted-marker (see
|
||||
// HandleScriptStarted) so re-runs of the same policy within one lifetime emit again.
|
||||
@@ -694,6 +710,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
ExitObservedAtUtc = exitObservedAtUtc,
|
||||
Stdout = stdout,
|
||||
StartedAtUtc = startedAtUtc,
|
||||
StartedAtProvenance = startedAtProvenance,
|
||||
ExitProvenance = exitProvenance,
|
||||
};
|
||||
NextTestEntry();
|
||||
RecordInvocationMarker(policyId, isClose: false);
|
||||
@@ -703,18 +721,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// (the production handler minus regex extraction). <paramref name="resultLineTimestampUtc"/>
|
||||
/// stands in for the CMTrace timestamp of the PS-SCRIPT-RESULT line (production reads it
|
||||
/// via <see cref="LastMatchedLogTimestamp"/>, the fallback here).</summary>
|
||||
internal void CompletePlatformScriptFromImeResultForTesting(string policyId, string result, DateTime? resultLineTimestampUtc = null)
|
||||
internal void CompletePlatformScriptFromImeResultForTesting(string policyId, string result, DateTime? resultLineTimestampUtc = null, CmTraceLineProvenance resultProvenance = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(policyId)) return;
|
||||
CompletePlatformScriptFromImeResult(policyId, result, resultLineTimestampUtc ?? LastMatchedLogTimestamp, "PS-SCRIPT-RESULT");
|
||||
CompletePlatformScriptFromImeResult(policyId, result, resultLineTimestampUtc ?? LastMatchedLogTimestamp, "PS-SCRIPT-RESULT", resultProvenance ?? CaptureLastMatchedProvenance());
|
||||
}
|
||||
|
||||
/// <summary>Test seam: the AgentExecutor exit-code line of a pending platform script (the
|
||||
/// production handler minus regex extraction and positional ownership).</summary>
|
||||
internal void RecordPlatformScriptExitCodeForTesting(string policyId, int exitCode, DateTime observedAtUtc)
|
||||
internal void RecordPlatformScriptExitCodeForTesting(string policyId, int exitCode, DateTime observedAtUtc, CmTraceLineProvenance exitProvenance = null)
|
||||
{
|
||||
if (_pendingPlatformScripts.TryGetValue(policyId, out var script))
|
||||
ApplyExitCode(script, exitCode, observedAtUtc);
|
||||
ApplyExitCode(script, exitCode, observedAtUtc, exitProvenance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -790,8 +808,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// HS-NEW-RESULT path whose end stamp includes IME's batched reporting latency.
|
||||
// Peek only: HS-NEW-RESULT still consumes + removes the entry for cycle timing.
|
||||
StartedAtUtc = _healthScriptStartTimes.TryGetValue(id, out var earlyStart)
|
||||
? earlyStart
|
||||
? earlyStart.Utc
|
||||
: (DateTime?)null,
|
||||
StartedAtProvenance = _healthScriptStartTimes.TryGetValue(id, out var earlyStartProvenance)
|
||||
? earlyStartProvenance.Provenance
|
||||
: null,
|
||||
// The HS-COMPLIANCE line is the end of this phase; emitted synchronously, so the
|
||||
// last matched line is it.
|
||||
ResultProvenance = CaptureLastMatchedProvenance(),
|
||||
DurationBasis = "script_runtime",
|
||||
};
|
||||
|
||||
@@ -860,10 +884,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// HS-SCRIPT-START line had been observed, so a subsequent
|
||||
/// <see cref="HandleHealthScriptResultJson"/> surfaces a run duration without driving the
|
||||
/// full regex pipeline.</summary>
|
||||
internal void SeedHealthScriptStartForTesting(string policyId, DateTime startedAtUtc)
|
||||
internal void SeedHealthScriptStartForTesting(string policyId, DateTime startedAtUtc, CmTraceLineProvenance provenance = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(policyId)) return;
|
||||
_healthScriptStartTimes[policyId] = startedAtUtc;
|
||||
_healthScriptStartTimes[policyId] = (startedAtUtc, provenance);
|
||||
}
|
||||
|
||||
private void HandleHealthScriptResult(Match match, Dictionary<string, string> parameters)
|
||||
@@ -920,10 +944,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// emitted phase events — the remediation analog of platform-script timing. Consumed +
|
||||
// removed so a later re-run of the same policy re-times from its own start. Null when
|
||||
// the start line was never seen in the agent's window (e.g. a replayed result).
|
||||
DateTime? cycleStartedAtUtc = _healthScriptStartTimes.TryGetValue(policyId, out var hsStart)
|
||||
? hsStart
|
||||
: (DateTime?)null;
|
||||
var hasCycleStart = _healthScriptStartTimes.TryGetValue(policyId, out var hsStart);
|
||||
DateTime? cycleStartedAtUtc = hasCycleStart ? hsStart.Utc : (DateTime?)null;
|
||||
var cycleStartProvenance = hasCycleStart ? hsStart.Provenance : null;
|
||||
_healthScriptStartTimes.Remove(policyId);
|
||||
// Every phase ends on this HS-NEW-RESULT line.
|
||||
var resultProvenance = CaptureLastMatchedProvenance();
|
||||
|
||||
// Common metadata — applied to every emitted phase event.
|
||||
var remediationStatus = TryGetInt(root, "RemediationStatus");
|
||||
@@ -963,7 +989,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
targetType: targetType,
|
||||
errorCode: errorCode,
|
||||
errorDetails: errorDetails,
|
||||
startedAtUtc: cycleStartedAtUtc);
|
||||
startedAtUtc: cycleStartedAtUtc,
|
||||
startedAtProvenance: cycleStartProvenance,
|
||||
resultProvenance: resultProvenance);
|
||||
// complianceResult mirrors the legacy HS-COMPLIANCE event semantics: True when the
|
||||
// pre-detection script reported compliant (exit 0), False otherwise.
|
||||
detection.ComplianceResult = firstDetectExit == 0 ? "True" : (firstDetectExit.HasValue ? "False" : null);
|
||||
@@ -987,7 +1015,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
targetType: targetType,
|
||||
errorCode: errorCode,
|
||||
errorDetails: errorDetails,
|
||||
startedAtUtc: cycleStartedAtUtc);
|
||||
startedAtUtc: cycleStartedAtUtc,
|
||||
startedAtProvenance: cycleStartProvenance,
|
||||
resultProvenance: resultProvenance);
|
||||
// No complianceResult on the remediation phase — there is nothing to be compliant about.
|
||||
EmitScriptEvent(remediation);
|
||||
}
|
||||
@@ -1009,7 +1039,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
targetType: targetType,
|
||||
errorCode: errorCode,
|
||||
errorDetails: errorDetails,
|
||||
startedAtUtc: cycleStartedAtUtc);
|
||||
startedAtUtc: cycleStartedAtUtc,
|
||||
startedAtProvenance: cycleStartProvenance,
|
||||
resultProvenance: resultProvenance);
|
||||
postDetection.ComplianceResult = lastDetectExit == 0 ? "True" : (lastDetectExit.HasValue ? "False" : null);
|
||||
EmitScriptEvent(postDetection);
|
||||
}
|
||||
@@ -1032,7 +1064,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
int? targetType,
|
||||
int? errorCode,
|
||||
string errorDetails,
|
||||
DateTime? startedAtUtc) =>
|
||||
DateTime? startedAtUtc,
|
||||
CmTraceLineProvenance startedAtProvenance,
|
||||
CmTraceLineProvenance resultProvenance) =>
|
||||
new ScriptExecutionState
|
||||
{
|
||||
PolicyId = policyId,
|
||||
@@ -1049,6 +1083,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// Cycle start (HS-SCRIPT-START). Every phase carries the same cycle start so the
|
||||
// adapter computes the total run duration; the Web surfaces it once at card level.
|
||||
StartedAtUtc = startedAtUtc,
|
||||
StartedAtProvenance = startedAtProvenance,
|
||||
ResultProvenance = resultProvenance,
|
||||
// End stamp is the HS-NEW-RESULT line — written only after IME's batched report
|
||||
// to the service, so this duration overstates the actual script execution time.
|
||||
DurationBasis = "cycle_including_reporting_latency"
|
||||
|
||||
+15
@@ -505,6 +505,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
"imetokenfailure",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the last matched line's provenance, for a consumer that acts later than
|
||||
/// the match (a held platform-script result, a run duration across two lines) and must
|
||||
/// describe THAT line rather than whichever line is matched by then.
|
||||
/// </summary>
|
||||
internal CmTraceLineProvenance CaptureLastMatchedProvenance() => new CmTraceLineProvenance
|
||||
{
|
||||
SourceLocalTs = LastMatchedSourceLocalTimestamp,
|
||||
Origin = LastMatchedSourceOffsetOrigin,
|
||||
OffsetMinutes = LastMatchedSourceOffsetMinutes,
|
||||
EraAnchorKind = LastMatchedEraAnchorKind,
|
||||
MeasuredWriterOffsetMinutes = LastMatchedMeasuredWriterOffsetMinutes,
|
||||
SourceFileName = _currentSourceFileName,
|
||||
};
|
||||
|
||||
private void HandlePatternMatch(CompiledPattern pattern, Match match, string message, CmTraceLogEntry entry)
|
||||
{
|
||||
LastMatchedPatternId = pattern.PatternId;
|
||||
|
||||
@@ -205,8 +205,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// the remediation-script analog of the platform-script StartedAtUtc timing. Kept in a
|
||||
// dedicated map (not on _pendingHealthScript) because the early-signal HS-COMPLIANCE
|
||||
// handler clears that slot before HS-NEW-RESULT arrives. Consumed + removed on result emit.
|
||||
private readonly Dictionary<string, DateTime> _healthScriptStartTimes =
|
||||
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
// The start line's provenance rides along so the adapter can tell whether start and result
|
||||
// were resolved on the same zone assumption before it subtracts them.
|
||||
private readonly Dictionary<string, (DateTime Utc, CmTraceLineProvenance Provenance)> _healthScriptStartTimes =
|
||||
new Dictionary<string, (DateTime Utc, CmTraceLineProvenance Provenance)>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Restart-safe dedup for the adapter's one-shot script_timeout_suspected Warning.
|
||||
// Owned by the tracker (not the adapter) solely so it rides the persisted state file —
|
||||
|
||||
+20
@@ -29,6 +29,15 @@ public class ScriptExecutionState
|
||||
/// </summary>
|
||||
public System.DateTime? StartedAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provenance of <see cref="StartedAtUtc"/>: how the start line's offset was obtained. Paired
|
||||
/// with <see cref="ResultProvenance"/> / <see cref="ExitProvenance"/> by the adapter to decide
|
||||
/// whether a run duration is measurable — two lines resolved on different zone assumptions
|
||||
/// differ by whole offset-grid steps, not by run time (session 4377911b: a 10 s script read
|
||||
/// as 3610 s). Null when the start was never observed or on state files from before the field.
|
||||
/// </summary>
|
||||
public CmTraceLineProvenance StartedAtProvenance { get; set; }
|
||||
|
||||
/// <summary>"detection", "remediation", or "post-detection" (remediation scripts only).</summary>
|
||||
public string ScriptPart { get; set; }
|
||||
|
||||
@@ -66,6 +75,9 @@ public class ScriptExecutionState
|
||||
/// </summary>
|
||||
public System.DateTime? ExitObservedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Provenance of the exit-code line behind <see cref="ExitObservedAtUtc"/> (the fallback emit's end).</summary>
|
||||
public CmTraceLineProvenance ExitProvenance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Source (CMTrace) timestamp of the IME <c>PS-SCRIPT-RESULT</c> line that delivered
|
||||
/// <see cref="Result"/> (platform scripts, <c>ime_policy_result</c> only). The emitted event is
|
||||
@@ -76,6 +88,14 @@ public class ScriptExecutionState
|
||||
/// </summary>
|
||||
public System.DateTime? ResultObservedAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provenance of the line that delivered <see cref="Result"/> — bound here for the same reason
|
||||
/// as <see cref="ResultObservedAtUtc"/>: a held completion is emitted passes later, when the
|
||||
/// tracker's "last matched" provenance describes an unrelated line. For health scripts the
|
||||
/// result line of the phase (HS-COMPLIANCE early signal or HS-NEW-RESULT).
|
||||
/// </summary>
|
||||
public CmTraceLineProvenance ResultProvenance { get; set; }
|
||||
|
||||
/// <summary>Pattern id of the line that delivered <see cref="Result"/> (surfaced as <c>patternId</c> on the event).</summary>
|
||||
public string ResultPatternId { get; set; }
|
||||
|
||||
|
||||
+126
-6
@@ -24,10 +24,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// <item><c>EspTrackingWin32Apps\<userGuid>\<appGuid>_<rev></c> — which apps IME
|
||||
/// registered for ESP tracking and in which phase.</item>
|
||||
/// <item><c>SideCarPolicies\StatusServiceReports\<userGuid>\<appId></c> — the exact
|
||||
/// AppInstallStatusReport the ESP page renders (Status 1000=Installed .. 3000=Failed).</item>
|
||||
/// AppInstallStatusReport the ESP page renders (Status 1000=Installed, 1001=Installing,
|
||||
/// 1003=InstallingPendingReboot, 2000=NotApplicable, 3000=Failed).</item>
|
||||
/// <item><c>Win32Apps\OperationalState\<userGuid>\<appGuid></c> (bare GUIDs) — IME's
|
||||
/// per-app operational state: the install deadline a downloaded app is waiting for
|
||||
/// (<c>ExecutionDeadlineTime</c>, IME 1.106+) and the app-in-use deferral counters
|
||||
/// (<c>DeferralsUsed</c>, <c>DeferUntilTime</c>, ..., IME 1.105+).</item>
|
||||
/// </list>
|
||||
/// The observer is snapshot-and-diff driven (RegistryWatcher gives key-scope edges only,
|
||||
/// coalesced — a per-write parse is impossible by design): every tick re-reads the three
|
||||
/// coalesced — a per-write parse is impossible by design): every tick re-reads the four
|
||||
/// surfaces and emits <c>registry_app_state</c> on real field changes. Pre-existing state at
|
||||
/// agent start is the silent baseline — Win32Apps keys survive re-enrollments, so replaying
|
||||
/// them as fresh events would be the registry twin of the historic-IME-replay bug.
|
||||
@@ -162,6 +167,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
ReadWin32Apps(root, snapshot);
|
||||
ReadEspTracking(root, snapshot);
|
||||
ReadStatusServiceReports(root, snapshot);
|
||||
ReadOperationalState(root, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@@ -250,6 +256,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
// Only the two wait states are read; the download/execution bookkeeping next to them
|
||||
// (DownloadStatus, DownloadStartTime, ExecutionStatus, ...) duplicates the log narrative
|
||||
// and would double the event volume without adding a state the timeline lacks.
|
||||
private static void ReadOperationalState(RegistryKey root, ImeRegistrySnapshot snapshot)
|
||||
{
|
||||
using var operational = root.OpenSubKey(@"Win32Apps\OperationalState");
|
||||
if (operational == null) return;
|
||||
|
||||
foreach (var userName in operational.GetSubKeyNames())
|
||||
{
|
||||
if (!LooksLikeGuid(userName)) continue;
|
||||
using var userKey = operational.OpenSubKey(userName);
|
||||
if (userKey == null) continue;
|
||||
|
||||
foreach (var appKeyName in userKey.GetSubKeyNames())
|
||||
{
|
||||
var appId = ExtractAppId(appKeyName);
|
||||
if (appId == null) continue;
|
||||
|
||||
using var appKey = userKey.OpenSubKey(appKeyName);
|
||||
if (appKey == null) continue;
|
||||
|
||||
var deadline = TryParseRegistryDateTime(appKey.GetValue("ExecutionDeadlineTime"));
|
||||
var deferralsUsed = TryReadInt(appKey.GetValue("DeferralsUsed"));
|
||||
if (deadline == null && deferralsUsed == null) continue; // no wait state — no entry of its own
|
||||
|
||||
var entry = snapshot.GetOrAdd(userName, appId);
|
||||
entry.InstallDeadlineUtc = deadline;
|
||||
entry.DeferralsUsed = deferralsUsed;
|
||||
entry.DeferralMaxDeferrals = TryReadInt(appKey.GetValue("DeferralMaxDeferrals"));
|
||||
entry.DeferUntilUtc = TryParseRegistryDateTime(appKey.GetValue("DeferUntilTime"));
|
||||
entry.DeferralAutoDeferred = TryReadBool(appKey.GetValue("DeferralAutoDeferred"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── pure helpers (unit-tested directly) ─────────────────────────────────
|
||||
|
||||
internal static bool LooksLikeGuid(string name) =>
|
||||
@@ -301,6 +343,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool? TryReadBool(object? value) =>
|
||||
value is string s && bool.TryParse(s, out var parsed) ? parsed : (bool?)null;
|
||||
|
||||
/// <summary>
|
||||
/// OperationalState timestamps are UTC but stored in two InvariantCulture shapes:
|
||||
/// <c>ExecutionDeadlineTime</c> as <c>MM/dd/yyyy HH:mm:ss</c> without a kind marker,
|
||||
/// <c>DeferUntilTime</c> round-trip ("o", trailing Z). Both come back as UTC here.
|
||||
/// </summary>
|
||||
internal static DateTime? TryParseRegistryDateTime(object? value)
|
||||
{
|
||||
if (!(value is string s) || string.IsNullOrWhiteSpace(s)) return null;
|
||||
return DateTime.TryParse(s, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)
|
||||
? parsed
|
||||
: (DateTime?)null;
|
||||
}
|
||||
|
||||
/// <summary>StateMessageEnforcementState bands (verified against decompiled IME 1.97/1.104).</summary>
|
||||
internal static string ClassifyEnforcementState(int state)
|
||||
{
|
||||
@@ -312,15 +371,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/// <summary>AppInstallStatus (StatusService/ESP page): 1000er = installed, 3000 = failed.</summary>
|
||||
/// <summary>
|
||||
/// AppInstallStatus as IME writes it to StatusServiceReports (unchanged IME 1.50 .. 1.106):
|
||||
/// 1000 Installed, 1001 Installing, 1002 InstalledButDependenciesNotPresent,
|
||||
/// 1003 InstallingPendingReboot, 1004 InstalledPendingReboot, 2000 NotApplicable, 3000 Failed.
|
||||
/// The 1000 band is NOT uniformly terminal — IME maps every InProgress enforcement state to
|
||||
/// 1001 — so the mapping is explicit and an unknown value is not judged.
|
||||
/// </summary>
|
||||
internal static string? ClassifyStatusServiceStatus(int status)
|
||||
{
|
||||
if (status >= 1000 && status < 2000) return "installed";
|
||||
if (status == 3000) return "failed";
|
||||
switch (status)
|
||||
{
|
||||
case 1000:
|
||||
case 1002:
|
||||
case 1004:
|
||||
return "installed";
|
||||
case 1001:
|
||||
case 1003:
|
||||
return "installing";
|
||||
case 3000:
|
||||
return "failed";
|
||||
}
|
||||
if (status >= 2000 && status < 3000) return "notApplicable";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable suffix for the two IME wait states (install deadline, app-in-use
|
||||
/// deferral); empty when neither applies. Culture-invariant on purpose — the agent runs
|
||||
/// under whatever culture the SYSTEM account carries.
|
||||
/// </summary>
|
||||
internal static string DescribeWaitState(AppRegistryEntry entry)
|
||||
{
|
||||
var parts = new List<string>(2);
|
||||
if (entry.InstallDeadlineUtc is DateTime deadline)
|
||||
parts.Add("downloaded, install deadline " + FormatUtcMinute(deadline));
|
||||
|
||||
if (entry.DeferralsUsed is int used && used > 0)
|
||||
{
|
||||
var kind = entry.DeferralAutoDeferred == true ? "auto-deferred" : "deferred by user";
|
||||
var count = entry.DeferralMaxDeferrals is int max
|
||||
? used.ToString(CultureInfo.InvariantCulture) + "/" + max.ToString(CultureInfo.InvariantCulture)
|
||||
: used.ToString(CultureInfo.InvariantCulture);
|
||||
parts.Add(entry.DeferUntilUtc is DateTime until
|
||||
? kind + " " + count + " until " + FormatUtcMinute(until)
|
||||
: kind + " " + count);
|
||||
}
|
||||
|
||||
return parts.Count == 0 ? string.Empty : ", " + string.Join(", ", parts);
|
||||
}
|
||||
|
||||
private static string FormatUtcMinute(DateTime utc) =>
|
||||
utc.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) + " UTC";
|
||||
|
||||
internal static List<AppRegistryChange> DiffSnapshots(ImeRegistrySnapshot previous, ImeRegistrySnapshot next)
|
||||
{
|
||||
var changes = new List<AppRegistryChange>();
|
||||
@@ -343,6 +446,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (prev?.StatusServiceStatus != next.StatusServiceStatus) changed.Add("statusServiceStatus");
|
||||
if ((prev?.EspTracked ?? false) != next.EspTracked) changed.Add("espTracked");
|
||||
if (!string.Equals(prev?.EspPhase, next.EspPhase, StringComparison.OrdinalIgnoreCase)) changed.Add("espPhase");
|
||||
// Wait states: the deadline and each recorded deferral are transitions worth an event;
|
||||
// MaxDeferrals / AutoDeferred only travel along as data.
|
||||
if (prev?.InstallDeadlineUtc != next.InstallDeadlineUtc) changed.Add("installDeadline");
|
||||
if (prev?.DeferralsUsed != next.DeferralsUsed) changed.Add("deferralsUsed");
|
||||
if (prev?.DeferUntilUtc != next.DeferUntilUtc) changed.Add("deferUntil");
|
||||
return changed;
|
||||
}
|
||||
|
||||
@@ -445,6 +553,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
if (e.EspTracked) data["espTracked"] = "true";
|
||||
if (!string.IsNullOrEmpty(e.EspPhase)) data["espPhase"] = e.EspPhase!;
|
||||
if (e.InstallDeadlineUtc is DateTime deadline) data["installDeadlineUtc"] = deadline.ToString("o", CultureInfo.InvariantCulture);
|
||||
if (e.DeferralsUsed is int used) data["deferralsUsed"] = used.ToString(CultureInfo.InvariantCulture);
|
||||
if (e.DeferralMaxDeferrals is int max) data["deferralMaxDeferrals"] = max.ToString(CultureInfo.InvariantCulture);
|
||||
if (e.DeferUntilUtc is DateTime until) data["deferUntilUtc"] = until.ToString("o", CultureInfo.InvariantCulture);
|
||||
if (e.DeferralAutoDeferred is bool auto) data["deferralAutoDeferred"] = auto ? "true" : "false";
|
||||
|
||||
var summary = data.TryGetValue("enforcementClass", out var encls) ? encls
|
||||
: data.TryGetValue("statusServiceClass", out var sscls) ? sscls
|
||||
@@ -453,7 +566,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.RegistryAppState,
|
||||
source: SourceLabel,
|
||||
message: $"Registry app state: {e.AppId} ({(e.UserContext == ImeRegistrySnapshot.DeviceContext ? "device" : "user")}) -> {summary}",
|
||||
message: $"Registry app state: {e.AppId} ({(e.UserContext == ImeRegistrySnapshot.DeviceContext ? "device" : "user")}) -> {summary}{DescribeWaitState(e)}",
|
||||
severity: EventSeverity.Info,
|
||||
data: data,
|
||||
occurredAtUtc: nowUtc);
|
||||
@@ -625,6 +738,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
public int? StatusServiceStatus { get; set; }
|
||||
public bool EspTracked { get; set; }
|
||||
public string? EspPhase { get; set; }
|
||||
|
||||
// Win32Apps\OperationalState — the two IME wait states (all UTC).
|
||||
public DateTime? InstallDeadlineUtc { get; set; }
|
||||
public int? DeferralsUsed { get; set; }
|
||||
public int? DeferralMaxDeferrals { get; set; }
|
||||
public DateTime? DeferUntilUtc { get; set; }
|
||||
public bool? DeferralAutoDeferred { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class AppRegistryChange
|
||||
|
||||
@@ -461,31 +461,35 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
/// no second clock frame, so there is nothing to record.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void TagDerivedTimestamp(IDictionary<string, string> data, bool derivedFromClock, DateTime? rawSourceTs)
|
||||
private void TagDerivedTimestamp(IDictionary<string, string> data, bool derivedFromClock, DateTime? rawSourceTs, CmTraceLineProvenance? lineProvenance = null)
|
||||
{
|
||||
var culture = System.Globalization.CultureInfo.InvariantCulture;
|
||||
|
||||
var sourceLocal = _tracker.LastMatchedSourceLocalTimestamp;
|
||||
// The line the event is bound to. Callers that emit later than the match (a held
|
||||
// platform-script result) pass the snapshot taken at their line; everyone else is
|
||||
// emitting while its line is still the last matched one.
|
||||
var line = lineProvenance ?? _tracker.CaptureLastMatchedProvenance();
|
||||
var sourceLocal = line.SourceLocalTs;
|
||||
if (sourceLocal.HasValue)
|
||||
{
|
||||
// No zone suffix: the value genuinely carries none, and formatting one in would
|
||||
// re-introduce the very assumption this whole change removes.
|
||||
data["sourceLocalTs"] = sourceLocal.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffffff", culture);
|
||||
data["sourceOffsetOrigin"] = OriginTag(_tracker.LastMatchedSourceOffsetOrigin);
|
||||
data["sourceOffsetOrigin"] = OriginTag(line.Origin);
|
||||
|
||||
var offsetMinutes = _tracker.LastMatchedSourceOffsetMinutes;
|
||||
var offsetMinutes = line.OffsetMinutes;
|
||||
if (offsetMinutes.HasValue)
|
||||
data["sourceOffsetMinutes"] = offsetMinutes.Value.ToString(culture);
|
||||
|
||||
// Which era anchor the offset came from — forensics for the era-anchored origin.
|
||||
var anchorKind = _tracker.LastMatchedEraAnchorKind;
|
||||
var anchorKind = line.EraAnchorKind;
|
||||
if (!string.IsNullOrEmpty(anchorKind))
|
||||
data["sourceOffsetAnchor"] = anchorKind;
|
||||
|
||||
// Observational only — what the calibrator measured, which since the 2026-08-20
|
||||
// revert is deliberately NOT what was applied. Kept separate so the two can never
|
||||
// be read as the same thing.
|
||||
var measured = _tracker.LastMatchedMeasuredWriterOffsetMinutes;
|
||||
var measured = line.MeasuredWriterOffsetMinutes;
|
||||
if (measured.HasValue)
|
||||
data["measuredWriterOffsetMinutes"] = measured.Value.ToString(culture);
|
||||
}
|
||||
@@ -1293,7 +1297,11 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, eventType, script.PolicyId))
|
||||
return;
|
||||
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
// The line that delivered the result is bound on the state (D-247), so a completion
|
||||
// emitted passes later still describes ITS line. Null (states from before the field,
|
||||
// synthetic tests) falls back to the tracker's last matched line as before.
|
||||
var endProvenance = isFallback ? script.ExitProvenance : script.ResultProvenance;
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs, endProvenance);
|
||||
|
||||
// Per-script run duration (start line → this completion). Surfaced on every
|
||||
// script completion so the UI can flag slow / inefficient scripts, not just timed-out
|
||||
@@ -1301,11 +1309,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
// against clock skew between the (possibly clock-derived) completion stamp and start.
|
||||
//
|
||||
// When the completion timestamp was clock-derived because the source line's timestamp
|
||||
// was rejected (future-skew CMTrace clock jump — the stale case never reaches here),
|
||||
// `now` lives on the clock's timeline while StartedAtUtc was captured RAW from the
|
||||
// source log. Mixing the two produced absurd durations (session eaf3d8c4: 170 h), so
|
||||
// the duration uses the raw source pair — both ends on the same timeline, the
|
||||
// difference is the true runtime. The event stamp stays `now` (timeline position).
|
||||
// was rejected (future-skew — the stale case never reaches here), `now` lives on the
|
||||
// clock's timeline while StartedAtUtc was captured RAW from the source log. Mixing the
|
||||
// two produced absurd durations (session eaf3d8c4: 170 h), so the span is taken from
|
||||
// the raw source pair. That pair is a run time only when both lines were resolved on
|
||||
// the same zone belief — JudgeDurationPair decides, from the provenance bound to each
|
||||
// line, whether the span is verified, unverified, or an offset error to omit (session
|
||||
// 4377911b: a 10 s script read as 3610 s). The event stamp stays `now`.
|
||||
//
|
||||
// L19 (delta review 2026-07-02): the same field name carries two different semantics —
|
||||
// start→own-end-signal (≈ script runtime) vs HS-SCRIPT-START→HS-NEW-RESULT (the whole
|
||||
@@ -1317,15 +1327,26 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
var completionForDuration = derivedFromClock && rawSourceTs.HasValue
|
||||
? NormalizeUtc(rawSourceTs.Value)
|
||||
: now;
|
||||
TimeSpan? measurableDuration = null;
|
||||
if (script.StartedAtUtc.HasValue)
|
||||
{
|
||||
var durationSeconds = (completionForDuration - script.StartedAtUtc.Value).TotalSeconds;
|
||||
var span = completionForDuration - script.StartedAtUtc.Value;
|
||||
var verdict = JudgeDurationPair(script.StartedAtProvenance, endProvenance,
|
||||
endStampRejected: derivedFromClock && rawSourceTs.HasValue, span, out var suppressedReason);
|
||||
if (verdict != DurationPairVerdict.Unknown)
|
||||
data["durationProvenance"] = verdict == DurationPairVerdict.Verified ? "verified" : "unverified";
|
||||
|
||||
if (verdict == DurationPairVerdict.Suppressed)
|
||||
{
|
||||
data["durationSuppressedReason"] = suppressedReason!;
|
||||
}
|
||||
// Plausibility backstop: IME's script execution timeout is ~30 min; anything above
|
||||
// 24 h is a cross-run artifact (e.g. a stale pending start slot from a previous
|
||||
// enrollment paired with a fresh completion) — omit rather than lie.
|
||||
if (durationSeconds >= 0 && durationSeconds <= StaleSourceThreshold.TotalSeconds)
|
||||
else if (span >= TimeSpan.Zero && span <= StaleSourceThreshold)
|
||||
{
|
||||
data["durationSeconds"] = durationSeconds.ToString("F2", culture);
|
||||
measurableDuration = span;
|
||||
data["durationSeconds"] = span.TotalSeconds.ToString("F2", culture);
|
||||
data["durationBasis"] = script.DurationBasis
|
||||
?? (IsRemediation(script.ScriptType)
|
||||
? "cycle_including_reporting_latency"
|
||||
@@ -1333,6 +1354,18 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
}
|
||||
}
|
||||
|
||||
// The start line's provenance, next to the end line's (sourceOffset*), so the pair can
|
||||
// be judged downstream as well.
|
||||
var startProvenance = script.StartedAtProvenance;
|
||||
if (startProvenance != null && startProvenance.SourceLocalTs.HasValue)
|
||||
{
|
||||
data["startSourceOffsetOrigin"] = OriginTag(startProvenance.Origin);
|
||||
if (startProvenance.OffsetMinutes.HasValue)
|
||||
data["startSourceOffsetMinutes"] = startProvenance.OffsetMinutes.Value.ToString(culture);
|
||||
if (!string.IsNullOrEmpty(startProvenance.EraAnchorKind))
|
||||
data["startSourceOffsetAnchor"] = startProvenance.EraAnchorKind;
|
||||
}
|
||||
|
||||
var label = IsRemediation(script.ScriptType) ? "Remediation script" : "Platform script";
|
||||
var shortId = script.PolicyId.Length >= 8 ? script.PolicyId.Substring(0, 8) : script.PolicyId;
|
||||
string messageCore;
|
||||
@@ -1375,7 +1408,51 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
$"ImeAdapter: script completed policyId={shortId} type={script.ScriptType ?? "?"} result={script.Result ?? "?"} exit={(script.ExitCode.HasValue ? script.ExitCode.Value.ToString() : "n/a")}");
|
||||
|
||||
MaybeEmitBootstrapDetected(script, now);
|
||||
MaybeEmitScriptTimeoutSuspected(script, now, completionForDuration);
|
||||
MaybeEmitScriptTimeoutSuspected(script, now, measurableDuration);
|
||||
}
|
||||
|
||||
internal enum DurationPairVerdict { Unknown, Verified, Unverified, Suppressed }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the span between a start line and an end line measures elapsed time.
|
||||
/// <para>
|
||||
/// Each CMTrace line is resolved on its own zone belief: declared or measured (bias,
|
||||
/// line-anchored, or the clock itself) — or ASSUMED (the reader's zone, a writer era's
|
||||
/// anchor). Two measured ends sit on the true UTC axis; two assumed ends resolved with the
|
||||
/// same offset from the same file share their error, which cancels (D-191). Any other pair
|
||||
/// is unverified, and its span is omitted when the end stamp was rejected as ahead of the
|
||||
/// clock — its assumption is proven wrong and the raw pair inherits the error (session
|
||||
/// 4377911b: a 10 s script read as 3610 s) — or when the span sits on the 15-minute offset
|
||||
/// grid, the signature of a wrong assumption on either end (session cbaed57b: 7200 s). A
|
||||
/// missing snapshot (state files from before the field, synthetic tests) leaves the rule
|
||||
/// off and the pair treated as before.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static DurationPairVerdict JudgeDurationPair(
|
||||
CmTraceLineProvenance? start, CmTraceLineProvenance? end, bool endStampRejected, TimeSpan span, out string? suppressedReason)
|
||||
{
|
||||
suppressedReason = null;
|
||||
if (start == null || end == null) return DurationPairVerdict.Unknown;
|
||||
|
||||
var startAssumed = start.IsAssumed();
|
||||
var endAssumed = end.IsAssumed();
|
||||
var sameTimeline = (!startAssumed && !endAssumed)
|
||||
|| (startAssumed && endAssumed
|
||||
&& start.OffsetMinutes == end.OffsetMinutes
|
||||
&& string.Equals(start.SourceFileName, end.SourceFileName, StringComparison.OrdinalIgnoreCase));
|
||||
if (sameTimeline) return DurationPairVerdict.Verified;
|
||||
|
||||
if (endStampRejected)
|
||||
{
|
||||
suppressedReason = "rejected-endpoint";
|
||||
return DurationPairVerdict.Suppressed;
|
||||
}
|
||||
if (CmTraceOffsetCalibrator.IsOnOffsetGrid(span, out _))
|
||||
{
|
||||
suppressedReason = "offset-grid";
|
||||
return DurationPairVerdict.Suppressed;
|
||||
}
|
||||
return DurationPairVerdict.Unverified;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1387,19 +1464,19 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
/// late start / pre-failed ESP. Advisory only: no state mutation, the underlying
|
||||
/// <c>script_failed</c> is still emitted separately. Health (remediation) scripts are
|
||||
/// excluded — they re-run on their own cadence and have a different timeout regime.
|
||||
/// <paramref name="durationEndUtc"/> is the timeline-consistent completion for duration
|
||||
/// math (raw source ts on rejected-timestamp emissions); <paramref name="occurredAtUtc"/>
|
||||
/// stamps the emitted event.
|
||||
/// <paramref name="measurableDuration"/> is the run duration the completion event carries —
|
||||
/// null when it was omitted (no start line, implausible span, or a start/end pair resolved
|
||||
/// on different zone assumptions: the fleet's daily 17 h "timeouts" of 2–9 s scripts were
|
||||
/// offset errors); <paramref name="occurredAtUtc"/> stamps the emitted event.
|
||||
/// </summary>
|
||||
private void MaybeEmitScriptTimeoutSuspected(ScriptExecutionState script, DateTime occurredAtUtc, DateTime durationEndUtc)
|
||||
private void MaybeEmitScriptTimeoutSuspected(ScriptExecutionState script, DateTime occurredAtUtc, TimeSpan? measurableDuration)
|
||||
{
|
||||
if (IsRemediation(script.ScriptType)) return; // platform scripts only
|
||||
if (!script.StartedAtUtc.HasValue) return; // no start ⇒ no duration
|
||||
if (!measurableDuration.HasValue) return; // no measurable duration ⇒ no verdict
|
||||
if (!string.Equals(script.Result, "Failed", StringComparison.OrdinalIgnoreCase)) return;
|
||||
|
||||
var duration = durationEndUtc - script.StartedAtUtc.Value;
|
||||
var duration = measurableDuration.Value;
|
||||
if (duration < ScriptTimeoutSuspectedThreshold) return;
|
||||
if (duration > StaleSourceThreshold) return; // implausible — cross-run artifact, not a hung script
|
||||
|
||||
if (!_tracker.TryClaimScriptTimeoutSuspected(script.PolicyId)) return; // restart-safe dedup per policyId (persisted tracker state)
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// D-285: the admin configuration row is changed only through the changed-columns write — fresh
|
||||
/// read, mutation on a copy, only the columns whose value changed are laid onto the freshly read
|
||||
/// row, conditional on its ETag. Pins the 2026-09-25 incident: a Global Settings page loaded before
|
||||
/// an agent release saved one toggle and reverted the release's hashes with the full-model PUT.
|
||||
/// </summary>
|
||||
public class AdminConfigurationChangedFieldsTests
|
||||
{
|
||||
private const string ReleaseVersion = "2.0.1465";
|
||||
private const string ReleaseZipSha = "9f0b5403da7e49202274fdf8f38675bc5cfea85f7d881e85d5fe4bd71308250e";
|
||||
private const string ReleaseExeSha = "503dd02b3c3690acfe20c44dfce82c367ca1e56dcedaba3ecc91ff6ce893d726";
|
||||
|
||||
/// <summary>
|
||||
/// A one-row table that behaves like storage: reads hand out copies with the current ETag,
|
||||
/// a replace with a stale ETag fails 412, a create on an existing row fails 409.
|
||||
/// </summary>
|
||||
private sealed class FakeAdminTable
|
||||
{
|
||||
private int _version = 1;
|
||||
public TableEntity? Current;
|
||||
public int Writes;
|
||||
public TableUpdateMode? LastMode;
|
||||
/// <summary>Runs once, right before the first replace is checked — a concurrent writer.</summary>
|
||||
public Action<FakeAdminTable>? BeforeFirstReplace;
|
||||
public TableConfigRepository Sut { get; }
|
||||
|
||||
public FakeAdminTable(TableEntity? current)
|
||||
{
|
||||
Current = current;
|
||||
var table = new Mock<TableClient>();
|
||||
table.Setup(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(() => Current == null
|
||||
? throw new RequestFailedException(404, "Not Found", "ResourceNotFound", null)
|
||||
: Task.FromResult(Response.FromValue(Copy(Current, _version), Mock.Of<Response>())));
|
||||
table.Setup(c => c.UpdateEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, ifMatch, mode, _) =>
|
||||
{
|
||||
if (BeforeFirstReplace != null)
|
||||
{
|
||||
var concurrent = BeforeFirstReplace;
|
||||
BeforeFirstReplace = null;
|
||||
concurrent(this);
|
||||
}
|
||||
if (ifMatch != Etag(_version))
|
||||
throw new RequestFailedException(412, "Precondition Failed", "UpdateConditionNotSatisfied", null);
|
||||
Current = Copy(e, _version);
|
||||
_version++;
|
||||
Writes++;
|
||||
LastMode = mode;
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
table.Setup(c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, CancellationToken>((e, _) =>
|
||||
{
|
||||
if (Current != null)
|
||||
throw new RequestFailedException(409, "Conflict", "EntityAlreadyExists", null);
|
||||
Current = Copy(e, _version);
|
||||
Writes++;
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(table.Object);
|
||||
Sut = new TableConfigRepository(
|
||||
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
|
||||
new Mock<IConfigBackupRepository>().Object,
|
||||
NullLogger<TableConfigRepository>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>A write by someone else (the release pipeline's Merge), bumping the ETag.</summary>
|
||||
public void ExternalMerge(string column, object value)
|
||||
{
|
||||
Current![column] = value;
|
||||
_version++;
|
||||
}
|
||||
|
||||
private static ETag Etag(int version) => new($"W/\"v{version}\"");
|
||||
|
||||
private static TableEntity Copy(TableEntity source, int version)
|
||||
=> new(source.Where(kv => kv.Key != "odata.etag").ToDictionary(kv => kv.Key, kv => kv.Value)) { ETag = Etag(version) };
|
||||
}
|
||||
|
||||
/// <summary>The stored row right after the agent release: the pipeline's hashes plus a column this build does not know.</summary>
|
||||
private static TableEntity ReleasedRow()
|
||||
{
|
||||
var row = TableConfigRepository.ConvertToAdminTableEntity(new AdminConfiguration
|
||||
{
|
||||
UpdatedBy = "ga@operator.example",
|
||||
GlobalRateLimitRequestsPerMinute = 100,
|
||||
NvdApiKey = "stored-key",
|
||||
LatestAgentV2Version = ReleaseVersion,
|
||||
LatestAgentV2Sha256 = ReleaseZipSha,
|
||||
LatestAgentV2ExeSha256 = ReleaseExeSha,
|
||||
});
|
||||
row["ColumnOfANewerBuild"] = "keep-me";
|
||||
return row;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_page_toggle_writes_only_that_field_and_keeps_the_release_hashes()
|
||||
{
|
||||
var table = new FakeAdminTable(ReleasedRow());
|
||||
// The page was loaded before the release; it sends only what the operator changed.
|
||||
var fields = JObject.Parse("{ \"enforceClientAppBinding\": true }");
|
||||
Assert.Null(PatchAdminConfigurationFunction.CheckFields(fields));
|
||||
|
||||
var result = await table.Sut.UpdateAdminConfigurationAsync(
|
||||
draft => PatchAdminConfigurationFunction.ApplyFields(draft, fields), "ga@operator.example");
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal(new[] { "EnforceClientAppBinding" }, result.ChangedColumns);
|
||||
var row = table.Current!;
|
||||
Assert.True(row.GetBoolean("EnforceClientAppBinding"));
|
||||
Assert.Equal(ReleaseVersion, row.GetString("LatestAgentV2Version"));
|
||||
Assert.Equal(ReleaseZipSha, row.GetString("LatestAgentV2Sha256"));
|
||||
Assert.Equal(ReleaseExeSha, row.GetString("LatestAgentV2ExeSha256"));
|
||||
Assert.Equal("keep-me", row.GetString("ColumnOfANewerBuild"));
|
||||
Assert.Equal("stored-key", row.GetString("NvdApiKey"));
|
||||
Assert.Equal(TableUpdateMode.Replace, table.LastMode);
|
||||
Assert.Equal(1, table.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_concurrent_pipeline_write_is_reread_and_kept()
|
||||
{
|
||||
var table = new FakeAdminTable(ReleasedRow())
|
||||
{
|
||||
// The release pipeline lands between the backend's read and its write.
|
||||
BeforeFirstReplace = t => t.ExternalMerge("LatestAgentV2Version", "2.0.1466"),
|
||||
};
|
||||
|
||||
var result = await table.Sut.UpdateAdminConfigurationAsync(
|
||||
c => { c.GlobalRateLimitRequestsPerMinute = 250; return null; }, "System (test)");
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal("2.0.1466", table.Current!.GetString("LatestAgentV2Version"));
|
||||
Assert.Equal(250, table.Current.GetInt32("GlobalRateLimitRequestsPerMinute"));
|
||||
Assert.Equal(1, table.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_mutation_that_changes_nothing_writes_nothing()
|
||||
{
|
||||
var table = new FakeAdminTable(ReleasedRow());
|
||||
|
||||
var result = await table.Sut.UpdateAdminConfigurationAsync(
|
||||
c => { c.GlobalRateLimitRequestsPerMinute = 100; return null; }, "ga@operator.example");
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Empty(result.ChangedColumns);
|
||||
Assert.Equal(0, table.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_rejected_mutation_writes_nothing()
|
||||
{
|
||||
var table = new FakeAdminTable(ReleasedRow());
|
||||
|
||||
var result = await table.Sut.UpdateAdminConfigurationAsync(
|
||||
c => { c.GlobalRateLimitRequestsPerMinute = 999; return "nope"; }, "ga@operator.example");
|
||||
|
||||
Assert.Equal("nope", result.Error);
|
||||
Assert.Equal(0, table.Writes);
|
||||
Assert.Equal(100, table.Current!.GetInt32("GlobalRateLimitRequestsPerMinute"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_null_value_drops_its_column()
|
||||
{
|
||||
var table = new FakeAdminTable(ReleasedRow());
|
||||
|
||||
var result = await table.Sut.UpdateAdminConfigurationAsync(
|
||||
c => { c.NvdApiKey = null!; return null; }, "ga@operator.example"); // what a JSON null in a patch does
|
||||
|
||||
Assert.Equal(new[] { "NvdApiKey" }, result.ChangedColumns);
|
||||
Assert.False(table.Current!.ContainsKey("NvdApiKey"));
|
||||
Assert.Equal(ReleaseExeSha, table.Current.GetString("LatestAgentV2ExeSha256"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_row_the_first_change_creates_it()
|
||||
{
|
||||
var table = new FakeAdminTable(current: null);
|
||||
|
||||
var result = await table.Sut.UpdateAdminConfigurationAsync(
|
||||
c => { c.EnforceClientAppBinding = true; return null; }, "ga@operator.example");
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.True(table.Current!.GetBoolean("EnforceClientAppBinding"));
|
||||
Assert.Equal(1, table.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Default_creation_never_replaces_an_existing_row()
|
||||
{
|
||||
var table = new FakeAdminTable(ReleasedRow());
|
||||
|
||||
var created = await table.Sut.CreateAdminConfigurationIfMissingAsync(AdminConfiguration.CreateDefault());
|
||||
|
||||
Assert.False(created);
|
||||
Assert.Equal(ReleaseVersion, table.Current!.GetString("LatestAgentV2Version"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("latestAgentV2Version")]
|
||||
[InlineData("LatestAgentV2Sha256")]
|
||||
[InlineData("latestAgentV2ExeSha256")]
|
||||
[InlineData("latestBootstrapV2ScriptVersion")]
|
||||
[InlineData("updatedBy")]
|
||||
[InlineData("lastUpdated")]
|
||||
[InlineData("planTierDefinitionsJson")]
|
||||
[InlineData("msrcLastSyncUtc")]
|
||||
public void The_gate_rejects_pipeline_stamp_and_flow_owned_fields(string field)
|
||||
{
|
||||
var error = PatchAdminConfigurationFunction.CheckFields(new JObject { [field] = "x" });
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("not writable", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_gate_rejects_unknown_fields_and_redacted_placeholders()
|
||||
{
|
||||
Assert.Contains("Unknown field", PatchAdminConfigurationFunction.CheckFields(new JObject { ["noSuchField"] = 1 }));
|
||||
Assert.Contains("placeholder", PatchAdminConfigurationFunction.CheckFields(
|
||||
new JObject { ["nvdApiKey"] = Constants.RedactedSecretPlaceholder }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Patchable_fields_are_the_persisted_settings()
|
||||
{
|
||||
var patchable = PatchAdminConfigurationFunction.PatchableFields;
|
||||
Assert.Contains("EnforceClientAppBinding", patchable);
|
||||
Assert.Contains("McpClientRegistrationEnabled", patchable);
|
||||
Assert.Contains("McpAccessPolicy", patchable);
|
||||
Assert.Contains("NvdApiKey", patchable);
|
||||
Assert.Contains("OpsNotificationChannelsJson", patchable);
|
||||
Assert.DoesNotContain("LatestAgentV2ExeSha256", patchable);
|
||||
Assert.DoesNotContain("PartitionKey", patchable);
|
||||
Assert.DoesNotContain("UpdatedBy", patchable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Applying_validates_the_result_without_echoing_values()
|
||||
{
|
||||
var draft = AdminConfiguration.CreateDefault();
|
||||
Assert.Contains("at least 1", PatchAdminConfigurationFunction.ApplyFields(
|
||||
draft, JObject.Parse("{ \"globalRateLimitRequestsPerMinute\": 0 }")));
|
||||
|
||||
var typeError = PatchAdminConfigurationFunction.ApplyFields(
|
||||
AdminConfiguration.CreateDefault(), JObject.Parse("{ \"collectorIdleTimeoutMinutes\": \"https://secret.example/sas?sig=abc\" }"));
|
||||
Assert.NotNull(typeError);
|
||||
Assert.DoesNotContain("secret.example", typeError);
|
||||
}
|
||||
}
|
||||
+17
-10
@@ -14,7 +14,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// for the device path and UserRateLimitMiddleware for the user path). These tests pin the
|
||||
/// removal of the former background sync job:
|
||||
/// <list type="bullet">
|
||||
/// <item>Saving the admin config must NOT enumerate or write ANY tenant configuration.</item>
|
||||
/// <item>Changing the admin config must NOT enumerate or write ANY tenant configuration.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Historical context: the sync used to copy <c>GlobalRateLimitRequestsPerMinute</c> into every
|
||||
@@ -28,26 +28,33 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
public class AdminConfigurationRateLimitSyncTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveConfigurationAsync_DoesNotEnumerateOrWriteTenantConfigs()
|
||||
public async Task UpdateAsync_DoesNotEnumerateOrWriteTenantConfigs()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>(MockBehavior.Strict);
|
||||
repo.Setup(r => r.SaveAdminConfigurationAsync(It.IsAny<AdminConfiguration>()))
|
||||
.ReturnsAsync(true);
|
||||
repo.Setup(r => r.UpdateAdminConfigurationAsync(
|
||||
It.IsAny<Func<AdminConfiguration, string?>>(), It.IsAny<string>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new AdminConfigurationUpdateResult
|
||||
{
|
||||
Before = new AdminConfiguration(),
|
||||
After = new AdminConfiguration(),
|
||||
ChangedColumns = new[] { "GlobalRateLimitRequestsPerMinute", "UserRateLimitRequestsPerMinute" },
|
||||
});
|
||||
|
||||
var sut = new AdminConfigurationService(
|
||||
repo.Object,
|
||||
NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await sut.SaveConfigurationAsync(new AdminConfiguration
|
||||
await sut.UpdateAsync(c =>
|
||||
{
|
||||
UpdatedBy = "global-admin@contoso.com",
|
||||
GlobalRateLimitRequestsPerMinute = 200,
|
||||
UserRateLimitRequestsPerMinute = 240,
|
||||
});
|
||||
c.GlobalRateLimitRequestsPerMinute = 200;
|
||||
c.UserRateLimitRequestsPerMinute = 240;
|
||||
return null;
|
||||
}, "global-admin@contoso.com");
|
||||
|
||||
// The admin config row is written...
|
||||
repo.Verify(r => r.SaveAdminConfigurationAsync(It.IsAny<AdminConfiguration>()), Times.Once);
|
||||
repo.Verify(r => r.UpdateAdminConfigurationAsync(
|
||||
It.IsAny<Func<AdminConfiguration, string?>>(), "global-admin@contoso.com", It.IsAny<string?>()), Times.Once);
|
||||
// ...but NO tenant configuration is enumerated or mutated (sync removed).
|
||||
repo.Verify(r => r.GetAllTenantConfigurationsAsync(), Times.Never);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
|
||||
@@ -107,7 +107,8 @@ public class AuthFunctionSideEffectTests
|
||||
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()),
|
||||
new Mock<AdminIdentityResolver>(
|
||||
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
|
||||
_signalR);
|
||||
_signalR,
|
||||
adminConfigService.Object);
|
||||
|
||||
// Default: all fire-and-forget calls succeed
|
||||
_tenantConfigMock
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Client-app binding of delegated tokens: a user token obtained by an application outside the platform's
|
||||
/// own registrations is measured always and, while <c>EnforceClientAppBinding</c> is on, admitted only when
|
||||
/// that application is an enabled member of the caller's tenant — the person then acts under the same caps
|
||||
/// as an application principal (Viewer, no platform role, DelegatedReader). The switch off must leave every
|
||||
/// request exactly as it was: only the request-row items appear.
|
||||
/// </summary>
|
||||
public class ClientAppBindingTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private const string OwnClientId = "aaaaaaaa-0000-0000-0000-00000000000a";
|
||||
private const string LegacyClientId = "bbbbbbbb-0000-0000-0000-00000000000b";
|
||||
private const string ForeignClientId = "cccccccc-0000-0000-0000-00000000000c";
|
||||
private const string UserUpn = "alice@contoso.example";
|
||||
private const string UserOid = "dddddddd-0000-0000-0000-00000000000d";
|
||||
private static readonly string ForeignAppKey = Constants.PrincipalKeys.ForApplication(ForeignClientId);
|
||||
private static readonly string[] Trusted = { OwnClientId, LegacyClientId };
|
||||
|
||||
private static ClaimsPrincipal UserToken(string? clientId = ForeignClientId, string clientClaim = "azp",
|
||||
string upn = UserUpn, string tenantId = TenantA)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new("tid", tenantId), new("upn", upn), new("oid", UserOid), new("scp", "access_as_user"),
|
||||
};
|
||||
if (clientId != null) claims.Add(new Claim(clientClaim, clientId));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "AuthenticationTypes.Federation"));
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal AppToken(string clientId = ForeignClientId)
|
||||
=> new(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("tid", TenantA), new Claim("idtyp", "app"), new Claim("appid", clientId),
|
||||
new Claim("oid", "eeeeeeee-0000-0000-0000-00000000000e"),
|
||||
new Claim("roles", Constants.ApplicationPermissions.AccessAsApplication),
|
||||
}, "AuthenticationTypes.Federation"));
|
||||
|
||||
// ── Classification ──────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(OwnClientId, "azp")]
|
||||
[InlineData(LegacyClientId, "azp")]
|
||||
[InlineData(OwnClientId, "appid")]
|
||||
[InlineData("AAAAAAAA-0000-0000-0000-00000000000A", "azp")]
|
||||
public void OwnRegistrations_AreNotForeign(string clientId, string claim)
|
||||
=> Assert.False(ClientAppBinding.IsForeignDelegatedToken(UserToken(clientId, claim), Trusted, out _));
|
||||
|
||||
[Theory]
|
||||
[InlineData("azp")]
|
||||
[InlineData("appid")]
|
||||
public void AnotherClient_IsForeign_AndNamed(string claim)
|
||||
{
|
||||
Assert.True(ClientAppBinding.IsForeignDelegatedToken(UserToken(ForeignClientId, claim), Trusted, out var id));
|
||||
Assert.Equal(ForeignClientId, id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ATokenNamingNoClient_IsForeign_Unidentified()
|
||||
{
|
||||
Assert.True(ClientAppBinding.IsForeignDelegatedToken(UserToken(clientId: null), Trusted, out var id));
|
||||
Assert.Null(id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppOnlyTokens_AreNeverForeign_TheyHaveTheirOwnGate()
|
||||
=> Assert.False(ClientAppBinding.IsForeignDelegatedToken(AppToken(), Trusted, out _));
|
||||
|
||||
[Fact]
|
||||
public void TheTrustedSet_IsTheAudienceTrustSet_PrimaryLegacyAndAdditional()
|
||||
{
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = OwnClientId,
|
||||
["EntraId:LegacyClientId"] = LegacyClientId,
|
||||
["EntraId:AdditionalClientIds"] = ForeignClientId.ToUpperInvariant() + ", not-a-guid",
|
||||
}).Build();
|
||||
|
||||
var ids = AuthenticationMiddleware.ResolveTrustedClientIds(config, out _, out var rejected);
|
||||
|
||||
Assert.Equal(new[] { OwnClientId, LegacyClientId, ForeignClientId }, ids);
|
||||
Assert.Equal(new[] { "not-a-guid" }, rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClientOnlyTrust_WidensTheClientSet_ButNotTheAudiences()
|
||||
{
|
||||
const string DevClientId = "ffffffff-0000-0000-0000-00000000000f";
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = OwnClientId,
|
||||
["EntraId:TrustedClientAppIds"] = DevClientId,
|
||||
}).Build();
|
||||
|
||||
var clients = ClientAppBindingMiddleware.ResolveTrustedClientApps(config);
|
||||
var audiences = AuthenticationMiddleware.ResolveTrustedClientIds(config, out _, out _);
|
||||
|
||||
Assert.Contains(DevClientId, clients);
|
||||
Assert.Contains(OwnClientId, clients);
|
||||
Assert.DoesNotContain(DevClientId, audiences);
|
||||
Assert.False(ClientAppBinding.IsForeignDelegatedToken(UserToken(DevClientId), clients, out _));
|
||||
}
|
||||
|
||||
// ── The cap marker ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AMarkedPrincipal_KeepsItsIdentity_ButIsCapped()
|
||||
{
|
||||
var principal = UserToken();
|
||||
ClientAppBinding.MarkCapped(principal, ForeignClientId);
|
||||
|
||||
var identity = AdminIdentity.FromPrincipal(principal)!;
|
||||
|
||||
Assert.True(principal.IsClientCapped());
|
||||
Assert.True(principal.IsCappedPrincipal());
|
||||
Assert.Equal(UserUpn, principal.GetUserPrincipalName());
|
||||
Assert.Equal(UserUpn, identity.Upn);
|
||||
Assert.False(identity.IsApplication);
|
||||
Assert.True(identity.IsCapped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ATokenCarryingTheMarkerClaim_IsNotCapped()
|
||||
{
|
||||
// The marker is an identity with the middleware's own authentication type — a token-derived identity
|
||||
// can carry the same claim name but never that authentication type.
|
||||
var principal = UserToken();
|
||||
((ClaimsIdentity)principal.Identity!).AddClaim(new Claim(ClientAppBinding.ClientAppClaimType, ForeignClientId));
|
||||
|
||||
Assert.False(principal.IsClientCapped());
|
||||
Assert.False(AdminIdentity.FromPrincipal(principal)!.IsCapped);
|
||||
}
|
||||
|
||||
// ── Middleware: measure always, enforce behind the switch ─────────────────────
|
||||
|
||||
private sealed class Run
|
||||
{
|
||||
public required bool NextCalled { get; init; }
|
||||
public required IDictionary<object, object> Items { get; init; }
|
||||
public required HttpContext Http { get; init; }
|
||||
public required ClaimsPrincipal? Principal { get; init; }
|
||||
|
||||
public string Body()
|
||||
{
|
||||
Http.Response.Body.Position = 0;
|
||||
return new StreamReader(Http.Response.Body, Encoding.UTF8).ReadToEnd();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Run> InvokeAsync(ClaimsPrincipal? principal, bool enforced, TenantMember? appRow = null)
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((TenantMember?)null);
|
||||
if (appRow != null)
|
||||
repo.Setup(r => r.GetTenantMemberAsync(TenantA, ForeignAppKey)).ReturnsAsync(appRow);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { EnforceClientAppBinding = enforced });
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = OwnClientId,
|
||||
["EntraId:LegacyClientId"] = LegacyClientId,
|
||||
}).Build();
|
||||
var sut = new ClientAppBindingMiddleware(
|
||||
NullLogger<ClientAppBindingMiddleware>.Instance, config,
|
||||
new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance), adminConfig.Object);
|
||||
|
||||
var http = new DefaultHttpContext();
|
||||
http.Response.Body = new MemoryStream();
|
||||
var items = new Dictionary<object, object> { ["HttpRequestContext"] = http, ["CorrelationId"] = "cid-1" };
|
||||
if (principal != null) items["ClaimsPrincipal"] = principal;
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(items);
|
||||
|
||||
var nextCalled = false;
|
||||
await sut.Invoke(context.Object, _ => { nextCalled = true; return Task.CompletedTask; });
|
||||
return new Run { NextCalled = nextCalled, Items = items, Http = http, Principal = principal };
|
||||
}
|
||||
|
||||
private static TenantMember AppRow(bool enabled = true)
|
||||
=> new() { TenantId = TenantA, Upn = ForeignAppKey, Role = Constants.TenantRoles.Viewer, IsEnabled = enabled };
|
||||
|
||||
[Fact]
|
||||
public async Task NoValidatedPrincipal_PassesThrough_Unmeasured()
|
||||
{
|
||||
var run = await InvokeAsync(principal: null, enforced: true);
|
||||
|
||||
Assert.True(run.NextCalled);
|
||||
Assert.False(run.Items.ContainsKey(ClientAppBinding.OutcomeItemKey));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task OwnClientTokens_PassUntouched_InBothModes(bool enforced)
|
||||
{
|
||||
var run = await InvokeAsync(UserToken(OwnClientId), enforced);
|
||||
|
||||
Assert.True(run.NextCalled);
|
||||
Assert.False(run.Items.ContainsKey(ClientAppBinding.OutcomeItemKey));
|
||||
Assert.False(run.Principal!.IsClientCapped());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SwitchOff_MeasuresAnUnregisteredClient_AndLetsItThrough()
|
||||
{
|
||||
var run = await InvokeAsync(UserToken(), enforced: false);
|
||||
|
||||
Assert.True(run.NextCalled);
|
||||
Assert.Equal(ClientAppBinding.Outcomes.Unregistered, run.Items[ClientAppBinding.OutcomeItemKey]);
|
||||
Assert.Equal(ForeignClientId, run.Items[ClientAppBinding.ClientAppIdItemKey]);
|
||||
Assert.False(run.Principal!.IsClientCapped());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SwitchOff_MeasuresARegisteredClient_WithoutCapping()
|
||||
{
|
||||
var run = await InvokeAsync(UserToken(), enforced: false, appRow: AppRow());
|
||||
|
||||
Assert.True(run.NextCalled);
|
||||
Assert.Equal(ClientAppBinding.Outcomes.Registered, run.Items[ClientAppBinding.OutcomeItemKey]);
|
||||
Assert.False(run.Principal!.IsClientCapped());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SwitchOn_RefusesAnUnregisteredClient_With403NamingTheApp()
|
||||
{
|
||||
var run = await InvokeAsync(UserToken(), enforced: true);
|
||||
|
||||
Assert.False(run.NextCalled);
|
||||
Assert.Equal(403, run.Http.Response.StatusCode);
|
||||
var body = run.Body();
|
||||
Assert.Contains(Constants.ApiErrorCodes.ClientAppNotRegistered, body);
|
||||
Assert.Contains(ForeignClientId, body);
|
||||
Assert.Equal(ClientAppBinding.Outcomes.Unregistered, run.Items[ClientAppBinding.OutcomeItemKey]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SwitchOn_RefusesADisabledAppRow()
|
||||
{
|
||||
var run = await InvokeAsync(UserToken(), enforced: true, appRow: AppRow(enabled: false));
|
||||
|
||||
Assert.False(run.NextCalled);
|
||||
Assert.Equal(403, run.Http.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SwitchOn_RefusesATokenNamingNoClient()
|
||||
{
|
||||
var run = await InvokeAsync(UserToken(clientId: null), enforced: true);
|
||||
|
||||
Assert.False(run.NextCalled);
|
||||
Assert.Equal(403, run.Http.Response.StatusCode);
|
||||
Assert.Equal(ClientAppBinding.Outcomes.Unidentified, run.Items[ClientAppBinding.OutcomeItemKey]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SwitchOn_AdmitsARegisteredClient_AndCapsThePerson()
|
||||
{
|
||||
var run = await InvokeAsync(UserToken(), enforced: true, appRow: AppRow());
|
||||
|
||||
Assert.True(run.NextCalled);
|
||||
Assert.True(run.Principal!.IsClientCapped());
|
||||
Assert.Equal(UserUpn, run.Principal!.GetUserPrincipalName());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheAppRowIsLookedUp_InTheCallersOwnTenant()
|
||||
{
|
||||
// A registration in another tenant admits nothing: the row must live in the token's tid.
|
||||
var run = await InvokeAsync(UserToken(tenantId: TenantB), enforced: true, appRow: AppRow());
|
||||
|
||||
Assert.False(run.NextCalled);
|
||||
Assert.Equal(403, run.Http.Response.StatusCode);
|
||||
}
|
||||
|
||||
// ── Caps through the real policy middleware ─────────────────────────────────
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public required PolicyEnforcementMiddleware Middleware { get; init; }
|
||||
public required Mock<IAdminRepository> Repo { get; init; }
|
||||
|
||||
public void UserRow(string role) =>
|
||||
Repo.Setup(r => r.GetTenantMemberAsync(TenantA, UserUpn))
|
||||
.ReturnsAsync(new TenantMember { TenantId = TenantA, Upn = UserUpn, Role = role, IsEnabled = true, CanManageBootstrapTokens = true });
|
||||
}
|
||||
|
||||
private static Harness BuildHarness()
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
repo.Setup(r => r.GetGlobalRoleAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
|
||||
repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((TenantMember?)null);
|
||||
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>())).ReturnsAsync(new List<DelegatedAdminEntry>());
|
||||
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>())).ReturnsAsync(new List<TenantGroupAssignment>());
|
||||
repo.Setup(r => r.GetIdentityBindingAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(new AdminIdentityBinding { TenantId = TenantA, ObjectId = UserOid });
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync((TenantConfiguration?)null);
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var bindings = new AdminIdentityBindingService(repo.Object, cache, NullLogger<AdminIdentityBindingService>.Instance);
|
||||
var globalAdmin = new GlobalAdminService(repo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance);
|
||||
var delegatedAdmin = new DelegatedAdminService(
|
||||
repo.Object, bindings, new StubTenantEntitlementService(TenantEdition.Pro), cache, NullLogger<DelegatedAdminService>.Instance);
|
||||
var tenantAdmins = new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance);
|
||||
var tenantConfig = new TenantConfigurationService(configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
var mw = new PolicyEnforcementMiddleware(
|
||||
NullLogger<PolicyEnforcementMiddleware>.Instance, globalAdmin, delegatedAdmin,
|
||||
new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig, new RecordingDenialReporter());
|
||||
return new Harness { Middleware = mw, Repo = repo };
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CappedUser()
|
||||
{
|
||||
var principal = UserToken();
|
||||
ClientAppBinding.MarkCapped(principal, ForeignClientId);
|
||||
return principal;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Control_TheSamePersonUncapped_KeepsTheirAdminRole()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
h.UserRow(Constants.TenantRoles.Admin);
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, UserToken(OwnClientId));
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
Assert.Equal(Constants.TenantRoles.Admin, r.Context!.UserRole);
|
||||
Assert.False(r.Context.ClientCapped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ACappedAdmin_ReadsAsViewer()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
h.UserRow(Constants.TenantRoles.Admin);
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, CappedUser());
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
Assert.Equal(Constants.TenantRoles.Viewer, r.Context!.UserRole);
|
||||
Assert.False(r.Context.IsTenantAdmin);
|
||||
Assert.True(r.Context.ClientCapped);
|
||||
Assert.True(AdminIdentity.FromRequestContext(r.Context)!.IsCapped);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("POST", "/api/sessions/abc/actions")]
|
||||
[InlineData("PUT", "/api/sessions/abc/annotations/operator")]
|
||||
[InlineData("POST", "/api/config/11111111-1111-1111-1111-111111111111")]
|
||||
public async Task ACappedAdmin_CannotWrite(string method, string path)
|
||||
{
|
||||
var h = BuildHarness();
|
||||
h.UserRow(Constants.TenantRoles.Admin);
|
||||
|
||||
var r = await h.Middleware.DecideAsync(method, path, null, CappedUser());
|
||||
|
||||
Assert.False(r.Allowed);
|
||||
Assert.Equal(403, r.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolelessTiers_AreClosed_ButTheMcpFrontDoorIsOpen()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
h.UserRow(Constants.TenantRoles.Viewer);
|
||||
|
||||
var me = await h.Middleware.DecideAsync("GET", "/api/auth/me", null, CappedUser());
|
||||
var mcp = await h.Middleware.DecideAsync("GET", "/api/auth/mcp", null, CappedUser());
|
||||
|
||||
Assert.False(me.Allowed);
|
||||
Assert.Equal("ApplicationPrincipalNotAllowed", me.LogReason);
|
||||
Assert.True(mcp.Allowed);
|
||||
Assert.Equal(UserUpn, mcp.Context!.UserPrincipalName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APlatformRole_IsInert_WhileCapped()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
h.Repo.Setup(r => r.GetGlobalRoleAsync(UserUpn)).ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
|
||||
|
||||
var uncapped = await h.Middleware.DecideAsync("GET", "/api/global/raw/access-probe", null, UserToken(OwnClientId));
|
||||
var capped = await h.Middleware.DecideAsync("GET", "/api/global/raw/access-probe", null, CappedUser());
|
||||
|
||||
Assert.True(uncapped.Allowed);
|
||||
Assert.False(capped.Allowed);
|
||||
Assert.Equal(403, capped.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ADelegatedAdminGrant_IsReaderOnly_WhileCapped()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
h.Repo.Setup(r => r.GetDelegatedTenantsAsync(UserUpn)).ReturnsAsync(new List<DelegatedAdminEntry>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = TenantB, Role = Constants.DelegatedRoles.DelegatedAdmin, IsEnabled = true,
|
||||
Status = Constants.DelegatedStatus.Active, Source = Constants.DelegatedSource.OperatorGranted,
|
||||
},
|
||||
});
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, CappedUser());
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
Assert.True(r.Context!.IsDelegatedReader);
|
||||
Assert.False(r.Context.IsDelegatedAdmin);
|
||||
}
|
||||
|
||||
// ── Resolver overload and the MCP front door ────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, Constants.TenantRoles.Admin, true)]
|
||||
[InlineData(true, Constants.TenantRoles.Viewer, false)]
|
||||
public async Task Resolver_CapsOnlyWhenAsked(bool capped, string expectedRole, bool expectedBootstrap)
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
repo.Setup(r => r.GetTenantMemberAsync(TenantA, UserUpn))
|
||||
.ReturnsAsync(new TenantMember { TenantId = TenantA, Upn = UserUpn, Role = Constants.TenantRoles.Admin, IsEnabled = true, CanManageBootstrapTokens = true });
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var sut = new TenantMemberRoleResolver(
|
||||
new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance),
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
|
||||
var role = await sut.ResolveAsync(TenantA, UserUpn, null, capped);
|
||||
|
||||
Assert.Equal(expectedRole, role?.Role);
|
||||
Assert.Equal(expectedBootstrap, role!.CanManageBootstrapTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolver_NeverTurnsANonMemberIntoAViewer()
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((TenantMember?)null);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var sut = new TenantMemberRoleResolver(
|
||||
new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance),
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
|
||||
Assert.Null(await sut.ResolveAsync(TenantA, UserUpn, null, clientCapped: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpAccessCheck_ResolvesTheCappedIdentity()
|
||||
{
|
||||
var adminRepo = new Mock<IAdminRepository>();
|
||||
adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>())).ReturnsAsync((McpUserEntry?)null);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var bindings = new StubAdminIdentityBindingService(bound: true);
|
||||
var seen = new List<AdminIdentity?>();
|
||||
var globalAdmin = new Mock<GlobalAdminService>(adminRepo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
|
||||
globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>()))
|
||||
.Callback<AdminIdentity?>(seen.Add).ReturnsAsync((string?)null);
|
||||
var delegatedAdmin = new Mock<DelegatedAdminService>(
|
||||
adminRepo.Object, bindings, new StubTenantEntitlementService(TenantEdition.Pro), cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>()))
|
||||
.Callback<AdminIdentity?>(seen.Add).ReturnsAsync(DelegatedScope.Empty);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.AllMembers.ToString() });
|
||||
var members = StubTenantMemberRoleResolver.Everyone();
|
||||
var sut = new McpUserService(
|
||||
adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, members,
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
|
||||
var result = await sut.IsAllowedAsync(UserUpn, TenantA, UserOid, null, clientCapped: true);
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal("AllMembers", result.AccessGrant);
|
||||
Assert.NotEmpty(seen);
|
||||
Assert.All(seen, identity => Assert.True(identity!.IsCapped));
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,13 @@ public class ConfigBackupHookTests
|
||||
Upserts.Add(e);
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
Table.Setup(c => c.UpdateEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
|
||||
{
|
||||
Upserts.Add(e);
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
|
||||
@@ -225,15 +232,11 @@ public class ConfigBackupHookTests
|
||||
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var incoming = new AdminConfiguration
|
||||
{
|
||||
UpdatedBy = "ga@operator.example",
|
||||
GlobalRateLimitRequestsPerMinute = 200,
|
||||
};
|
||||
var result = await harness.Sut.UpdateAdminConfigurationAsync(
|
||||
c => { c.GlobalRateLimitRequestsPerMinute = 200; return null; }, "ga@operator.example");
|
||||
|
||||
var saved = await harness.Sut.SaveAdminConfigurationAsync(incoming);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal(new[] { "GlobalRateLimitRequestsPerMinute" }, result.ChangedColumns);
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal("GlobalConfig", snapshot!.PartitionKey);
|
||||
Assert.Equal("admin-config", snapshot.Source);
|
||||
|
||||
@@ -526,7 +526,41 @@ public class DelegationSelfServiceTests
|
||||
Assert.Equal(Now.AddDays(-3), owned.SinceUtc);
|
||||
var direct = managers.Single(m => m.GroupId == null);
|
||||
Assert.Equal(DelegationSelfService.SourceOperator, direct.Source);
|
||||
Assert.Equal(DelegationSelfService.OperatorLabel, direct.Name);
|
||||
Assert.False(direct.Revocable);
|
||||
Assert.Single(direct.Assignees); // the revoked row confers nothing
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListManagers_OperatorGroup_ShowsNeutralLabelNotItsName_ButItsAssignees()
|
||||
{
|
||||
const string operatorGroupId = "0f1e2d3c4b5a69788796a5b4c3d2e1f0";
|
||||
var h = Build();
|
||||
h.Repo.Setup(x => x.GetGroupIdsContainingTenantAsync(Customer)).ReturnsAsync(new List<string> { operatorGroupId });
|
||||
h.Repo.Setup(x => x.GetTenantGroupAsync(operatorGroupId)).ReturnsAsync(new TenantGroup
|
||||
{
|
||||
GroupId = operatorGroupId,
|
||||
Name = "Internal MSP customers north",
|
||||
OwnerTenantId = null,
|
||||
TenantIds = new List<string> { Customer, Other },
|
||||
Assignees = new List<TenantGroupAssignment>
|
||||
{
|
||||
new() { Upn = "ops@vendor.example", GroupId = operatorGroupId, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true },
|
||||
new() { Upn = "ops2@vendor.example", GroupId = operatorGroupId, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true },
|
||||
},
|
||||
});
|
||||
|
||||
var managers = await h.Svc.ListManagersAsync(Customer);
|
||||
|
||||
var group = Assert.Single(managers);
|
||||
Assert.Equal(operatorGroupId, group.GroupId);
|
||||
Assert.Equal(DelegationSelfService.OperatorLabel, group.Name);
|
||||
Assert.DoesNotContain("north", group.Name, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Null(group.OwnerTenantId);
|
||||
Assert.Null(group.OwnerDomain);
|
||||
Assert.Null(group.SinceUtc);
|
||||
Assert.Equal(DelegationSelfService.SourceOperator, group.Source);
|
||||
Assert.False(group.Revocable);
|
||||
Assert.Equal(2, group.Assignees.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ public class DelegationWireParityTests
|
||||
managers = new object[]
|
||||
{
|
||||
new { groupId = (string?)("msp-" + Home), ownerTenantId = (string?)Home, ownerDomain = (string?)"partner.example", name = "Customers of partner.example", source = "self-service", assignees, sinceUtc = (DateTime?)since, revocable = true },
|
||||
new { groupId = nullGroup, ownerTenantId = nullOwner, ownerDomain = nullDomain, name = "Platform operators", source = "operator", assignees, sinceUtc = (DateTime?)null, revocable = false },
|
||||
new { groupId = nullGroup, ownerTenantId = nullOwner, ownerDomain = nullDomain, name = "Platform support", source = "operator", assignees, sinceUtc = (DateTime?)null, revocable = false },
|
||||
},
|
||||
},
|
||||
new TenantManagerListResponse
|
||||
@@ -204,7 +204,7 @@ public class DelegationWireParityTests
|
||||
{
|
||||
new() { GroupId = "msp-" + Home, OwnerTenantId = Home, OwnerDomain = "partner.example", Name = "Customers of partner.example", Source = "self-service",
|
||||
Assignees = new List<TenantManagerAssignee> { new() { Upn = "analyst@partner.example", Role = "DelegatedReader", IsEnabled = true } }, SinceUtc = since, Revocable = true },
|
||||
new() { Name = "Platform operators", Source = "operator",
|
||||
new() { Name = "Platform support", Source = "operator",
|
||||
Assignees = new List<TenantManagerAssignee> { new() { Upn = "analyst@partner.example", Role = "DelegatedReader", IsEnabled = true } }, Revocable = false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,24 +32,28 @@ public sealed class EmailServiceTests
|
||||
// ----- configuration gates -----
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
|
||||
public async Task SendAsync_EmptyApiKey_NoOps_ReturnsFalse_AndLogsMissingKeyWarning()
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "");
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId, CancellationToken.None);
|
||||
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId, CancellationToken.None);
|
||||
|
||||
Assert.False(sent);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
// Warning, not Debug: same reasoning as the welcome mail — worker logs below Warning never
|
||||
// reach Application Insights, and the handler records FarewellEmailFailed on the false.
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("Email:ApiKey not configured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine()
|
||||
public async Task SendAsync_EmptyRecipient_NoOps_ReturnsFalse_AndLogsMissingEmailDebugLine()
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "any-non-empty-key");
|
||||
|
||||
await sut.SendAsync("", "contoso.invalid", TenantId);
|
||||
var sent = await sut.SendAsync("", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured"));
|
||||
@@ -65,7 +69,7 @@ public sealed class EmailServiceTests
|
||||
await sut.SendAsync("", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("Email:ApiKey not configured"));
|
||||
Assert.DoesNotContain(logger.Entries,
|
||||
e => e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
@@ -123,8 +127,9 @@ public sealed class EmailServiceTests
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "md-test-key");
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.True(sent);
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
var message = doc.RootElement.GetProperty("message");
|
||||
Assert.Equal(EmailTemplates.OffboardingFarewellSubject, message.GetProperty("subject").GetString());
|
||||
@@ -161,8 +166,9 @@ public sealed class EmailServiceTests
|
||||
responder: _ => Json(HttpStatusCode.InternalServerError,
|
||||
"{\"status\":\"error\",\"code\":-1,\"name\":\"Invalid_Key\",\"message\":\"Invalid API key\"}"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("500") && e.Message.Contains("Invalid_Key"));
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
@@ -175,8 +181,9 @@ public sealed class EmailServiceTests
|
||||
responder: _ => Json(HttpStatusCode.OK,
|
||||
"[{\"email\":\"ops@contoso.invalid\",\"status\":\"rejected\",\"reject_reason\":\"hard-bounce\",\"_id\":\"abc\"}]"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("rejected") && e.Message.Contains("hard-bounce"));
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
@@ -192,8 +199,9 @@ public sealed class EmailServiceTests
|
||||
responder: _ => Json(HttpStatusCode.OK,
|
||||
$"[{{\"email\":\"ops@contoso.invalid\",\"status\":\"{status}\",\"_id\":\"abc\"}}]"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.True(sent);
|
||||
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Warning);
|
||||
}
|
||||
@@ -204,8 +212,9 @@ public sealed class EmailServiceTests
|
||||
var (sut, _, logger) = Build(apiKey: "md-test-key",
|
||||
responder: _ => Json(HttpStatusCode.OK, "not json"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning);
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
}
|
||||
|
||||
+16
@@ -237,6 +237,22 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
string.Join("\n", missing.Select(m => $" - {m}")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AllowedDuringOffboarding punches a hole in the tenant suspension gate (offboarding tombstone only).
|
||||
/// It exists for exactly one route — the farewell feedback a departing admin submits after the offboard
|
||||
/// endpoint has already tombstoned their tenant. Any second route here needs the same justification.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AllowedDuringOffboarding_IsOnlyTheFarewellFeedbackRoute()
|
||||
{
|
||||
var routes = EndpointAccessPolicyCatalog.Entries
|
||||
.Where(e => e.AllowedDuringOffboarding)
|
||||
.Select(e => $"{e.HttpMethod} {e.RouteTemplate}")
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(new[] { "POST tenants/{tenantId}/offboard/feedback" }, routes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every entry with TenantScoping.RouteParam must have {tenantId} in its route template.
|
||||
/// Prevents misattributed scoping declarations.
|
||||
|
||||
@@ -44,6 +44,9 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
// Non-sensitive by review: bool only ("is a contact address set"), never the
|
||||
// address itself (admin-gated full config only).
|
||||
"contactEmailSet",
|
||||
// Non-sensitive by review: bool only ("is any device validation method enabled"),
|
||||
// never which one or any Graph detail.
|
||||
"deviceValidationEnabled",
|
||||
"diagnosticsUploadConfigured",
|
||||
"edition",
|
||||
// Non-sensitive by review: one of "community" | "plan" | "trial" | "msp" — says WHY the
|
||||
@@ -59,7 +62,6 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
// wire options omit null keys (WhenWritingNull) — exactly what production always
|
||||
// served. Payload_ActiveTrial_IsTrialWithExpiry pins the key on an active trial.
|
||||
"unrestrictedMode",
|
||||
"validateAutopilotDevice",
|
||||
}, fieldNames);
|
||||
}
|
||||
|
||||
@@ -81,13 +83,30 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
var element = Serialize(config);
|
||||
|
||||
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
|
||||
Assert.True(element.GetProperty("validateAutopilotDevice").GetBoolean());
|
||||
Assert.True(element.GetProperty("deviceValidationEnabled").GetBoolean());
|
||||
Assert.False(element.GetProperty("showScriptOutput").GetBoolean());
|
||||
Assert.True(element.GetProperty("enableSoftwareInventoryAnalyzer").GetBoolean());
|
||||
Assert.False(element.GetProperty("enableIntegrityBypassAnalyzer").GetBoolean());
|
||||
Assert.True(element.GetProperty("unrestrictedMode").GetBoolean());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false, false)]
|
||||
[InlineData(true, false, true)]
|
||||
[InlineData(false, true, true)]
|
||||
public void Payload_DeviceValidationEnabled_IsAnyMethod_NotJustAutopilot(bool autopilot, bool intuneEnrollment, bool expected)
|
||||
{
|
||||
// A device-preparation tenant that validates only via Intune enrollment (or device
|
||||
// association) must not see the "agent ingestion is blocked" banner.
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
ValidateAutopilotDevice = autopilot,
|
||||
ValidateIntuneDeviceBinding = intuneEnrollment,
|
||||
});
|
||||
|
||||
Assert.Equal(expected, element.GetProperty("deviceValidationEnabled").GetBoolean());
|
||||
}
|
||||
|
||||
// ── Effective feature gates (plan-derived) ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -49,6 +49,7 @@ public class InfrastructureWireParityTests
|
||||
homedApp = "primary",
|
||||
bootstrapTokenEnabled = true,
|
||||
unrestrictedModeEnabled = false,
|
||||
mcpClientRegistrationEnabled = true,
|
||||
whatsNewSeenPlatformUtc = platformSeen,
|
||||
whatsNewSeenAgentUtc = agentSeen
|
||||
},
|
||||
@@ -69,6 +70,7 @@ public class InfrastructureWireParityTests
|
||||
HomedApp = "primary",
|
||||
BootstrapTokenEnabled = true,
|
||||
UnrestrictedModeEnabled = false,
|
||||
McpClientRegistrationEnabled = true,
|
||||
WhatsNewSeenPlatformUtc = platformSeen,
|
||||
WhatsNewSeenAgentUtc = agentSeen
|
||||
});
|
||||
@@ -105,6 +107,7 @@ public class InfrastructureWireParityTests
|
||||
homedApp = "legacy",
|
||||
bootstrapTokenEnabled = false,
|
||||
unrestrictedModeEnabled = false,
|
||||
mcpClientRegistrationEnabled = false,
|
||||
whatsNewSeenPlatformUtc,
|
||||
whatsNewSeenAgentUtc
|
||||
},
|
||||
@@ -125,6 +128,7 @@ public class InfrastructureWireParityTests
|
||||
HomedApp = "legacy",
|
||||
BootstrapTokenEnabled = false,
|
||||
UnrestrictedModeEnabled = false,
|
||||
McpClientRegistrationEnabled = false,
|
||||
WhatsNewSeenPlatformUtc = null,
|
||||
WhatsNewSeenAgentUtc = null
|
||||
});
|
||||
|
||||
@@ -162,6 +162,7 @@ public class IntuneDeviceBindingValidatorTests
|
||||
[InlineData(IntuneDeviceBindingOutcome.NotFound)]
|
||||
[InlineData(IntuneDeviceBindingOutcome.NoDeviceIdInCert)]
|
||||
[InlineData(IntuneDeviceBindingOutcome.PermissionMissing)]
|
||||
[InlineData(IntuneDeviceBindingOutcome.NotManaged)]
|
||||
public void DefinitiveNegatives_AreNotTransient(IntuneDeviceBindingOutcome outcome)
|
||||
{
|
||||
// Only Transient may be retried / left uncached. A missing grant or a foreign device is a
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Intune Enrollment Validation: the certificate's Intune device id admits a device without any
|
||||
/// pre-registration (<see cref="IntuneDeviceBindingValidator.ValidateAsync"/>), and observes
|
||||
/// devices another validator admitted (<see cref="IntuneDeviceBindingValidator.ObserveInBackground"/>)
|
||||
/// — the latter only when the tenant granted the Graph permission, so a tenant without the add-on
|
||||
/// costs no Graph call at all.
|
||||
/// </summary>
|
||||
public class IntuneEnrollmentValidationTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string DeviceId = "07bc0167-9061-43b7-b243-0bb6aeda736e";
|
||||
|
||||
private static readonly DateTimeOffset OldCert = DateTimeOffset.UtcNow.AddDays(-30);
|
||||
private static readonly DateTimeOffset RecentCert = DateTimeOffset.UtcNow.AddMinutes(-5);
|
||||
|
||||
private const string MatchBody =
|
||||
"{\"id\":\"07bc0167-9061-43b7-b243-0bb6aeda736e\",\"deviceName\":\"TEST-DEVICE-01\",\"managementState\":\"managed\","
|
||||
+ "\"serialNumber\":\"TESTSERIAL01\",\"managedDeviceOwnerType\":\"company\",\"deviceEnrollmentType\":\"windowsAzureADJoin\"}";
|
||||
|
||||
// ------------------------------------------------------------------ harness
|
||||
|
||||
private sealed class ScriptedHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<Func<HttpResponseMessage>> _responses;
|
||||
public List<HttpRequestMessage> Requests { get; } = new();
|
||||
|
||||
public ScriptedHandler(params Func<HttpResponseMessage>[] responses) => _responses = new(responses);
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
lock (Requests) Requests.Add(request);
|
||||
var next = _responses.Count > 1 ? _responses.Dequeue() : _responses.Peek();
|
||||
return Task.FromResult(next());
|
||||
}
|
||||
}
|
||||
|
||||
private static Func<HttpResponseMessage> Respond(HttpStatusCode status, string body = "{}")
|
||||
=> () => new HttpResponseMessage(status) { Content = new StringContent(body) };
|
||||
|
||||
private sealed record Sut(
|
||||
IntuneDeviceBindingValidator Validator,
|
||||
ScriptedHandler Handler,
|
||||
MemoryCache Cache,
|
||||
Mock<GraphTokenService> Tokens,
|
||||
Mock<IGraphFeatureDetector> Detector);
|
||||
|
||||
private static Sut Build(bool permissionGranted, params Func<HttpResponseMessage>[] responses)
|
||||
{
|
||||
var handler = new ScriptedHandler(responses.Length == 0 ? new[] { Respond(HttpStatusCode.OK, MatchBody) } : responses);
|
||||
var factory = new Mock<IHttpClientFactory>();
|
||||
factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(() => new HttpClient(handler, disposeHandler: false));
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
["EntraId:ClientSecret"] = "secret",
|
||||
}).Build();
|
||||
var registry = new EntraAppRegistry(configuration, NullLogger<EntraAppRegistry>.Instance);
|
||||
var tenantConfig = new Mock<TenantConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tokens = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache,
|
||||
configuration, registry, tenantConfig.Object)
|
||||
{ CallBase = false };
|
||||
tokens.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(GraphTokenResult.Success("token"));
|
||||
|
||||
var detector = new Mock<IGraphFeatureDetector>();
|
||||
detector.Setup(d => d.HasPermissionAsync(TenantId, GraphAppPermissions.DeviceManagementManagedDevicesReadAll, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(permissionGranted);
|
||||
|
||||
var validator = new IntuneDeviceBindingValidator(
|
||||
NullLogger<IntuneDeviceBindingValidator>.Instance, factory.Object, cache, tokens.Object, detector.Object);
|
||||
return new Sut(validator, handler, cache, tokens, detector);
|
||||
}
|
||||
|
||||
private static async Task WaitUntil(Func<bool> condition)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline)
|
||||
throw new TimeoutException("Background observation did not settle in time.");
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool InFlight(MemoryCache cache)
|
||||
=> cache.TryGetValue(IntuneDeviceBindingValidator.BuildCacheKey(TenantId, DeviceId) + ":inflight", out _);
|
||||
|
||||
// ------------------------------------------------------------------ admitting lookups
|
||||
|
||||
[Fact]
|
||||
public async Task Admitting_Match_IsCached_SoTheNextRequestCostsNoGraphCall()
|
||||
{
|
||||
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.OK, MatchBody));
|
||||
|
||||
var first = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
|
||||
var second = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
|
||||
|
||||
Assert.Equal(IntuneDeviceBindingOutcome.Match, first.Outcome);
|
||||
Assert.False(first.ServedFromCache);
|
||||
Assert.True(second.ServedFromCache);
|
||||
Assert.Single(sut.Handler.Requests);
|
||||
Assert.Equal("TESTSERIAL01", second.SerialNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admitting_UsesAPointGetWithTheTelemetrySelect()
|
||||
{
|
||||
var sut = Build(permissionGranted: true);
|
||||
|
||||
await sut.Validator.ValidateAsync(TenantId, DeviceId.ToUpperInvariant(), OldCert, IntuneDeviceBindingRole.Admitting);
|
||||
|
||||
var uri = Uri.UnescapeDataString(Assert.Single(sut.Handler.Requests).RequestUri!.ToString());
|
||||
Assert.Contains($"/v1.0/deviceManagement/managedDevices/{DeviceId}?", uri);
|
||||
Assert.DoesNotContain("$filter", uri);
|
||||
foreach (var field in new[] { "managementState", "serialNumber", "managedDeviceOwnerType", "deviceEnrollmentType", "azureADRegistered" })
|
||||
Assert.Contains(field, uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admitting_NotFound_WithAnOldCertificate_IsDefinitiveAndCached()
|
||||
{
|
||||
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound));
|
||||
|
||||
var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
|
||||
|
||||
Assert.Equal(IntuneDeviceBindingOutcome.NotFound, result.Outcome);
|
||||
Assert.Single(sut.Handler.Requests);
|
||||
Assert.Equal(IntuneDeviceBindingOutcome.NotFound, sut.Validator.TryGetCached(TenantId, DeviceId)?.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admitting_NotFound_WithARecentCertificate_IsTransientAndNeverCached()
|
||||
{
|
||||
// The enrollment race: the device may not be visible in Graph yet. Caching the 404 would
|
||||
// keep a genuine device out for the whole negative TTL.
|
||||
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound));
|
||||
|
||||
var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, RecentCert, IntuneDeviceBindingRole.Admitting);
|
||||
|
||||
Assert.Equal(IntuneDeviceBindingOutcome.NotFoundRecentEnrollment, result.Outcome);
|
||||
Assert.True(result.IsTransient);
|
||||
Assert.Equal(2, sut.Handler.Requests.Count);
|
||||
Assert.Null(sut.Validator.TryGetCached(TenantId, DeviceId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admitting_RecentCertificate_ThenTheObjectAppears_IsAdmittedOnTheRetry()
|
||||
{
|
||||
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound), Respond(HttpStatusCode.OK, MatchBody));
|
||||
|
||||
var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, RecentCert, IntuneDeviceBindingRole.Admitting);
|
||||
|
||||
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
|
||||
Assert.Equal(2, sut.Handler.Requests.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admitting_ForbiddenWithAFreshTokenToo_IsPermissionMissing()
|
||||
{
|
||||
var sut = Build(permissionGranted: false, Respond(HttpStatusCode.Forbidden), Respond(HttpStatusCode.Forbidden));
|
||||
|
||||
var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
|
||||
|
||||
Assert.Equal(IntuneDeviceBindingOutcome.PermissionMissing, result.Outcome);
|
||||
sut.Tokens.Verify(t => t.InvalidateTenant(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admitting_RunsEvenWhenThePermissionWasNotDetected()
|
||||
{
|
||||
// An admin who just ran the grant script must not wait for the detector's token cache:
|
||||
// the admitting lookup goes to Graph and the 401/403 refresh picks up the new role.
|
||||
var sut = Build(permissionGranted: false, Respond(HttpStatusCode.Forbidden), Respond(HttpStatusCode.OK, MatchBody));
|
||||
|
||||
var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
|
||||
|
||||
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
|
||||
sut.Detector.Verify(d => d.HasPermissionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admitting_InvalidDeviceId_NeverReachesGraph()
|
||||
{
|
||||
var sut = Build(permissionGranted: true);
|
||||
|
||||
var result = await sut.Validator.ValidateAsync(TenantId, "not-a-guid", OldCert, IntuneDeviceBindingRole.Admitting);
|
||||
|
||||
Assert.Equal(IntuneDeviceBindingOutcome.NoDeviceIdInCert, result.Outcome);
|
||||
Assert.Empty(sut.Handler.Requests);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ observation
|
||||
|
||||
[Fact]
|
||||
public async Task Observe_WithoutThePermission_MakesNoGraphCall_AndAsksTheDetectorOncePerWindow()
|
||||
{
|
||||
var sut = Build(permissionGranted: false);
|
||||
|
||||
sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, null);
|
||||
await WaitUntil(() => !InFlight(sut.Cache));
|
||||
|
||||
// Later requests of the same tenant stop at the cached "not granted" verdict.
|
||||
sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, null);
|
||||
sut.Validator.ObserveInBackground(TenantId, "22222222-3333-4444-5555-666666666666", OldCert, null);
|
||||
await WaitUntil(() => !InFlight(sut.Cache));
|
||||
|
||||
Assert.Empty(sut.Handler.Requests);
|
||||
sut.Tokens.Verify(t => t.GetAccessTokenAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
sut.Detector.Verify(d => d.HasPermissionAsync(TenantId, GraphAppPermissions.DeviceManagementManagedDevicesReadAll, It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.Null(sut.Validator.TryGetCached(TenantId, DeviceId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Observe_WithThePermission_FillsTheCacheInTheBackground()
|
||||
{
|
||||
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.OK, MatchBody));
|
||||
|
||||
sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, "session-1");
|
||||
await WaitUntil(() => sut.Validator.TryGetCached(TenantId, DeviceId) != null);
|
||||
|
||||
var observed = sut.Validator.TryGetCached(TenantId, DeviceId)!;
|
||||
Assert.Equal(IntuneDeviceBindingOutcome.Match, observed.Outcome);
|
||||
Assert.True(observed.ServedFromCache);
|
||||
Assert.Single(sut.Handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Observe_WhenAlreadyCached_DoesNothing()
|
||||
{
|
||||
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.OK, MatchBody));
|
||||
await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
|
||||
|
||||
sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, null);
|
||||
|
||||
Assert.False(InFlight(sut.Cache));
|
||||
Assert.Single(sut.Handler.Requests);
|
||||
sut.Detector.Verify(d => d.HasPermissionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Observe_TransientOutcome_KeepsACooldownInsteadOfALookupPerRequest()
|
||||
{
|
||||
// A recent certificate whose device is not visible yet caches nothing; without the cooldown
|
||||
// every request of that device would start a new Graph lookup.
|
||||
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound));
|
||||
|
||||
sut.Validator.ObserveInBackground(TenantId, DeviceId, RecentCert, null);
|
||||
await WaitUntil(() => sut.Handler.Requests.Count == 2);
|
||||
await Task.Delay(100);
|
||||
|
||||
sut.Validator.ObserveInBackground(TenantId, DeviceId, RecentCert, null);
|
||||
await Task.Delay(100);
|
||||
|
||||
Assert.True(InFlight(sut.Cache));
|
||||
Assert.Equal(2, sut.Handler.Requests.Count);
|
||||
Assert.Null(sut.Validator.TryGetCached(TenantId, DeviceId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Observe_InvalidDeviceId_DoesNothing()
|
||||
{
|
||||
var sut = Build(permissionGranted: true);
|
||||
|
||||
sut.Validator.ObserveInBackground(TenantId, null, OldCert, null);
|
||||
|
||||
sut.Detector.Verify(d => d.HasPermissionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ SecurityValidator chain
|
||||
|
||||
private static (SecurityValidator Validator, Mock<IntuneDeviceBindingValidator> Binding) BuildChain(TenantConfiguration config)
|
||||
{
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
config.ManufacturerWhitelist = "*";
|
||||
config.ModelWhitelist = "*";
|
||||
|
||||
var configService = new Mock<TenantConfigurationService>(
|
||||
configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
configService.Setup(x => x.TryGetConfigurationAsync(It.IsAny<string>())).ReturnsAsync((config, true));
|
||||
|
||||
var adminConfigService = new Mock<AdminConfigurationService>(
|
||||
configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
adminConfigService.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration());
|
||||
|
||||
var binding = new Mock<IntuneDeviceBindingValidator>(
|
||||
NullLogger<IntuneDeviceBindingValidator>.Instance, Mock.Of<IHttpClientFactory>(), cache,
|
||||
null!, Mock.Of<IGraphFeatureDetector>())
|
||||
{ CallBase = false };
|
||||
|
||||
var validator = new SecurityValidator(
|
||||
configService.Object, adminConfigService.Object,
|
||||
new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
|
||||
autopilotDeviceValidator: null!, corporateIdentifierValidator: null!,
|
||||
NullLogger.Instance,
|
||||
intuneDeviceBindingValidator: binding.Object);
|
||||
return (validator, binding);
|
||||
}
|
||||
|
||||
private static string CertTenant => CertTenantBindingTests.SampleCertTenantId.ToString();
|
||||
|
||||
private static void SetupAdmitting(Mock<IntuneDeviceBindingValidator> binding, IntuneDeviceBindingResult result)
|
||||
=> binding.Setup(b => b.ValidateAsync(CertTenant, DeviceId, It.IsAny<DateTimeOffset?>(), IntuneDeviceBindingRole.Admitting,
|
||||
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
|
||||
[Fact]
|
||||
public async Task Chain_OnlyIntuneEnrollmentEnabled_PassesTheGate_AndAdmitsAnEnrolledDevice()
|
||||
{
|
||||
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
|
||||
SetupAdmitting(binding, new IntuneDeviceBindingResult
|
||||
{
|
||||
Outcome = IntuneDeviceBindingOutcome.Match, IntuneDeviceId = DeviceId,
|
||||
SerialNumber = "TESTSERIAL01", OwnerType = "personal", EnrollmentType = "windowsAzureADJoin",
|
||||
});
|
||||
var items = new Dictionary<object, object>();
|
||||
|
||||
var result = await validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items), CertTenant);
|
||||
|
||||
Assert.True(result.IsValid, result.ErrorMessage + " " + result.Details);
|
||||
Assert.Equal(ValidatorType.IntuneEnrollment, result.ValidatedBy);
|
||||
Assert.Equal("IntuneEnrollment", items[RequestRowMarkers.DeviceValidationKey]);
|
||||
Assert.Equal("Match", items[RequestRowMarkers.CertDeviceBindingKey]);
|
||||
Assert.Equal("Admitting", items[RequestRowMarkers.CertDeviceBindingRoleKey]);
|
||||
Assert.Equal("true", items[RequestRowMarkers.CertDeviceSerialMatchKey]);
|
||||
Assert.Equal("personal", items[RequestRowMarkers.CertDeviceOwnerTypeKey]);
|
||||
Assert.Equal("windowsAzureADJoin", items[RequestRowMarkers.CertDeviceEnrollmentTypeKey]);
|
||||
// The admitting lookup already stamped the row; no second, observing lookup.
|
||||
binding.Verify(b => b.ObserveInBackground(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<DateTimeOffset?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chain_PassesTheCertificateNotBeforeToTheLookup()
|
||||
{
|
||||
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
|
||||
DateTimeOffset? seen = null;
|
||||
binding.Setup(b => b.ValidateAsync(CertTenant, DeviceId, It.IsAny<DateTimeOffset?>(), IntuneDeviceBindingRole.Admitting,
|
||||
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string?, DateTimeOffset?, IntuneDeviceBindingRole, string?, CancellationToken>((_, _, nb, _, _, _) => seen = nb)
|
||||
.ReturnsAsync(new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.Match });
|
||||
|
||||
await validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64()), CertTenant);
|
||||
|
||||
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero), seen);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(IntuneDeviceBindingOutcome.NotFound)]
|
||||
[InlineData(IntuneDeviceBindingOutcome.NotManaged)]
|
||||
[InlineData(IntuneDeviceBindingOutcome.NoDeviceIdInCert)]
|
||||
public async Task Chain_DefinitiveMiss_IsA403(IntuneDeviceBindingOutcome outcome)
|
||||
{
|
||||
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
|
||||
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = outcome, ErrorMessage = "miss" });
|
||||
var items = new Dictionary<object, object>();
|
||||
|
||||
var result = await validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items), CertTenant);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
|
||||
Assert.Equal(outcome.ToString(), items[RequestRowMarkers.CertDeviceBindingKey]);
|
||||
Assert.Equal(RequestRowMarkers.DeviceValidation.Rejected, items[RequestRowMarkers.DeviceValidationKey]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(IntuneDeviceBindingOutcome.NotFoundRecentEnrollment, 30)]
|
||||
[InlineData(IntuneDeviceBindingOutcome.Transient, 30)]
|
||||
[InlineData(IntuneDeviceBindingOutcome.PermissionMissing, 120)]
|
||||
public async Task Chain_NotYetDecidable_IsA503WithRetryAfter(IntuneDeviceBindingOutcome outcome, int retryAfter)
|
||||
{
|
||||
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
|
||||
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = outcome, ErrorMessage = "later" });
|
||||
|
||||
var result = await validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64()), CertTenant);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, result.StatusCode);
|
||||
Assert.Equal(retryAfter, result.RetryAfterSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chain_ToggleOff_NoAdmittingLookup_OnlyABackgroundObservation()
|
||||
{
|
||||
var (validator, binding) = BuildChain(new TenantConfiguration { AllowInsecureAgentRequests = true });
|
||||
var items = new Dictionary<object, object>();
|
||||
|
||||
var result = await validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items), CertTenant);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
binding.Verify(b => b.ValidateAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<DateTimeOffset?>(),
|
||||
It.IsAny<IntuneDeviceBindingRole>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
binding.Verify(b => b.ObserveInBackground(CertTenant, DeviceId, It.IsAny<DateTimeOffset?>(), It.IsAny<string?>()), Times.Once);
|
||||
Assert.False(items.ContainsKey(RequestRowMarkers.CertDeviceBindingKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chain_CachedObservation_IsStampedAsObserving_AndNeverRejects()
|
||||
{
|
||||
var (validator, binding) = BuildChain(new TenantConfiguration { AllowInsecureAgentRequests = true });
|
||||
binding.Setup(b => b.TryGetCached(CertTenant, DeviceId)).Returns(new IntuneDeviceBindingResult
|
||||
{
|
||||
Outcome = IntuneDeviceBindingOutcome.NotFound, SerialNumber = "OTHER-SERIAL",
|
||||
});
|
||||
var items = new Dictionary<object, object>();
|
||||
|
||||
var result = await validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items), CertTenant);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("NotFound", items[RequestRowMarkers.CertDeviceBindingKey]);
|
||||
Assert.Equal("Observing", items[RequestRowMarkers.CertDeviceBindingRoleKey]);
|
||||
Assert.Equal("false", items[RequestRowMarkers.CertDeviceSerialMatchKey]);
|
||||
binding.Verify(b => b.ObserveInBackground(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<DateTimeOffset?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chain_NothingEnabled_IsStillRejectedAtTheGate()
|
||||
{
|
||||
var (validator, _) = BuildChain(new TenantConfiguration());
|
||||
|
||||
var result = await validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64()), CertTenant);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("Device validation is required", result.ErrorMessage);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ gate + wire
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(TenantConfiguration.ValidateAutopilotDevice))]
|
||||
[InlineData(nameof(TenantConfiguration.ValidateCorporateIdentifier))]
|
||||
[InlineData(nameof(TenantConfiguration.ValidateDeviceAssociation))]
|
||||
[InlineData(nameof(TenantConfiguration.ValidateCloudPcDevice))]
|
||||
[InlineData(nameof(TenantConfiguration.ValidateIntuneDeviceBinding))]
|
||||
public void HasAnyDeviceValidation_EachMethodCountsAlone(string property)
|
||||
{
|
||||
var config = new TenantConfiguration();
|
||||
Assert.False(config.HasAnyDeviceValidation());
|
||||
|
||||
typeof(TenantConfiguration).GetProperty(property)!.SetValue(config, true);
|
||||
|
||||
Assert.True(config.HasAnyDeviceValidation());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAnyDeviceValidation_CoversEveryValidateFlag()
|
||||
{
|
||||
// A new Validate* flag that the gate does not know would leave its tenants locked out.
|
||||
var flags = typeof(TenantConfiguration).GetProperties()
|
||||
.Where(p => p.PropertyType == typeof(bool) && p.Name.StartsWith("Validate", StringComparison.Ordinal))
|
||||
.Select(p => p.Name)
|
||||
.ToList();
|
||||
|
||||
foreach (var flag in flags)
|
||||
{
|
||||
var config = new TenantConfiguration();
|
||||
typeof(TenantConfiguration).GetProperty(flag)!.SetValue(config, true);
|
||||
Assert.True(config.HasAnyDeviceValidation(), $"{flag} is not part of HasAnyDeviceValidation");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterWire_EveryValidatorTheBackendSends_ParsesInTheAgent()
|
||||
{
|
||||
// The backend writes enum names (ApiJsonOptions); the agent reads the same Shared model
|
||||
// with a plain JsonConvert.DeserializeObject. An agent built from this Shared therefore
|
||||
// parses every member — which is why a new member ships with an agent release first.
|
||||
foreach (var value in Enum.GetValues<ValidatorType>())
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new RegisterSessionResponse { ValidatedBy = value }, ApiJsonOptions.Create());
|
||||
Assert.Contains($"\"validatedBy\":\"{value}\"", json);
|
||||
|
||||
var parsed = Newtonsoft.Json.JsonConvert.DeserializeObject<RegisterSessionResponse>(json);
|
||||
Assert.Equal(value, parsed!.ValidatedBy);
|
||||
}
|
||||
}
|
||||
|
||||
private enum ReleasedAgentValidatorType { Unknown = 0, AutopilotV1 = 1, CorporateIdentifier = 2, DeviceAssociation = 3, Bootstrap = 4, CloudPc = 5 }
|
||||
|
||||
private sealed class ReleasedAgentResponse
|
||||
{
|
||||
public ReleasedAgentValidatorType ValidatedBy { get; set; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterWire_AnAgentWithoutTheMember_FailsTheRegistration()
|
||||
{
|
||||
// Why the release order matters: an agent that predates IntuneEnrollment throws on it,
|
||||
// and a failed parse counts as a failed registration. Agents self-update at every start,
|
||||
// so releasing the agent before the backend closes the gap.
|
||||
Assert.ThrowsAny<Newtonsoft.Json.JsonException>(() =>
|
||||
Newtonsoft.Json.JsonConvert.DeserializeObject<ReleasedAgentResponse>("{\"validatedBy\":\"IntuneEnrollment\"}"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("TESTSERIAL01", "testserial01", true)]
|
||||
[InlineData(" TESTSERIAL01 ", "TESTSERIAL01", true)]
|
||||
[InlineData("TESTSERIAL01", "OTHER", false)]
|
||||
[InlineData(null, "TESTSERIAL01", null)]
|
||||
[InlineData("TESTSERIAL01", "", null)]
|
||||
public void SerialMatch_ComparesTrimmedAndCaseInsensitive(string? header, string? intune, bool? expected)
|
||||
=> Assert.Equal(expected, RequestRowMarkers.SerialMatch(header, intune));
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Self-hosted MCP client registrations: storage round trip, wire parity, callback validation (the MCP proxy
|
||||
/// redirects authorization codes to the stored URL), the per-tenant cap, the operator switch (create refused,
|
||||
/// lookup 404, delete still possible) and the audit trail.
|
||||
/// </summary>
|
||||
public class McpClientRegistrationTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private const string Callback = "https://chat.contoso.example/api/mcp/autopilot-monitor/oauth/callback";
|
||||
private static readonly DateTime Now = new(2026, 9, 24, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ── Storage and wire ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_FullRow_SurvivesBuildAndMap()
|
||||
{
|
||||
var row = new McpClientRegistration
|
||||
{
|
||||
RegistrationId = "0123456789abcdef0123456789abcdef",
|
||||
TenantId = TenantA.ToUpperInvariant(),
|
||||
Name = "Team chat",
|
||||
RedirectUri = Callback,
|
||||
CreatedBy = "admin@contoso.example",
|
||||
CreatedAt = Now,
|
||||
};
|
||||
|
||||
var entity = TableMcpClientRegistrationRepository.Build(row);
|
||||
var back = TableMcpClientRegistrationRepository.Map(entity);
|
||||
|
||||
Assert.Equal(TableMcpClientRegistrationRepository.PartitionKey, entity.PartitionKey);
|
||||
Assert.Equal(row.RegistrationId, entity.RowKey);
|
||||
Assert.Equal(row.RegistrationId, back.RegistrationId);
|
||||
Assert.Equal(TenantA, back.TenantId); // lowercase: the offboarding wipe matches it
|
||||
Assert.Equal(row.Name, back.Name);
|
||||
Assert.Equal(row.RedirectUri, back.RedirectUri);
|
||||
Assert.Equal(row.CreatedBy, back.CreatedBy);
|
||||
Assert.Equal(Now, back.CreatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenantFilter_IsPartitionPlusLowercaseTenant()
|
||||
=> Assert.Equal($"PartitionKey eq 'registrations' and TenantId eq '{TenantA}'",
|
||||
TableMcpClientRegistrationRepository.BuildTenantFilter(TenantA.ToUpperInvariant()));
|
||||
|
||||
[Fact]
|
||||
public void ListResponse_WireShape()
|
||||
{
|
||||
var item = McpClientRegistrationFunction.ToItem(new McpClientRegistration
|
||||
{
|
||||
RegistrationId = "abc", TenantId = TenantA, Name = "Team chat", RedirectUri = Callback,
|
||||
CreatedBy = "admin@contoso.example", CreatedAt = Now,
|
||||
});
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
enabled = true,
|
||||
maxRegistrations = 3,
|
||||
serverUrl = "https://mcp.autopilotmonitor.com/mcp",
|
||||
registrations = new[]
|
||||
{
|
||||
new { registrationId = "abc", clientId = "amc_abc", name = "Team chat", redirectUri = Callback, createdBy = "admin@contoso.example", createdUtc = Now },
|
||||
},
|
||||
},
|
||||
new McpClientRegistrationListResponse
|
||||
{
|
||||
Enabled = true, MaxRegistrations = 3, ServerUrl = "https://mcp.autopilotmonitor.com/mcp",
|
||||
Registrations = new[] { item },
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAndLookupResponses_WireShape()
|
||||
{
|
||||
var item = new McpClientRegistrationItem
|
||||
{
|
||||
RegistrationId = "abc", ClientId = "amc_abc", Name = "Team chat", RedirectUri = Callback,
|
||||
CreatedBy = "admin@contoso.example", CreatedUtc = Now,
|
||||
};
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { registration = new { registrationId = "abc", clientId = "amc_abc", name = "Team chat", redirectUri = Callback, createdBy = "admin@contoso.example", createdUtc = Now } },
|
||||
new CreateMcpClientRegistrationResponse { Registration = item });
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { registrationId = "abc", tenantId = TenantA, redirectUri = Callback, name = "Team chat" },
|
||||
new McpClientRegistrationLookupResponse { RegistrationId = "abc", TenantId = TenantA, RedirectUri = Callback, Name = "Team chat" });
|
||||
}
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(Callback)]
|
||||
[InlineData("http://localhost:3080/api/mcp/autopilot-monitor/oauth/callback")]
|
||||
[InlineData("http://127.0.0.1:3080/cb")]
|
||||
[InlineData("https://chat.contoso.example:8443/cb/")]
|
||||
public void AcceptableCallbacks(string uri) => Assert.Null(McpClientRegistrationService.ValidateRedirectUri(uri));
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("chat.contoso.example/cb")]
|
||||
[InlineData("http://chat.contoso.example/cb")] // http off loopback
|
||||
[InlineData("https://chat.contoso.example/cb?x=1")] // query
|
||||
[InlineData("https://chat.contoso.example/cb#frag")] // fragment
|
||||
[InlineData("https://*.contoso.example/cb")] // wildcard
|
||||
[InlineData("https://user:pw@chat.contoso.example/cb")]
|
||||
[InlineData("javascript:alert(1)")]
|
||||
[InlineData("ftp://chat.contoso.example/cb")]
|
||||
public void RejectedCallbacks(string uri) => Assert.NotNull(McpClientRegistrationService.ValidateRedirectUri(uri));
|
||||
|
||||
[Fact]
|
||||
public void OverlongCallback_IsRejected()
|
||||
=> Assert.NotNull(McpClientRegistrationService.ValidateRedirectUri("https://chat.contoso.example/" + new string('a', 1100)));
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("tab\there")]
|
||||
public void RejectedNames(string name) => Assert.NotNull(McpClientRegistrationService.ValidateName(name));
|
||||
|
||||
[Fact]
|
||||
public void OverlongName_IsRejected() => Assert.NotNull(McpClientRegistrationService.ValidateName(new string('n', 65)));
|
||||
|
||||
// ── Service: switch, cap, audit, tenant isolation ───────────────────────────
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public required McpClientRegistrationService Sut { get; init; }
|
||||
public required Mock<IMcpClientRegistrationRepository> Repo { get; init; }
|
||||
public required Mock<IMaintenanceRepository> Audit { get; init; }
|
||||
}
|
||||
|
||||
private static Harness Build(bool enabled = true, int? tenantLimit = null)
|
||||
{
|
||||
var rows = new List<McpClientRegistration>();
|
||||
var repo = new Mock<IMcpClientRegistrationRepository>();
|
||||
repo.Setup(r => r.GetForTenantAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string t) => rows.Where(r => r.TenantId == t.ToLowerInvariant()).ToList());
|
||||
repo.Setup(r => r.GetAsync(It.IsAny<string>())).ReturnsAsync((string id) => rows.FirstOrDefault(r => r.RegistrationId == id));
|
||||
repo.Setup(r => r.CreateAsync(It.IsAny<McpClientRegistration>())).Callback<McpClientRegistration>(rows.Add).ReturnsAsync(true);
|
||||
repo.Setup(r => r.DeleteAsync(It.IsAny<string>())).ReturnsAsync((string id) => rows.RemoveAll(r => r.RegistrationId == id) > 0);
|
||||
var audit = new Mock<IMaintenanceRepository>();
|
||||
audit.Setup(a => a.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
|
||||
.ReturnsAsync(true);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { McpClientRegistrationEnabled = enabled });
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string t) => new TenantConfiguration { TenantId = t, McpClientRegistrationLimit = t == TenantA ? tenantLimit : null });
|
||||
var tenantConfig = new TenantConfigurationService(configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
var sut = new McpClientRegistrationService(repo.Object, audit.Object, adminConfig.Object, tenantConfig, NullLogger<McpClientRegistrationService>.Instance);
|
||||
return new Harness { Sut = sut, Repo = repo, Audit = audit };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_StoresARegistration_WithARandomHexId_AndAudits()
|
||||
{
|
||||
var h = Build();
|
||||
|
||||
var result = await h.Sut.CreateAsync(TenantA.ToUpperInvariant(), " Team chat ", Callback, "admin@contoso.example");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, result.Status);
|
||||
var reg = result.Registration!;
|
||||
Assert.True(McpClientRegistrationService.IsRegistrationId(reg.RegistrationId));
|
||||
Assert.Equal(TenantA, reg.TenantId);
|
||||
Assert.Equal("Team chat", reg.Name);
|
||||
Assert.Equal($"amc_{reg.RegistrationId}", McpClientRegistrationService.ClientIdOf(reg.RegistrationId));
|
||||
h.Audit.Verify(a => a.LogAuditEntryAsync(TenantA, "CREATE", "McpClientRegistration", reg.RegistrationId, "admin@contoso.example",
|
||||
It.Is<Dictionary<string, string>?>(d => d != null && d["RedirectUri"] == Callback)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_IsRefused_WhileTheSwitchIsOff()
|
||||
{
|
||||
var h = Build(enabled: false);
|
||||
|
||||
var result = await h.Sut.CreateAsync(TenantA, "Team chat", Callback, "admin@contoso.example");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, result.Status);
|
||||
h.Repo.Verify(r => r.CreateAsync(It.IsAny<McpClientRegistration>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_AllowsOneRegistrationByDefault_PerTenant()
|
||||
{
|
||||
var h = Build();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, (await h.Sut.CreateAsync(TenantA, "chat", Callback, "a")).Status);
|
||||
var second = await h.Sut.CreateAsync(TenantA, "chat 2", "https://chat2.contoso.example/cb", "a");
|
||||
var otherTenant = await h.Sut.CreateAsync(TenantB, "chat", Callback, "b");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, second.Status);
|
||||
Assert.Contains("raise the limit", second.Error);
|
||||
Assert.Equal(HttpStatusCode.Created, otherTenant.Status); // the limit and the duplicate check are per tenant
|
||||
Assert.Equal(1, await h.Sut.GetLimitAsync(TenantA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGlobalAdminOverride_RaisesTheLimit_AndDuplicatesStayRefused()
|
||||
{
|
||||
var h = Build(tenantLimit: 3);
|
||||
Assert.Equal(3, await h.Sut.GetLimitAsync(TenantA.ToUpperInvariant()));
|
||||
for (var i = 0; i < 3; i++)
|
||||
Assert.Equal(HttpStatusCode.Created, (await h.Sut.CreateAsync(TenantA, $"chat {i}", $"https://chat{i}.contoso.example/cb", "a")).Status);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await h.Sut.CreateAsync(TenantA, "chat 4", "https://chat4.contoso.example/cb", "a")).Status);
|
||||
|
||||
var dup = Build(tenantLimit: 3);
|
||||
await dup.Sut.CreateAsync(TenantA, "one", Callback, "a");
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await dup.Sut.CreateAsync(TenantA, "two", Callback.ToUpperInvariant(), "a")).Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(50, 10)]
|
||||
public async Task AnOutOfRangeOverride_IsClamped(int stored, int effective)
|
||||
=> Assert.Equal(effective, await Build(tenantLimit: stored).Sut.GetLimitAsync(TenantA));
|
||||
|
||||
[Fact]
|
||||
public async Task Create_ValidatesBeforeStoring()
|
||||
{
|
||||
var h = Build();
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await h.Sut.CreateAsync(TenantA, "x", "http://chat.contoso.example/cb", "a")).Status);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await h.Sut.CreateAsync(TenantA, "", Callback, "a")).Status);
|
||||
h.Repo.Verify(r => r.CreateAsync(It.IsAny<McpClientRegistration>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_OnlyTheOwnTenantsRegistration_AlsoWhileTheSwitchIsOff()
|
||||
{
|
||||
var h = Build();
|
||||
var reg = (await h.Sut.CreateAsync(TenantA, "Team chat", Callback, "a")).Registration!;
|
||||
|
||||
Assert.False(await h.Sut.DeleteAsync(TenantB, reg.RegistrationId, "b"));
|
||||
Assert.False(await h.Sut.DeleteAsync(TenantA, "not-an-id", "a"));
|
||||
Assert.True(await h.Sut.DeleteAsync(TenantA.ToUpperInvariant(), reg.RegistrationId, "a"));
|
||||
h.Audit.Verify(a => a.LogAuditEntryAsync(TenantA, "DELETE", "McpClientRegistration", reg.RegistrationId, "a", It.IsAny<Dictionary<string, string>?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lookup_AnswersOnlyWhileEnabled_AndOnlyForWellFormedIds()
|
||||
{
|
||||
var on = Build();
|
||||
var reg = (await on.Sut.CreateAsync(TenantA, "Team chat", Callback, "a")).Registration!;
|
||||
|
||||
Assert.NotNull(await on.Sut.LookupAsync(reg.RegistrationId));
|
||||
Assert.Null(await on.Sut.LookupAsync(reg.RegistrationId.ToUpperInvariant()));
|
||||
Assert.Null(await on.Sut.LookupAsync("../../etc"));
|
||||
|
||||
var off = Build(enabled: false);
|
||||
off.Repo.Setup(r => r.GetAsync(reg.RegistrationId)).ReturnsAsync(reg);
|
||||
Assert.Null(await off.Sut.LookupAsync(reg.RegistrationId));
|
||||
off.Repo.Verify(r => r.GetAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,21 @@ public class MiddlewarePipelineOrderTests
|
||||
Assert.True(rateLimit < 0 || policy < rateLimit, "UserRateLimitMiddleware keys off the RequestContext the policy step resolves");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Client_app_binding_runs_between_authentication_and_policy_enforcement()
|
||||
{
|
||||
// It reads the principal the auth step stores and sets the cap marker the policy step resolves roles with.
|
||||
var src = ProgramSource();
|
||||
|
||||
var auth = src.IndexOf($"UseMiddleware<{nameof(AuthenticationMiddleware)}>()", System.StringComparison.Ordinal);
|
||||
var binding = src.IndexOf($"UseMiddleware<{nameof(ClientAppBindingMiddleware)}>()", System.StringComparison.Ordinal);
|
||||
var policy = src.IndexOf($"UseMiddleware<{nameof(PolicyEnforcementMiddleware)}>()", System.StringComparison.Ordinal);
|
||||
|
||||
Assert.True(binding >= 0, "ClientAppBindingMiddleware is not registered in Program.cs");
|
||||
Assert.True(auth < binding && binding < policy, "ClientAppBindingMiddleware must run after authentication and before policy enforcement");
|
||||
Assert.Equal(1, Count(src, $"UseMiddleware<{nameof(ClientAppBindingMiddleware)}>()"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_gate_middleware_is_registered_exactly_once()
|
||||
{
|
||||
|
||||
+6
-4
@@ -7,18 +7,20 @@ namespace AutopilotMonitor.Functions.Tests.Offboarding;
|
||||
/// <summary>
|
||||
/// Test double for <see cref="IOffboardFarewellEmailSender"/>. Records every invocation so
|
||||
/// the handler-side tests can assert "did the farewell send fire after History → Completed?"
|
||||
/// without booting the real mail provider client. Default = silent noop; set <see cref="ThrowOnSend"/>
|
||||
/// to simulate downstream failures and prove the handler's fail-soft contract.
|
||||
/// without booting the real mail provider client. Default = accepted (<see cref="Result"/> true);
|
||||
/// set <see cref="Result"/> false to simulate a refused send, or <see cref="ThrowOnSend"/> to
|
||||
/// simulate downstream failures and prove the handler's fail-soft contract.
|
||||
/// </summary>
|
||||
internal sealed class FakeOffboardFarewellEmailSender : IOffboardFarewellEmailSender
|
||||
{
|
||||
public readonly List<(string ToEmail, string DomainName, string TenantId)> Calls = new();
|
||||
public bool Result { get; set; } = true;
|
||||
public System.Exception? ThrowOnSend { get; set; }
|
||||
|
||||
public Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
|
||||
public Task<bool> SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
Calls.Add((toEmail, domainName, tenantId));
|
||||
if (ThrowOnSend is { } ex) throw ex;
|
||||
return Task.CompletedTask;
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
+80
-4
@@ -383,6 +383,77 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
Times.Never(), "Drain-skip-gate must also bypass the enumerate+enqueue phase");
|
||||
}
|
||||
|
||||
// ── Farewell mail record (Side-effect 6) ────────────────────────────────────
|
||||
// The send fails soft and its success log never reaches Application Insights, so the ops
|
||||
// event is the only record of whether the departing customer got the farewell mail.
|
||||
|
||||
[Fact]
|
||||
public async Task Completed_WithCapturedAddress_ProviderAccepts_RecordsFarewellEmailSent()
|
||||
{
|
||||
var harness = Harness.New();
|
||||
harness.Repo.History[HistoryRowKey].NotificationEmail = "it@contoso.invalid";
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
|
||||
|
||||
Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
|
||||
var call = Assert.Single(harness.Farewell.Calls);
|
||||
Assert.Equal(("it@contoso.invalid", "contoso.invalid", TenantId), call);
|
||||
var evt = Assert.Single(harness.OpsEvents, e => e.EventType.StartsWith("FarewellEmail", StringComparison.Ordinal));
|
||||
Assert.Equal(OpsEventTypes.FarewellEmailSent, evt.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Info, evt.Severity);
|
||||
Assert.Equal(TenantId, evt.TenantId);
|
||||
Assert.Contains("it@contoso.invalid", evt.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completed_WithoutCapturedAddress_RecordsFarewellEmailSkipped_AndNeverCallsSender()
|
||||
{
|
||||
var harness = Harness.New();
|
||||
harness.Repo.History[HistoryRowKey].NotificationEmail = null;
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
|
||||
|
||||
Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
|
||||
Assert.Empty(harness.Farewell.Calls);
|
||||
var evt = Assert.Single(harness.OpsEvents, e => e.EventType.StartsWith("FarewellEmail", StringComparison.Ordinal));
|
||||
Assert.Equal(OpsEventTypes.FarewellEmailSkipped, evt.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Warning, evt.Severity);
|
||||
Assert.Contains("no contact address", evt.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completed_ProviderRefuses_RecordsFarewellEmailFailed()
|
||||
{
|
||||
var harness = Harness.New();
|
||||
harness.Repo.History[HistoryRowKey].NotificationEmail = "it@contoso.invalid";
|
||||
harness.Farewell.Result = false;
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
|
||||
|
||||
var evt = Assert.Single(harness.OpsEvents, e => e.EventType.StartsWith("FarewellEmail", StringComparison.Ordinal));
|
||||
Assert.Equal(OpsEventTypes.FarewellEmailFailed, evt.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Error, evt.Severity);
|
||||
Assert.Contains("did not accept", evt.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completed_SenderThrows_RecordsFarewellEmailFailed_AndStillCompletes()
|
||||
{
|
||||
// Fail-soft contract: the History row is already Completed when the mail goes out; a
|
||||
// throwing sender must neither propagate nor stop the TenantConfiguration delete after it.
|
||||
var harness = Harness.New();
|
||||
harness.Repo.History[HistoryRowKey].NotificationEmail = "it@contoso.invalid";
|
||||
harness.Farewell.ThrowOnSend = new HttpRequestException("simulated provider outage");
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
|
||||
|
||||
Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
|
||||
Assert.Equal("Completed", harness.Repo.Markers[TenantId].Status);
|
||||
var evt = Assert.Single(harness.OpsEvents, e => e.EventType.StartsWith("FarewellEmail", StringComparison.Ordinal));
|
||||
Assert.Equal(OpsEventTypes.FarewellEmailFailed, evt.EventType);
|
||||
Assert.Contains("HttpRequestException", evt.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task History_AlreadyCompleted_IsNoOp()
|
||||
{
|
||||
@@ -436,6 +507,9 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
public CountingSafeWipeService SafeWipeProbe { get; } = new();
|
||||
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
|
||||
public Mock<IConfigRepository> ConfigRepo { get; } = new();
|
||||
public FakeOffboardFarewellEmailSender Farewell { get; } = new();
|
||||
/// <summary>Every ops event the handler recorded, in order (the repository is a capturing mock).</summary>
|
||||
public List<OpsEventEntry> OpsEvents { get; } = new();
|
||||
public List<string> EnumeratorYields { get; } = new();
|
||||
public Exception? EnumeratorThrow { get; set; }
|
||||
|
||||
@@ -475,7 +549,7 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var opsService = BuildRealOpsService();
|
||||
var opsService = BuildRealOpsService(h.OpsEvents);
|
||||
|
||||
var unusedTableStorage = new Mock<TableStorageService>(
|
||||
Mock.Of<TableServiceClient>(),
|
||||
@@ -494,7 +568,7 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
opsService,
|
||||
Mock.Of<ITenantCustomsArchiveRepository>(),
|
||||
h.ConfigRepo.Object,
|
||||
new FakeOffboardFarewellEmailSender(),
|
||||
h.Farewell,
|
||||
NullLogger<TenantOffboardingHandler>.Instance);
|
||||
|
||||
return h;
|
||||
@@ -578,10 +652,12 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
}, "\"0xFAKE_PTR_1\"");
|
||||
}
|
||||
|
||||
private static OpsEventService BuildRealOpsService()
|
||||
private static OpsEventService BuildRealOpsService(List<OpsEventEntry> captured)
|
||||
{
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Returns(Task.CompletedTask);
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(captured.Add)
|
||||
.Returns(Task.CompletedTask);
|
||||
var memCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
@@ -1623,6 +1624,82 @@ public class PolicyEnforcementMiddlewareTests
|
||||
h.ConfigRepo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Offboarding tombstone vs. the farewell feedback route (AllowedDuringOffboarding) ──────
|
||||
// The offboard endpoint tombstones the tenant (Disabled=true, offboarding reason) BEFORE it answers
|
||||
// 202, so the departing admin submits the farewell feedback from an already-suspended tenant. That
|
||||
// one route opts out of the tombstone gate; every other route and every other suspension stay closed.
|
||||
|
||||
private static void AsOffboardingTombstone(Harness h, string tenantId) =>
|
||||
AsSuspended(h, tenantId, reason: TenantOffboardFunction.OffboardingDisabledReason);
|
||||
|
||||
[Fact]
|
||||
public async Task OffboardingTombstone_FeedbackRoute_IsAllowed_ForDepartingTenantAdmin()
|
||||
{
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsOffboardingTombstone(h, TenantA);
|
||||
|
||||
var result = await h.Middleware.DecideAsync(
|
||||
"POST", $"/api/tenants/{TenantA}/offboard/feedback", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Equal(TenantA, result.Context!.TargetTenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OffboardingTombstone_OtherRoutes_StayForbidden()
|
||||
{
|
||||
// The opt-out is per route: the same tombstone still closes the rest of the API.
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsOffboardingTombstone(h, TenantA);
|
||||
|
||||
var read = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
var write = await h.Middleware.DecideAsync("DELETE", $"/api/tenants/{TenantA}/offboard", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(read.Allowed);
|
||||
Assert.Equal("TenantSuspended", read.ErrorCode);
|
||||
Assert.Equal(TenantOffboardFunction.OffboardingDisabledReason, read.ErrorMessage);
|
||||
Assert.False(write.Allowed);
|
||||
Assert.Equal("TenantSuspended", write.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OperatorSuspension_FeedbackRoute_StaysForbidden()
|
||||
{
|
||||
// The opt-out is for the offboarding tombstone only: a tenant an operator suspended (any other
|
||||
// reason) cannot reach the feedback route either — nothing in the flag grants access.
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsSuspended(h, TenantA, reason: "Abuse investigation");
|
||||
|
||||
var result = await h.Middleware.DecideAsync(
|
||||
"POST", $"/api/tenants/{TenantA}/offboard/feedback", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal("TenantSuspended", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OffboardingTombstone_FeedbackRoute_NonMember_StillNeedsTenantAdmin()
|
||||
{
|
||||
// Policy tier is untouched by the flag: a plain member (no tenant admin role) is denied by the
|
||||
// TenantAdminOrGA tier before the suspension gate is even reached.
|
||||
const string upn = "viewer@contoso.com";
|
||||
var h = BuildHarness();
|
||||
AsOffboardingTombstone(h, TenantA);
|
||||
|
||||
var result = await h.Middleware.DecideAsync(
|
||||
"POST", $"/api/tenants/{TenantA}/offboard/feedback", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
Assert.NotEqual("TenantSuspended", result.ErrorCode);
|
||||
}
|
||||
|
||||
// ── Tenant MCP switch (TenantConfiguration.McpDisabled) — the per-request half ────────────
|
||||
// The MCP front door (McpUserService) already denies a caller whose home tenant is closed; this gate
|
||||
// is what makes the switch hold per request (front-door verdicts are cached) and what keeps a closed
|
||||
|
||||
+2
-2
@@ -373,7 +373,7 @@ public class TypedContractCompletionWireParityTests
|
||||
{
|
||||
bootstrapTokenEnabled = true,
|
||||
diagnosticsUploadConfigured = false,
|
||||
validateAutopilotDevice = true,
|
||||
deviceValidationEnabled = true,
|
||||
appHomingFunnelActive = false,
|
||||
showScriptOutput = true,
|
||||
enableSoftwareInventoryAnalyzer = false,
|
||||
@@ -399,7 +399,7 @@ public class TypedContractCompletionWireParityTests
|
||||
{
|
||||
BootstrapTokenEnabled = true,
|
||||
DiagnosticsUploadConfigured = false,
|
||||
ValidateAutopilotDevice = true,
|
||||
DeviceValidationEnabled = true,
|
||||
AppHomingFunnelActive = false,
|
||||
ShowScriptOutput = true,
|
||||
EnableSoftwareInventoryAnalyzer = false,
|
||||
|
||||
@@ -61,7 +61,7 @@ public class TypedRequestGuardTests
|
||||
private static readonly Dictionary<string, int> NewtonsoftBaseline = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = 1,
|
||||
["Functions/Config/UpdateAdminConfigurationFunction.cs"] = 1,
|
||||
["Functions/Config/PatchAdminConfigurationFunction.cs"] = 1,
|
||||
["Functions/Config/UpdateTenantConfigurationFunction.cs"] = 1,
|
||||
["Functions/Rules/AnalyzeRulesFunction.cs"] = 2,
|
||||
["Functions/Rules/DryRunAnalyzeRuleFunction.cs"] = 1,
|
||||
@@ -157,13 +157,21 @@ public class TypedRequestGuardTests
|
||||
var isObjectMap = slot.IsGenericType && slot.GetGenericTypeDefinition() == typeof(Dictionary<,>) && slot.GetGenericArguments()[1] == typeof(object);
|
||||
if (slot == typeof(object))
|
||||
offenders.Add($"{t.Name}.{p.Name}: object slot (TypeScript gets unknown)");
|
||||
else if (isObjectMap && !(t == typeof(PatchTenantConfigurationFieldsRequest) && p.Name == nameof(PatchTenantConfigurationFieldsRequest.Fields)))
|
||||
offenders.Add($"{t.Name}.{p.Name}: untyped map (only the tenant-config field patch carries one by design)");
|
||||
else if (isObjectMap && !IsFieldPatchMap(t, p))
|
||||
offenders.Add($"{t.Name}.{p.Name}: untyped map (only the config field patches carry one by design)");
|
||||
}
|
||||
}
|
||||
Assert.True(offenders.Count == 0, "Request DTO shape violations:\n " + string.Join("\n ", offenders));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two field patches (tenant and admin configuration) carry a { field: value } map on purpose:
|
||||
/// each value takes its field's own JSON type and the server gates the keys against the model.
|
||||
/// </summary>
|
||||
private static bool IsFieldPatchMap(Type type, PropertyInfo property)
|
||||
=> (type == typeof(PatchTenantConfigurationFieldsRequest) && property.Name == nameof(PatchTenantConfigurationFieldsRequest.Fields))
|
||||
|| (type == typeof(PatchAdminConfigurationRequest) && property.Name == nameof(PatchAdminConfigurationRequest.Fields));
|
||||
|
||||
private static IEnumerable<(string Relative, string Text)> HandlerSources()
|
||||
{
|
||||
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
|
||||
|
||||
@@ -43,6 +43,7 @@ namespace AutopilotMonitor.Functions.DataAccess
|
||||
services.AddSingleton<IDeviceSecurityRepository, TableDeviceSecurityRepository>();
|
||||
services.AddSingleton<IUserUsageRepository, TableUserUsageRepository>();
|
||||
services.AddSingleton<IDelegationInvitationRepository, TableDelegationInvitationRepository>();
|
||||
services.AddSingleton<IMcpClientRegistrationRepository, TableMcpClientRegistrationRepository>();
|
||||
services.AddSingleton<IDistressReportRepository, TableDistressReportRepository>();
|
||||
services.AddSingleton<IOpsEventRepository, TableOpsEventRepository>();
|
||||
|
||||
|
||||
+124
-14
@@ -327,23 +327,126 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SaveAdminConfigurationAsync(AdminConfiguration config)
|
||||
{
|
||||
await TrySnapshotBeforeSaveAsync(
|
||||
_adminConfigTableClient, "GlobalConfig", "config",
|
||||
ConvertFromAdminTableEntity, config, AdminBackupNoiseProperties,
|
||||
config.UpdatedBy, "admin-config", reason: null);
|
||||
internal const int AdminUpdateMaxAttempts = 3;
|
||||
|
||||
/// <summary>Server stamps: written on every accepted change, never counted as one.</summary>
|
||||
private static readonly HashSet<string> AdminStampColumns = new(StringComparer.Ordinal)
|
||||
{
|
||||
"PartitionKey", "RowKey", "LastUpdated", "UpdatedBy",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The one write path of the admin row (D-285). Each attempt reads the raw row fresh with
|
||||
/// its ETag and materializes it twice — <c>before</c> and the <c>draft</c> the mutation edits —
|
||||
/// so the copy is exact for every persisted column. The changed columns are computed from
|
||||
/// the two converter projections and laid onto the freshly read raw entity (a null value
|
||||
/// drops the column); the conditional replace then writes exactly those plus the stamps.
|
||||
/// Every other column keeps its stored value: the release pipeline's <c>LatestAgentV2*</c>,
|
||||
/// columns of a newer build, retired columns. The former full-model replace wrote every
|
||||
/// column from whatever copy the caller held, so a portal page loaded before an agent
|
||||
/// release reverted the release's hashes on save (2026-09-25).
|
||||
/// </summary>
|
||||
public async Task<AdminConfigurationUpdateResult> UpdateAdminConfigurationAsync(
|
||||
Func<AdminConfiguration, string?> mutate, string updatedBy, string? source = null)
|
||||
{
|
||||
if (mutate == null) throw new ArgumentNullException(nameof(mutate));
|
||||
|
||||
for (var attempt = 1; attempt <= AdminUpdateMaxAttempts; attempt++)
|
||||
{
|
||||
TableEntity? stored = null;
|
||||
try
|
||||
{
|
||||
stored = (await _adminConfigTableClient.GetEntityAsync<TableEntity>("GlobalConfig", "config")).Value;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// No row yet: the first accepted change creates it from the defaults.
|
||||
}
|
||||
|
||||
var before = stored != null ? ConvertFromAdminTableEntity(stored) : AdminConfiguration.CreateDefault();
|
||||
var draft = stored != null ? ConvertFromAdminTableEntity(stored) : AdminConfiguration.CreateDefault();
|
||||
|
||||
var error = mutate(draft);
|
||||
if (error != null)
|
||||
return new AdminConfigurationUpdateResult { Error = error, Before = before, After = before };
|
||||
|
||||
var beforeColumns = ConvertToAdminTableEntity(before);
|
||||
var draftColumns = ConvertToAdminTableEntity(draft);
|
||||
var changed = ChangedAdminColumns(beforeColumns, draftColumns);
|
||||
if (changed.Count == 0)
|
||||
return new AdminConfigurationUpdateResult { Before = before, After = before };
|
||||
|
||||
draft.UpdatedBy = updatedBy;
|
||||
draft.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
await TrySnapshotBeforeSaveAsync(
|
||||
_adminConfigTableClient, "GlobalConfig", "config",
|
||||
ConvertFromAdminTableEntity, draft, AdminBackupNoiseProperties,
|
||||
updatedBy, source ?? "admin-config", reason: null);
|
||||
|
||||
try
|
||||
{
|
||||
if (stored == null)
|
||||
{
|
||||
await _adminConfigTableClient.AddEntityAsync(ConvertToAdminTableEntity(draft));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var column in changed)
|
||||
{
|
||||
var value = draftColumns[column];
|
||||
if (value == null) stored.Remove(column);
|
||||
else stored[column] = value;
|
||||
}
|
||||
stored["LastUpdated"] = draft.LastUpdated;
|
||||
stored["UpdatedBy"] = draft.UpdatedBy;
|
||||
await _adminConfigTableClient.UpdateEntityAsync(stored, stored.ETag, TableUpdateMode.Replace);
|
||||
}
|
||||
|
||||
return new AdminConfigurationUpdateResult { Before = before, After = draft, ChangedColumns = changed };
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 412 || ex.Status == 409)
|
||||
{
|
||||
// Someone wrote (or created) the row since this attempt's read: re-read and
|
||||
// re-apply the mutation onto their state instead of overwriting it.
|
||||
_logger.LogInformation(
|
||||
"Admin configuration changed concurrently (attempt {Attempt}/{Max}) — re-reading",
|
||||
attempt, AdminUpdateMaxAttempts);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(150));
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Admin configuration update lost {AdminUpdateMaxAttempts} conditional-write races — retry shortly.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Columns whose projected value differs between the two converter outputs. Both sides come
|
||||
/// from <see cref="ConvertToAdminTableEntity"/>, so value types match and plain equality holds.
|
||||
/// </summary>
|
||||
internal static List<string> ChangedAdminColumns(TableEntity before, TableEntity after)
|
||||
{
|
||||
var changed = new List<string>();
|
||||
foreach (var column in after.Keys.Union(before.Keys, StringComparer.Ordinal))
|
||||
{
|
||||
if (AdminStampColumns.Contains(column)) continue;
|
||||
before.TryGetValue(column, out var b);
|
||||
after.TryGetValue(column, out var a);
|
||||
if (!Equals(b, a)) changed.Add(column);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
public async Task<bool> CreateAdminConfigurationIfMissingAsync(AdminConfiguration config)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = ConvertToAdminTableEntity(config);
|
||||
await _adminConfigTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
|
||||
await _adminConfigTableClient.AddEntityAsync(ConvertToAdminTableEntity(config));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (RequestFailedException ex) when (ex.Status == 409)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving admin configuration");
|
||||
return false;
|
||||
return false; // A row exists — never replace it with defaults.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,6 +773,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "DisabledUntil", config.DisabledUntil },
|
||||
{ "McpDisabled", config.McpDisabled },
|
||||
{ "McpDisabledReason", config.McpDisabledReason },
|
||||
{ "McpClientRegistrationLimit", config.McpClientRegistrationLimit },
|
||||
{ "CustomRateLimitRequestsPerMinute", config.CustomRateLimitRequestsPerMinute },
|
||||
{ "CustomUserRateLimitRequestsPerMinute", config.CustomUserRateLimitRequestsPerMinute },
|
||||
{ "ManufacturerWhitelist", config.ManufacturerWhitelist },
|
||||
@@ -784,6 +888,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
DisabledUntil = entity.GetDateTime("DisabledUntil"),
|
||||
McpDisabled = entity.GetBoolean("McpDisabled") ?? false,
|
||||
McpDisabledReason = entity.GetString("McpDisabledReason"),
|
||||
McpClientRegistrationLimit = entity.GetInt32("McpClientRegistrationLimit"),
|
||||
CustomRateLimitRequestsPerMinute = entity.GetInt32("CustomRateLimitRequestsPerMinute"),
|
||||
CustomUserRateLimitRequestsPerMinute = entity.GetInt32("CustomUserRateLimitRequestsPerMinute"),
|
||||
ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation",
|
||||
@@ -929,10 +1034,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "OpsAlertSlackEnabled", config.OpsAlertSlackEnabled },
|
||||
{ "OpsAlertSlackWebhookUrl", config.OpsAlertSlackWebhookUrl ?? string.Empty },
|
||||
{ "OpsNotificationChannelsJson", config.OpsNotificationChannelsJson ?? string.Empty },
|
||||
// Per-line agent binary integrity (written by build scripts via Merge).
|
||||
// Per-line agent binary integrity: written by the release pipeline via Merge.
|
||||
// The backend never changes them (the PATCH gate rejects them), so the
|
||||
// changed-columns write (UpdateAdminConfigurationAsync) never touches them.
|
||||
// V2 is the only wired line; future V3 = add field set here. Retired columns
|
||||
// (V1-suffix and the even older unsuffixed "LatestAgent*") are never written,
|
||||
// so the next Save evicts them implicitly on overwrite.
|
||||
// (V1-suffix and the even older unsuffixed "LatestAgent*") are not projected.
|
||||
{ "AllowAgentDowngrade", config.AllowAgentDowngrade },
|
||||
{ "LatestAgentV2Version", config.LatestAgentV2Version ?? string.Empty },
|
||||
{ "LatestAgentV2Sha256", config.LatestAgentV2Sha256 ?? string.Empty },
|
||||
@@ -950,6 +1056,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "FeedbackCooldownDays", config.FeedbackCooldownDays },
|
||||
// MCP access control
|
||||
{ "McpAccessPolicy", config.McpAccessPolicy ?? "WhitelistOnly" },
|
||||
{ "EnforceClientAppBinding", config.EnforceClientAppBinding },
|
||||
{ "McpClientRegistrationEnabled", config.McpClientRegistrationEnabled },
|
||||
// Agent endpoint migration (config-channel re-home)
|
||||
{ "AgentMigrateApiBaseUrl", config.AgentMigrateApiBaseUrl ?? string.Empty },
|
||||
{ "AgentMigrateTenantOverridesJson", config.AgentMigrateTenantOverridesJson ?? string.Empty }
|
||||
@@ -1019,6 +1127,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
FeedbackCooldownDays = entity.GetInt32("FeedbackCooldownDays") ?? 60,
|
||||
// MCP access control
|
||||
McpAccessPolicy = entity.GetString("McpAccessPolicy") ?? "WhitelistOnly",
|
||||
EnforceClientAppBinding = entity.GetBoolean("EnforceClientAppBinding") ?? false,
|
||||
McpClientRegistrationEnabled = entity.GetBoolean("McpClientRegistrationEnabled") ?? false,
|
||||
// Agent endpoint migration (config-channel re-home)
|
||||
AgentMigrateApiBaseUrl = entity.GetString("AgentMigrateApiBaseUrl") ?? string.Empty,
|
||||
AgentMigrateTenantOverridesJson = entity.GetString("AgentMigrateTenantOverridesJson") ?? string.Empty
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Table-storage implementation of <see cref="IMcpClientRegistrationRepository"/>.
|
||||
/// PK = <see cref="PartitionKey"/> for every tenant, RK = registration id; the tenant view is a
|
||||
/// property filter, the MCP server's lookup a point read.
|
||||
/// </summary>
|
||||
public class TableMcpClientRegistrationRepository : IMcpClientRegistrationRepository
|
||||
{
|
||||
internal const string PartitionKey = "registrations";
|
||||
|
||||
private readonly TableClient _table;
|
||||
private readonly ILogger<TableMcpClientRegistrationRepository> _logger;
|
||||
|
||||
public TableMcpClientRegistrationRepository(TableStorageService storage, ILogger<TableMcpClientRegistrationRepository> logger)
|
||||
{
|
||||
_table = storage.GetTableClient(Constants.TableNames.McpClientRegistrations);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> CreateAsync(McpClientRegistration registration)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _table.AddEntityAsync(Build(registration));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to store MCP client registration {RegistrationId}", registration.RegistrationId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<McpClientRegistration?> GetAsync(string registrationId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _table.GetEntityIfExistsAsync<TableEntity>(PartitionKey, registrationId);
|
||||
return response.HasValue && response.Value != null ? Map(response.Value) : null;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<McpClientRegistration>> GetForTenantAsync(string tenantId)
|
||||
{
|
||||
var results = new List<McpClientRegistration>();
|
||||
try
|
||||
{
|
||||
await foreach (var entity in _table.QueryAsync<TableEntity>(filter: BuildTenantFilter(tenantId)))
|
||||
results.Add(Map(entity));
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
_logger.LogDebug("McpClientRegistrations table does not exist yet, returning empty list");
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string registrationId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _table.DeleteEntityAsync(PartitionKey, registrationId, ETag.All);
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string BuildTenantFilter(string tenantId)
|
||||
=> $"PartitionKey eq '{PartitionKey}' and TenantId eq '{tenantId.ToLowerInvariant().Replace("'", "''")}'";
|
||||
|
||||
/// <summary>Model → entity (every field in both directions; the offboarding wipe matches the lowercase TenantId).</summary>
|
||||
internal static TableEntity Build(McpClientRegistration r) => new(PartitionKey, r.RegistrationId)
|
||||
{
|
||||
["RegistrationId"] = r.RegistrationId,
|
||||
["TenantId"] = r.TenantId.ToLowerInvariant(),
|
||||
["Name"] = r.Name,
|
||||
["RedirectUri"] = r.RedirectUri,
|
||||
["CreatedBy"] = r.CreatedBy,
|
||||
["CreatedDate"] = new DateTimeOffset(DateTime.SpecifyKind(r.CreatedAt, DateTimeKind.Utc)),
|
||||
};
|
||||
|
||||
internal static McpClientRegistration Map(TableEntity e) => new()
|
||||
{
|
||||
RegistrationId = e.GetString("RegistrationId") ?? e.RowKey,
|
||||
TenantId = e.GetString("TenantId") ?? string.Empty,
|
||||
Name = e.GetString("Name") ?? string.Empty,
|
||||
RedirectUri = e.GetString("RedirectUri") ?? string.Empty,
|
||||
CreatedBy = e.GetString("CreatedBy") ?? string.Empty,
|
||||
CreatedAt = e.GetDateTimeOffset("CreatedDate")?.UtcDateTime ?? DateTime.MinValue,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,18 @@ public static class ClaimsPrincipalExtensions
|
||||
public static bool IsApplicationPrincipal(this ClaimsPrincipal principal)
|
||||
=> string.Equals(principal.FindFirst("idtyp")?.Value, "app", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// True for a delegated token from a foreign client application that the client-app binding admitted
|
||||
/// (<see cref="Security.ClientAppBinding"/>): the person keeps their identity, but is capped like an
|
||||
/// application principal.
|
||||
/// </summary>
|
||||
public static bool IsClientCapped(this ClaimsPrincipal principal)
|
||||
=> Security.ClientAppBinding.IsCapped(principal);
|
||||
|
||||
/// <summary>Application principal or admitted foreign client: the callers every application cap applies to.</summary>
|
||||
public static bool IsCappedPrincipal(this ClaimsPrincipal principal)
|
||||
=> principal.IsApplicationPrincipal() || principal.IsClientCapped();
|
||||
|
||||
/// <summary>
|
||||
/// The caller's rate-limit budget class (<see cref="Security.ThrottleSurface"/>), from signed claims
|
||||
/// only: <c>appidacr</c> (v1.0) / <c>azpacr</c> (v2.0) say how the CLIENT authenticated — <c>0</c> is a
|
||||
|
||||
+3
-3
@@ -303,9 +303,9 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Known consent triggers — frontend flows that initiate the AAD admin-consent dialog.
|
||||
/// "device-preparation" is reserved for a future DevPrep-specific consent flow; it is
|
||||
/// accepted by the success endpoint so the value can land in ops events when wired up.
|
||||
/// Known consent triggers — frontend flows that initiate the AAD admin-consent dialog:
|
||||
/// one per serial-based validation (Autopilot, corporate identifier, and
|
||||
/// "device-preparation" for Device Association Validation).
|
||||
/// </summary>
|
||||
internal static readonly HashSet<string> KnownConsentTriggers = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Self-hosted MCP client registrations: Tenant Admins (or a Global Admin) manage the exact callbacks of
|
||||
/// clients their organization runs itself; the MCP server's OAuth proxy resolves a client id
|
||||
/// <c>amc_<id></c> through the anonymous lookup. The lookup sits under <c>/api/auth</c> because the
|
||||
/// Function App requires client certificates outside a fixed list of path prefixes.
|
||||
/// </summary>
|
||||
public class McpClientRegistrationFunction
|
||||
{
|
||||
/// <summary>Per source IP and minute. Every lookup comes from the MCP server's egress IP, which caches answers for a minute.</summary>
|
||||
internal const int LookupRateLimitPerMinute = 120;
|
||||
|
||||
private readonly ILogger<McpClientRegistrationFunction> _logger;
|
||||
private readonly McpClientRegistrationService _service;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
|
||||
public McpClientRegistrationFunction(
|
||||
ILogger<McpClientRegistrationFunction> logger,
|
||||
McpClientRegistrationService service,
|
||||
RateLimitService rateLimitService)
|
||||
{
|
||||
_logger = logger;
|
||||
_service = service;
|
||||
_rateLimitService = rateLimitService;
|
||||
}
|
||||
|
||||
/// <summary>GET /api/tenants/{tenantId}/mcp-client-registrations</summary>
|
||||
[Function("GetMcpClientRegistrations")]
|
||||
[Authorize]
|
||||
public async Task<HttpResponseData> List(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "tenants/{tenantId}/mcp-client-registrations")] HttpRequestData req,
|
||||
string tenantId,
|
||||
FunctionContext context)
|
||||
{
|
||||
var requestCtx = context.GetRequestContext();
|
||||
var registrations = await _service.ListAsync(requestCtx.TargetTenantId);
|
||||
return await req.OkAsync(new McpClientRegistrationListResponse
|
||||
{
|
||||
Enabled = await _service.IsEnabledAsync(),
|
||||
MaxRegistrations = await _service.GetLimitAsync(requestCtx.TargetTenantId),
|
||||
ServerUrl = $"{Constants.McpServerBaseUrl}/mcp",
|
||||
Registrations = registrations.Select(ToItem).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST /api/tenants/{tenantId}/mcp-client-registrations</summary>
|
||||
[Function("CreateMcpClientRegistration")]
|
||||
[Authorize]
|
||||
public async Task<HttpResponseData> Create(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "tenants/{tenantId}/mcp-client-registrations")] HttpRequestData req,
|
||||
string tenantId,
|
||||
FunctionContext context)
|
||||
{
|
||||
var requestCtx = context.GetRequestContext();
|
||||
var read = await req.ReadAsync<CreateMcpClientRegistrationRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
|
||||
var result = await _service.CreateAsync(
|
||||
requestCtx.TargetTenantId, read.Value!.Name, read.Value.RedirectUri, requestCtx.UserPrincipalName);
|
||||
if (result.Registration == null)
|
||||
{
|
||||
var code = result.Status switch
|
||||
{
|
||||
HttpStatusCode.Forbidden => Constants.ApiErrorCodes.Forbidden,
|
||||
HttpStatusCode.Conflict => Constants.ApiErrorCodes.Conflict,
|
||||
HttpStatusCode.InternalServerError => Constants.ApiErrorCodes.InternalError,
|
||||
_ => Constants.ApiErrorCodes.BadRequest,
|
||||
};
|
||||
return await req.ErrorAsync(result.Status, code, result.Error!);
|
||||
}
|
||||
return await req.CreatedAsync(new CreateMcpClientRegistrationResponse { Registration = ToItem(result.Registration) });
|
||||
}
|
||||
|
||||
/// <summary>DELETE /api/tenants/{tenantId}/mcp-client-registrations/{registrationId}</summary>
|
||||
[Function("DeleteMcpClientRegistration")]
|
||||
[Authorize]
|
||||
public async Task<HttpResponseData> Delete(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "tenants/{tenantId}/mcp-client-registrations/{registrationId}")] HttpRequestData req,
|
||||
string tenantId,
|
||||
string registrationId,
|
||||
FunctionContext context)
|
||||
{
|
||||
var requestCtx = context.GetRequestContext();
|
||||
var deleted = await _service.DeleteAsync(requestCtx.TargetTenantId, registrationId, requestCtx.UserPrincipalName);
|
||||
return deleted
|
||||
? await req.OkAsync(new MessageResponse { Message = "Registration deleted" })
|
||||
: await req.NotFoundAsync("Registration not found");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/auth/mcp/client-registrations/{registrationId} — anonymous; the MCP server's OAuth proxy
|
||||
/// resolves a tenant-bound client id before any user token exists. 404 for an unknown id and while the
|
||||
/// operator switch is off; the id is 128 random bits, the answer names nothing secret.
|
||||
/// </summary>
|
||||
[Function("LookupMcpClientRegistration")]
|
||||
public async Task<HttpResponseData> Lookup(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "auth/mcp/client-registrations/{registrationId}")] HttpRequestData req,
|
||||
string registrationId)
|
||||
{
|
||||
var clientIp = ClientIpExtractor.GetTrustedClientIp(req);
|
||||
var rateLimit = _rateLimitService.CheckRateLimit($"mcp-client-lookup:{clientIp}", LookupRateLimitPerMinute);
|
||||
if (!rateLimit.IsAllowed)
|
||||
{
|
||||
return await req.ErrorAsync(HttpStatusCode.TooManyRequests, Constants.ApiErrorCodes.RateLimited,
|
||||
"Rate limit exceeded. Try again later.",
|
||||
retryAfterSeconds: rateLimit.RetryAfter is { } retryAfter ? (int)retryAfter.TotalSeconds : null);
|
||||
}
|
||||
|
||||
var registration = await _service.LookupAsync(registrationId);
|
||||
if (registration == null)
|
||||
return await req.NotFoundAsync("Registration not found");
|
||||
|
||||
return await req.OkAsync(new McpClientRegistrationLookupResponse
|
||||
{
|
||||
RegistrationId = registration.RegistrationId,
|
||||
TenantId = registration.TenantId,
|
||||
RedirectUri = registration.RedirectUri,
|
||||
Name = registration.Name,
|
||||
});
|
||||
}
|
||||
|
||||
internal static McpClientRegistrationItem ToItem(McpClientRegistration r) => new()
|
||||
{
|
||||
RegistrationId = r.RegistrationId,
|
||||
ClientId = McpClientRegistrationService.ClientIdOf(r.RegistrationId),
|
||||
Name = r.Name,
|
||||
RedirectUri = r.RedirectUri,
|
||||
CreatedBy = r.CreatedBy,
|
||||
CreatedUtc = r.CreatedAt,
|
||||
};
|
||||
}
|
||||
+2
-3
@@ -85,9 +85,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// read the full config, so this boolean is their only signal. Deliberately exposes no
|
||||
// destination detail — just "would an upload work".
|
||||
DiagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config),
|
||||
// Drives the "Autopilot Device Validation disabled" dashboard banner
|
||||
// (useTenantSecurityConfig).
|
||||
ValidateAutopilotDevice = config.ValidateAutopilotDevice,
|
||||
// Drives the "no device validation enabled" dashboard banner (useTenantSecurityConfig).
|
||||
DeviceValidationEnabled = config.HasAnyDeviceValidation(),
|
||||
// Dual app-reg self-service migration: when true, running the consent flow (or
|
||||
// "Detect existing access") targets the NEW app registration and auto-flips this
|
||||
// tenant's homing after verification — drives the explanatory banner in the
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// PATCH global/config — the portal's only write to the platform configuration (D-285). The body
|
||||
/// names just the fields the operator changed; the repository re-reads the row, applies them and
|
||||
/// writes only the columns whose value actually changed. It replaced a full-model PUT that wrote
|
||||
/// every column from the page's copy, so a page loaded before an agent release reverted the
|
||||
/// release's hashes on save (2026-09-25).
|
||||
/// </summary>
|
||||
public class PatchAdminConfigurationFunction
|
||||
{
|
||||
private const int MaxBodyBytes = 1_048_576;
|
||||
|
||||
private readonly ILogger<PatchAdminConfigurationFunction> _logger;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
|
||||
public PatchAdminConfigurationFunction(
|
||||
ILogger<PatchAdminConfigurationFunction> logger,
|
||||
AdminConfigurationService adminConfigService,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_adminConfigService = adminConfigService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fields no patch may write: the server stamps, the columns only the agent release pipeline
|
||||
/// writes (build-agent.yml / publish-scripts.yml), and the ones owned by their own flow
|
||||
/// (plan-tier endpoint, vulnerability-sync bookkeeping).
|
||||
/// </summary>
|
||||
internal static readonly HashSet<string> DeniedFields = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"LastUpdated", "UpdatedBy",
|
||||
"LatestAgentV2Version", "LatestAgentV2Sha256", "LatestAgentV2ExeSha256", "LatestBootstrapV2ScriptVersion",
|
||||
"PlanTierDefinitionsJson",
|
||||
"VulnerabilityDataLastSyncUtc", "MsrcLastSyncUtc", "NvdCacheLastRefreshUtc", "EpssLastSyncUtc",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Every persisted, writable AdminConfiguration property except <see cref="DeniedFields"/>.
|
||||
/// Derived from the entity converter, so a new column is patchable the moment it is persisted
|
||||
/// and a model property that is never stored cannot be "saved" into nothing.
|
||||
/// </summary>
|
||||
internal static readonly IReadOnlyCollection<string> PatchableFields = BuildPatchableFields();
|
||||
|
||||
private static HashSet<string> BuildPatchableFields()
|
||||
{
|
||||
var writable = new HashSet<string>(
|
||||
typeof(AdminConfiguration)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanWrite && p.GetIndexParameters().Length == 0)
|
||||
.Select(p => p.Name),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new HashSet<string>(
|
||||
TableConfigRepository.ConvertToAdminTableEntity(AdminConfiguration.CreateDefault()).Keys
|
||||
.Where(c => c != "PartitionKey" && c != "RowKey" && writable.Contains(c) && !DeniedFields.Contains(c)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Function("PatchAdminConfiguration")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/config")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
string userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
// Newtonsoft on purpose: the field map is applied with JsonConvert.PopulateObject, like the
|
||||
// tenant field patch (TypedRequestGuardTests baseline).
|
||||
var read = await req.ReadNewtonsoftAsync<PatchAdminConfigurationRequest>(MaxBodyBytes);
|
||||
if (read.Error != null) return read.Error;
|
||||
var request = read.Value!;
|
||||
if (request.Fields == null || request.Fields.Count == 0)
|
||||
{
|
||||
return await req.BadRequestAsync(
|
||||
"Body must be { \"fields\": { <fieldName>: <value>, ... } } with at least one field.");
|
||||
}
|
||||
|
||||
var fields = JObject.FromObject(request.Fields);
|
||||
var gateError = CheckFields(fields);
|
||||
if (gateError != null) return await req.BadRequestAsync(gateError);
|
||||
|
||||
var result = await _adminConfigService.UpdateAsync(
|
||||
draft => ApplyFields(draft, fields), userIdentifier, "portal");
|
||||
if (result.Error != null) return await req.BadRequestAsync(result.Error);
|
||||
|
||||
_logger.LogInformation(
|
||||
"PatchAdminConfiguration by {User}: {Count} field(s) sent, changed: {Columns}",
|
||||
userIdentifier, fields.Count, string.Join(", ", result.ChangedColumns));
|
||||
|
||||
if (result.ChangedColumns.Count > 0)
|
||||
{
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
Constants.AuditGlobalTenantId,
|
||||
"UPDATE",
|
||||
"AdminConfiguration",
|
||||
"GlobalConfig",
|
||||
userIdentifier,
|
||||
ConfigDiffHelper.GetChanges(result.Before, result.After));
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new UpdateAdminConfigurationResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = result.ChangedColumns.Count > 0
|
||||
? "Admin configuration updated successfully"
|
||||
: "No changes — nothing was written",
|
||||
Config = result.After,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "PatchAdminConfiguration");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The field gate, before any storage work: every key must be a patchable field, named in the
|
||||
/// error when it is not so the caller can correct itself.
|
||||
/// </summary>
|
||||
internal static string? CheckFields(JObject fields)
|
||||
{
|
||||
foreach (var prop in fields.Properties())
|
||||
{
|
||||
if (DeniedFields.Contains(prop.Name))
|
||||
{
|
||||
return $"Field \"{prop.Name}\" is not writable here (server stamp, owned by the agent release " +
|
||||
"pipeline, or written by its own flow).";
|
||||
}
|
||||
if (!PatchableFields.Contains(prop.Name))
|
||||
return $"Unknown field \"{prop.Name}\".";
|
||||
|
||||
// The ops channel list carries per-channel redacted destinations that are restored below;
|
||||
// anywhere else the placeholder would overwrite a real secret with the sentinel.
|
||||
if (!prop.Name.Equals(nameof(AdminConfiguration.OpsNotificationChannelsJson), StringComparison.OrdinalIgnoreCase)
|
||||
&& prop.Value is JValue { Type: JTokenType.String, Value: string text }
|
||||
&& text.Contains(Constants.RedactedSecretPlaceholder, StringComparison.Ordinal))
|
||||
{
|
||||
return $"Field \"{prop.Name}\" carries the \"{Constants.RedactedSecretPlaceholder}\" placeholder — " +
|
||||
"a redacted read must never be written back.";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the gated fields onto the repository's fresh draft and validates the result. JSON
|
||||
/// null clears a nullable field; camelCase keys bind case-insensitively to the model.
|
||||
/// </summary>
|
||||
internal static string? ApplyFields(AdminConfiguration draft, JObject fields)
|
||||
{
|
||||
var storedChannels = draft.OpsNotificationChannelsJson;
|
||||
try
|
||||
{
|
||||
JsonConvert.PopulateObject(fields.ToString(), draft, new JsonSerializerSettings
|
||||
{
|
||||
MissingMemberHandling = MissingMemberHandling.Error,
|
||||
});
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
// Never ex.Message: Newtonsoft quotes the offending VALUE, which may be a secret.
|
||||
var path = (ex as JsonSerializationException)?.Path ?? (ex as JsonReaderException)?.Path;
|
||||
return string.IsNullOrEmpty(path)
|
||||
? "A value has the wrong JSON type for its field."
|
||||
: $"The value for \"{path}\" has the wrong JSON type.";
|
||||
}
|
||||
|
||||
// Rate limits must be positive: a zero/negative value would throttle every request
|
||||
// (RateLimitService clamps as a last resort, but reject at the edge for a clear error).
|
||||
var rateLimitError =
|
||||
draft.GlobalRateLimitRequestsPerMinute < 1 ? "Global Device API Rate Limit" :
|
||||
draft.UserRateLimitRequestsPerMinute < 1 ? "MCP & Integrations API Rate Limit" :
|
||||
draft.PortalUserRateLimitRequestsPerMinute < 1 ? "Portal User API Rate Limit" :
|
||||
draft.GlobalAdminRateLimitRequestsPerMinute < 1 ? "Global Admin API Rate Limit" :
|
||||
null;
|
||||
if (rateLimitError != null)
|
||||
return $"{rateLimitError} must be at least 1 request per minute.";
|
||||
|
||||
if (fields.Properties().Any(p => p.Name.Equals(
|
||||
nameof(AdminConfiguration.OpsNotificationChannelsJson), StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
// A GlobalReader is served destinations as the ***REDACTED*** sentinel; persisting it as a
|
||||
// chat ID or webhook URL would silently break every alert, so restore per channel id.
|
||||
draft.OpsNotificationChannelsJson = NotificationChannel.RestoreRedactedList(
|
||||
draft.OpsNotificationChannelsJson, storedChannels);
|
||||
|
||||
// Same structural gate the tenant channel list passes. No GA gate on Telegram here:
|
||||
// this endpoint is GlobalAdminOnly by policy.
|
||||
var channelsError = TenantConfigValidation.ValidateNotificationChannels(draft.OpsNotificationChannelsJson);
|
||||
if (channelsError != null)
|
||||
return $"Invalid ops notification channels: {channelsError}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -602,9 +602,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
foreach (var tier in body.Tiers)
|
||||
tier.Name = tier.Name.ToLowerInvariant();
|
||||
|
||||
var config = await _adminConfigService.GetConfigurationAsync();
|
||||
config.PlanTierDefinitionsJson = JsonSerializer.Serialize(body.Tiers);
|
||||
await _adminConfigService.SaveConfigurationAsync(config);
|
||||
var tiersJson = JsonSerializer.Serialize(body.Tiers);
|
||||
await _adminConfigService.UpdateAsync(
|
||||
c => { c.PlanTierDefinitionsJson = tiersJson; return null; },
|
||||
TenantHelper.GetUserIdentifier(req), "plan-tiers");
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new PlanTierDefinitionsResponse { Tiers = body.Tiers, Catalog = BuildUsagePlanCatalog() });
|
||||
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
public class UpdateAdminConfigurationFunction
|
||||
{
|
||||
private readonly ILogger<UpdateAdminConfigurationFunction> _logger;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
|
||||
public UpdateAdminConfigurationFunction(
|
||||
ILogger<UpdateAdminConfigurationFunction> logger,
|
||||
AdminConfigurationService adminConfigService,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_adminConfigService = adminConfigService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
}
|
||||
|
||||
[Function("UpdateAdminConfiguration")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "put", "post", Route = "global/config")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
string userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
_logger.LogInformation($"UpdateAdminConfiguration by Global Admin user {userIdentifier}");
|
||||
|
||||
// Parse request body
|
||||
// Newtonsoft on purpose: same configuration stack as the tenant PUT — TypedRequestGuardTests baseline.
|
||||
var read = await req.ReadNewtonsoftAsync<AdminConfiguration>(1_048_576);
|
||||
if (read.Error != null) return read.Error;
|
||||
var config = read.Value!;
|
||||
|
||||
// Rate limits must be positive: a zero/negative value would throttle every request
|
||||
// (RateLimitService clamps as a last resort, but reject at the edge for a clear error).
|
||||
var rateLimitError =
|
||||
config.GlobalRateLimitRequestsPerMinute < 1 ? "Global Device API Rate Limit" :
|
||||
config.UserRateLimitRequestsPerMinute < 1 ? "MCP & Integrations API Rate Limit" :
|
||||
config.PortalUserRateLimitRequestsPerMinute < 1 ? "Portal User API Rate Limit" :
|
||||
config.GlobalAdminRateLimitRequestsPerMinute < 1 ? "Global Admin API Rate Limit" :
|
||||
null;
|
||||
if (rateLimitError != null)
|
||||
{
|
||||
return await req.BadRequestAsync($"{rateLimitError} must be at least 1 request per minute.");
|
||||
}
|
||||
|
||||
// Set the actual user identifier for audit logging
|
||||
config.UpdatedBy = userIdentifier;
|
||||
|
||||
// Ensure PartitionKey and RowKey are correct
|
||||
config.PartitionKey = "GlobalConfig";
|
||||
config.RowKey = "config";
|
||||
|
||||
// Load existing config for diff before saving
|
||||
var existingConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
|
||||
// Ops channels: a GlobalReader is served a redacted copy (destinations replaced
|
||||
// with the ***REDACTED*** sentinel). Only a GA can reach this endpoint, so this is
|
||||
// defense-in-depth — but persisting the literal sentinel as a chat ID or webhook
|
||||
// URL would silently break every alert, so restore per channel id first.
|
||||
config.OpsNotificationChannelsJson = NotificationChannel.RestoreRedactedList(
|
||||
config.OpsNotificationChannelsJson, existingConfig.OpsNotificationChannelsJson);
|
||||
|
||||
// Same structural gate the tenant channel list passes (ids, provider types,
|
||||
// SSRF-checked URLs / Telegram chat IDs, header and signing-secret shape). No GA
|
||||
// gate on Telegram here: this endpoint is GlobalAdminOnly by policy.
|
||||
var channelsError = TenantConfigValidation.ValidateNotificationChannels(config.OpsNotificationChannelsJson);
|
||||
if (channelsError != null)
|
||||
{
|
||||
return await req.BadRequestAsync($"Invalid ops notification channels: {channelsError}");
|
||||
}
|
||||
|
||||
var changes = ConfigDiffHelper.GetChanges(existingConfig, config);
|
||||
|
||||
// Save configuration
|
||||
await _adminConfigService.SaveConfigurationAsync(config);
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
AutopilotMonitor.Shared.Constants.AuditGlobalTenantId,
|
||||
"UPDATE",
|
||||
"AdminConfiguration",
|
||||
"GlobalConfig",
|
||||
userIdentifier,
|
||||
changes.Count > 0 ? changes : null
|
||||
);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new UpdateAdminConfigurationResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Admin configuration updated successfully",
|
||||
Config = config
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "UpdateAdminConfiguration");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -128,7 +128,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
config.CustomRateLimitRequestsPerMinute != existingConfig.CustomRateLimitRequestsPerMinute ||
|
||||
config.CustomUserRateLimitRequestsPerMinute != existingConfig.CustomUserRateLimitRequestsPerMinute ||
|
||||
config.Disabled != existingConfig.Disabled ||
|
||||
config.McpDisabled != existingConfig.McpDisabled)
|
||||
config.McpDisabled != existingConfig.McpDisabled ||
|
||||
config.McpClientRegistrationLimit != existingConfig.McpClientRegistrationLimit)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tenant Admin {User} attempted to modify GA-only fields for tenant {TenantId}",
|
||||
@@ -148,6 +149,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
config.DisabledReason = existingConfig.DisabledReason;
|
||||
config.DisabledUntil = existingConfig.DisabledUntil;
|
||||
config.McpDisabled = existingConfig.McpDisabled;
|
||||
config.McpClientRegistrationLimit = existingConfig.McpClientRegistrationLimit;
|
||||
config.McpDisabledReason = existingConfig.McpDisabledReason;
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -129,6 +129,9 @@ public class GetGraphPermissionsStatusFunction
|
||||
["W365CloudPcValidationGranted"] = GraphFeatureCatalog
|
||||
.IsFeatureGranted(GraphFeatureCatalog.FeatureW365CloudPcValidation, snapshot.GrantedRoles)
|
||||
.ToString(CultureInfo.InvariantCulture),
|
||||
["IntuneDeviceBindingGranted"] = GraphFeatureCatalog
|
||||
.IsFeatureGranted(GraphFeatureCatalog.FeatureIntuneDeviceBinding, snapshot.GrantedRoles)
|
||||
.ToString(CultureInfo.InvariantCulture),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -35,6 +35,7 @@ public class AuthFunction
|
||||
private readonly EntraAppRegistry _appRegistry;
|
||||
private readonly AdminIdentityResolver _identityResolver;
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
|
||||
public AuthFunction(
|
||||
ILogger<AuthFunction> logger,
|
||||
@@ -50,9 +51,11 @@ public class AuthFunction
|
||||
Services.Activation.ITenantAutoApproveEnqueuer tenantAutoApproveEnqueuer,
|
||||
EntraAppRegistry appRegistry,
|
||||
AdminIdentityResolver identityResolver,
|
||||
ISignalRNotificationService signalRService)
|
||||
ISignalRNotificationService signalRService,
|
||||
AdminConfigurationService adminConfigService)
|
||||
{
|
||||
_logger = logger;
|
||||
_adminConfigService = adminConfigService;
|
||||
_identityResolver = identityResolver;
|
||||
_signalRService = signalRService;
|
||||
_globalAdminService = globalAdminService;
|
||||
@@ -169,7 +172,8 @@ public class AuthFunction
|
||||
delegatedTenantIds,
|
||||
homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary",
|
||||
whatsNewSeenPlatformUtc: whatsNewSeen.PlatformUtc,
|
||||
whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc);
|
||||
whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc,
|
||||
mcpClientRegistrationEnabled: (await _adminConfigService.GetConfigurationAsync())?.McpClientRegistrationEnabled ?? false);
|
||||
|
||||
if (!decision.IsSuccess)
|
||||
{
|
||||
@@ -555,7 +559,8 @@ public class AuthFunction
|
||||
IReadOnlyCollection<string>? delegatedTenantIds = null,
|
||||
string homedApp = "primary",
|
||||
DateTime? whatsNewSeenPlatformUtc = null,
|
||||
DateTime? whatsNewSeenAgentUtc = null)
|
||||
DateTime? whatsNewSeenAgentUtc = null,
|
||||
bool mcpClientRegistrationEnabled = false)
|
||||
{
|
||||
// A delegated ("MSP") admin manages a subset of OTHER tenants. They are explicitly authorized, so —
|
||||
// like a Global Admin / Reader — they bypass the private-preview gate even when their own home tenant
|
||||
@@ -644,6 +649,7 @@ public class AuthFunction
|
||||
UnrestrictedModeEnabled =
|
||||
FeatureEntitlementCatalog.Get(TenantEntitlementService.Resolve(tenantConfig, DateTime.UtcNow)).UnrestrictedModeAvailable
|
||||
&& tenantConfig.UnrestrictedModeEnabled,
|
||||
McpClientRegistrationEnabled = mcpClientRegistrationEnabled,
|
||||
WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc,
|
||||
WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc
|
||||
}, needsAutoAdmin);
|
||||
|
||||
@@ -250,7 +250,8 @@ public class McpUserFunction
|
||||
// the identity binding (never the UPN alone); the delegated seat additionally requires a Pro home tenant.
|
||||
// The token's app roles feed the AllMembers member check (claim-derived Admin/Operator when the tenant opted in).
|
||||
var result = await _mcpUserService.IsAllowedAsync(
|
||||
upn, principal?.GetTenantId(), principal?.GetObjectId(), principal?.GetAppRoles());
|
||||
upn, principal?.GetTenantId(), principal?.GetObjectId(), principal?.GetAppRoles(),
|
||||
principal?.IsClientCapped() ?? false);
|
||||
|
||||
if (result.IsAllowed && principal != null && principal.IsApplicationPrincipal())
|
||||
ObserveApplicationSession(principal, result);
|
||||
|
||||
+2
-1
@@ -143,7 +143,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
_logger.LogInformation($"Registering session {registration.SessionId} for tenant {registration.TenantId} (Device: {validation.CertificateThumbprint})");
|
||||
|
||||
// Server-populated: record which device-validation path accepted this request
|
||||
// (AutopilotV1 / CorporateIdentifier / DeviceAssociation / Bootstrap) so the
|
||||
// (AutopilotV1 / CorporateIdentifier / DeviceAssociation / Bootstrap / CloudPc /
|
||||
// IntuneEnrollment) so the
|
||||
// session row shows HOW the backend admitted the device. Overwrites any
|
||||
// agent-sent value unconditionally — this is the server's verdict, not input.
|
||||
registration.ValidatedBy = validation.ValidatedBy;
|
||||
|
||||
+4
-6
@@ -49,12 +49,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
|
||||
if (string.IsNullOrEmpty(msrc.Error))
|
||||
{
|
||||
var config = await _adminConfigService.GetConfigurationAsync();
|
||||
if (config != null)
|
||||
{
|
||||
config.MsrcLastSyncUtc = DateTime.UtcNow.ToString("o");
|
||||
await _adminConfigService.SaveConfigurationAsync(config);
|
||||
}
|
||||
var syncedAt = DateTime.UtcNow.ToString("o");
|
||||
await _adminConfigService.UpdateAsync(
|
||||
c => { c.MsrcLastSyncUtc = syncedAt; return null; },
|
||||
"System (MSRC sync)", "vuln-sync");
|
||||
}
|
||||
|
||||
var durationMs = (int)(DateTime.UtcNow - startedAt).TotalMilliseconds;
|
||||
|
||||
+4
-6
@@ -97,12 +97,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
}
|
||||
|
||||
// Update sync timestamp
|
||||
var config = await _adminConfigService.GetConfigurationAsync();
|
||||
if (config != null)
|
||||
{
|
||||
config.VulnerabilityDataLastSyncUtc = DateTime.UtcNow.ToString("o");
|
||||
await _adminConfigService.SaveConfigurationAsync(config);
|
||||
}
|
||||
var syncedAt = DateTime.UtcNow.ToString("o");
|
||||
await _adminConfigService.UpdateAsync(
|
||||
c => { c.VulnerabilityDataLastSyncUtc = syncedAt; return null; },
|
||||
"System (vulnerability sync)", "vuln-sync");
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new TriggerVulnerabilityDataSyncResponse
|
||||
|
||||
+6
-14
@@ -117,22 +117,14 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
// "Last KEV/MSRC Sync" accurately. (NVD/EPSS stamps are written by the service.)
|
||||
try
|
||||
{
|
||||
var config = await _adminConfigService.GetConfigurationAsync();
|
||||
if (config != null)
|
||||
if (kevSyncedAt != null || msrcSyncedAt != null)
|
||||
{
|
||||
bool dirty = false;
|
||||
if (kevSyncedAt != null)
|
||||
await _adminConfigService.UpdateAsync(c =>
|
||||
{
|
||||
config.VulnerabilityDataLastSyncUtc = kevSyncedAt;
|
||||
dirty = true;
|
||||
}
|
||||
if (msrcSyncedAt != null)
|
||||
{
|
||||
config.MsrcLastSyncUtc = msrcSyncedAt;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty)
|
||||
await _adminConfigService.SaveConfigurationAsync(config);
|
||||
if (kevSyncedAt != null) c.VulnerabilityDataLastSyncUtc = kevSyncedAt;
|
||||
if (msrcSyncedAt != null) c.MsrcLastSyncUtc = msrcSyncedAt;
|
||||
return null;
|
||||
}, "System (vulnerability sync)", "vuln-sync");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -24,6 +24,12 @@ public sealed record RequestContext
|
||||
/// <see cref="Security.AdminIdentity"/>; empty when the token carried none (app-only) or no JWT at all.</summary>
|
||||
public string ObjectId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// True when the caller's token was obtained by a foreign client application that the client-app
|
||||
/// binding admitted; <see cref="Security.AdminIdentity.FromRequestContext"/> carries it into every cap.
|
||||
/// </summary>
|
||||
public bool ClientCapped { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stable identity of the authenticated caller, for THROTTLING keys only — never authorization,
|
||||
/// never audit (use <see cref="UserPrincipalName"/> there; this may be a bare object id).
|
||||
|
||||
@@ -158,6 +158,10 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
if (customLimitError != null)
|
||||
return $"{customLimitError} override must be at least 1 request per minute (or left blank to inherit the global default).";
|
||||
|
||||
if (candidate.McpClientRegistrationLimit is int registrations
|
||||
&& (registrations < 1 || registrations > Services.McpClientRegistrationService.MaxRegistrationLimit))
|
||||
return $"Self-hosted AI client registrations must be between 1 and {Services.McpClientRegistrationService.MaxRegistrationLimit} (or left blank for the default of {Services.McpClientRegistrationService.DefaultRegistrationLimit}).";
|
||||
|
||||
var contactEmailError = ValidateContactEmail(candidate.ContactEmail);
|
||||
if (contactEmailError != null)
|
||||
return $"Invalid contact email: {contactEmailError}";
|
||||
|
||||
@@ -169,11 +169,8 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
// EntraId:AdditionalClientIds. Folding the legacy id in here means configuring the
|
||||
// legacy pair alone is sufficient for inbound trust — no separate AdditionalClientIds
|
||||
// entry required. All extra sources unset ⇒ exactly the primary id ⇒ zero change.
|
||||
var additionalClientIdsRaw = CombineAdditionalClientIdSources(
|
||||
_configuration["EntraId:LegacyClientId"],
|
||||
_configuration["EntraId:AdditionalClientIds"]);
|
||||
var clientIds = ResolveConfiguredClientIds(
|
||||
_configuration["EntraId:ClientId"], additionalClientIdsRaw, out var rejectedAdditionalEntries);
|
||||
var clientIds = ResolveTrustedClientIds(
|
||||
_configuration, out var additionalClientIdsRaw, out var rejectedAdditionalEntries);
|
||||
AuditClientIdTrustSet(additionalClientIdsRaw, clientIds, rejectedAdditionalEntries);
|
||||
var validationParameters = BuildTokenValidationParameters(
|
||||
openIdConfig.SigningKeys, clientIds);
|
||||
@@ -385,6 +382,21 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
return nonEmpty.Length > 0 ? string.Join(",", nonEmpty) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The platform's own app registrations: the audience trust set of this middleware, and the set
|
||||
/// <see cref="ClientAppBindingMiddleware"/> treats as "not foreign". One resolver so the two can never
|
||||
/// disagree about which applications are ours.
|
||||
/// </summary>
|
||||
internal static string[] ResolveTrustedClientIds(
|
||||
IConfiguration configuration, out string? additionalClientIdsRaw, out string[] rejectedAdditionalEntries)
|
||||
{
|
||||
additionalClientIdsRaw = CombineAdditionalClientIdSources(
|
||||
configuration["EntraId:LegacyClientId"],
|
||||
configuration["EntraId:AdditionalClientIds"]);
|
||||
return ResolveConfiguredClientIds(
|
||||
configuration["EntraId:ClientId"], additionalClientIdsRaw, out rejectedAdditionalEntries);
|
||||
}
|
||||
|
||||
internal static string[] ResolveConfiguredClientIds(
|
||||
string? primaryClientId, string? additionalClientIds, out string[] rejectedAdditionalEntries)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Middleware;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Measures and — behind <c>AdminConfiguration.EnforceClientAppBinding</c> — enforces the client-app binding
|
||||
/// of delegated tokens (<see cref="ClientAppBinding"/>). Runs after <see cref="AuthenticationMiddleware"/>
|
||||
/// (it reads the validated principal) and before <see cref="PolicyEnforcementMiddleware"/> (the cap marker
|
||||
/// must exist before any role resolves). Tokens from the platform's own registrations cost one set lookup.
|
||||
/// </summary>
|
||||
public class ClientAppBindingMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
private static readonly TimeSpan LogInterval = TimeSpan.FromHours(1);
|
||||
private const int MaxLogKeys = 1000;
|
||||
|
||||
// Worker traces reach App Insights at Warning+ only; the request dimensions carry the per-request
|
||||
// measurement, this line makes a new (tenant, client, outcome) visible without flooding.
|
||||
private static readonly ConcurrentDictionary<string, DateTime> LastLogged = new();
|
||||
|
||||
private readonly ILogger<ClientAppBindingMiddleware> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
|
||||
public ClientAppBindingMiddleware(
|
||||
ILogger<ClientAppBindingMiddleware> logger,
|
||||
IConfiguration configuration,
|
||||
TenantAdminsService tenantAdminsService,
|
||||
AdminConfigurationService adminConfigService)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_adminConfigService = adminConfigService;
|
||||
}
|
||||
|
||||
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
|
||||
{
|
||||
// Only a principal the authentication step validated — never the empty httpContext.User of an
|
||||
// anonymous or device route, which names no client and would read as "unidentified".
|
||||
if (!context.Items.TryGetValue("ClaimsPrincipal", out var principalObj) || principalObj is not ClaimsPrincipal principal)
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var trustedClientIds = ResolveTrustedClientApps(_configuration);
|
||||
if (!ClientAppBinding.IsForeignDelegatedToken(principal, trustedClientIds, out var clientAppId))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantId = principal.GetTenantId();
|
||||
var registered = false;
|
||||
if (clientAppId != null && !string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
var (state, _) = await _tenantAdminsService.GetTableMembershipAsync(
|
||||
tenantId, Constants.PrincipalKeys.ForApplication(clientAppId));
|
||||
registered = state == TableMemberState.Enabled;
|
||||
}
|
||||
|
||||
var outcome = clientAppId == null
|
||||
? ClientAppBinding.Outcomes.Unidentified
|
||||
: registered ? ClientAppBinding.Outcomes.Registered : ClientAppBinding.Outcomes.Unregistered;
|
||||
context.Items[ClientAppBinding.OutcomeItemKey] = outcome;
|
||||
if (clientAppId != null)
|
||||
context.Items[ClientAppBinding.ClientAppIdItemKey] = clientAppId;
|
||||
|
||||
var enforced = (await _adminConfigService.GetConfigurationAsync()).EnforceClientAppBinding;
|
||||
LogOncePerInterval(tenantId, clientAppId, outcome, enforced);
|
||||
|
||||
if (enforced)
|
||||
{
|
||||
if (!registered)
|
||||
{
|
||||
var httpContext = context.GetHttpContext();
|
||||
if (httpContext != null)
|
||||
{
|
||||
await ApiErrorWriter.WriteAsync(
|
||||
httpContext, context.GetCorrelationId(), HttpStatusCode.Forbidden,
|
||||
Constants.ApiErrorCodes.ClientAppNotRegistered,
|
||||
ClientAppBinding.RefusalMessage(clientAppId));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ClientAppBinding.MarkCapped(principal, clientAppId!);
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The client applications whose delegated tokens are never foreign: the audience trust set (primary,
|
||||
/// legacy, additional app registrations) plus <c>EntraId:TrustedClientAppIds</c> — clients of the platform's
|
||||
/// own that obtain tokens for the API app without being an audience themselves (the local development
|
||||
/// registration). The extra setting widens only this set, never the accepted token audiences.
|
||||
/// </summary>
|
||||
internal static IReadOnlyCollection<string> ResolveTrustedClientApps(IConfiguration configuration)
|
||||
{
|
||||
var audienceTrust = AuthenticationMiddleware.ResolveTrustedClientIds(configuration, out _, out _);
|
||||
var clientOnly = AuthenticationMiddleware.ResolveConfiguredClientIds(
|
||||
null, configuration["EntraId:TrustedClientAppIds"], out _);
|
||||
return audienceTrust.Concat(clientOnly).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private void LogOncePerInterval(string? tenantId, string? clientAppId, string outcome, bool enforced)
|
||||
{
|
||||
var key = $"{tenantId}|{clientAppId}|{outcome}|{enforced}";
|
||||
var now = DateTime.UtcNow;
|
||||
if (LastLogged.TryGetValue(key, out var last) && now - last < LogInterval)
|
||||
return;
|
||||
if (LastLogged.Count >= MaxLogKeys)
|
||||
LastLogged.Clear();
|
||||
LastLogged[key] = now;
|
||||
|
||||
_logger.LogWarning(
|
||||
"[ClientAppBinding] Delegated token from client app {ClientAppId} (tid={TenantId}): {Outcome}, enforced={Enforced}",
|
||||
LogSanitizer.Clean(clientAppId ?? "(none)"), LogSanitizer.Clean(tenantId ?? "(none)"), outcome, enforced);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -303,10 +304,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
// and nobody without platform scope reaches a suspended target. Platform operators (GA/Reader) bypass —
|
||||
// they must still administer the suspended tenant. Anonymous/device routes bring their own gate
|
||||
// (SecurityValidator → 403 TenantDisabled). Cached, side-effect-free read; a missing row is not disabled.
|
||||
// An AllowedDuringOffboarding route ignores the offboarding tombstone alone (the departing admin's
|
||||
// farewell feedback arrives after their own tenant was tombstoned); any other suspension still holds.
|
||||
if (principal != null && !hasGlobalScope)
|
||||
{
|
||||
var gateConfigs = await LoadGateConfigsAsync(jwtTenantId, targetTenantId);
|
||||
var suspended = gateConfigs.FirstOrDefault(c => c.IsCurrentlyDisabled());
|
||||
var suspended = gateConfigs.FirstOrDefault(c => c.IsCurrentlyDisabled()
|
||||
&& !(catalogEntry.AllowedDuringOffboarding && TenantOffboardFunction.IsOffboardingTombstone(c)));
|
||||
if (suspended != null)
|
||||
{
|
||||
_logger.LogWarning("[PolicyEnforcement] BLOCKED suspended tenant: user={User} tenant={Tenant} path={Path}",
|
||||
@@ -361,6 +365,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
// decision.UserIdentifier for log lines, where "anonymous" is the useful rendering.
|
||||
UserPrincipalName = upn ?? string.Empty,
|
||||
ObjectId = principal?.GetObjectId() ?? string.Empty,
|
||||
ClientCapped = principal?.IsClientCapped() ?? false,
|
||||
// Throttle identity — NOT the same thing as the UPN. Empty only when the request carried
|
||||
// no JWT at all (device/anonymous routes, which bring their own limits). An authenticated
|
||||
// caller ALWAYS gets a non-empty value, including app-only tokens that carry no upn:
|
||||
@@ -483,7 +488,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
// "some authenticated person". The roleless tiers admit any valid token, so they are closed to
|
||||
// applications unless the catalog entry opted in (auth/mcp). Every role-gated tier below already
|
||||
// resolves the app:<client-id> key through the same tables as a person, with its caps applied.
|
||||
if (principal != null && principal.IsApplicationPrincipal() && entry.IsRolelessTier && !entry.ApplicationAllowed)
|
||||
// A person reached through an admitted foreign client is capped the same way.
|
||||
if (principal != null && principal.IsCappedPrincipal() && entry.IsRolelessTier && !entry.ApplicationAllowed)
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "Application", "ApplicationPrincipalNotAllowed");
|
||||
|
||||
switch (entry.Policy)
|
||||
@@ -693,7 +699,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
/// </summary>
|
||||
private Task<MemberRoleInfo?> ResolveEffectiveRoleAsync(
|
||||
string tenantId, string upn, ClaimsPrincipal? principal)
|
||||
=> _memberRoleResolver.ResolveAsync(tenantId, upn, principal?.GetAppRoles());
|
||||
=> _memberRoleResolver.ResolveAsync(tenantId, upn, principal?.GetAppRoles(), principal?.IsClientCapped() ?? false);
|
||||
|
||||
/// <summary>
|
||||
/// Platform-wide cross-tenant READ: admits GlobalAdmin and the read-only GlobalReader. The
|
||||
|
||||
@@ -159,6 +159,13 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
requestTelemetry.Properties["ApplicationId"] = applicationId;
|
||||
}
|
||||
|
||||
// CLIENT-APP BINDING — set by ClientAppBindingMiddleware only for a delegated token a foreign
|
||||
// client application obtained: the measurement behind the enforcement switch (who would be refused).
|
||||
if (context.Items.TryGetValue(ClientAppBinding.OutcomeItemKey, out var clientBinding) && clientBinding is string clientBindingOutcome)
|
||||
requestTelemetry.Properties["ClientAppBinding"] = clientBindingOutcome;
|
||||
if (context.Items.TryGetValue(ClientAppBinding.ClientAppIdItemKey, out var clientApp) && clientApp is string clientAppId)
|
||||
requestTelemetry.Properties["ClientAppId"] = clientAppId;
|
||||
|
||||
if (context.Items.TryGetValue("CorrelationId", out var corrId) && corrId is string correlationId)
|
||||
requestTelemetry.Properties["CorrelationId"] = correlationId;
|
||||
|
||||
@@ -191,6 +198,14 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
if (context.Items.TryGetValue(RequestRowMarkers.DeviceValidationKey, out var deviceValidation) && deviceValidation is string deviceValidationOutcome)
|
||||
requestTelemetry.Properties[RequestRowMarkers.DeviceValidationKey] = deviceValidationOutcome;
|
||||
|
||||
// CERT-DEVICE-BINDING — the Intune device lookup behind the certificate (admitting or
|
||||
// observing), set by SecurityValidator whenever a lookup result exists for the request.
|
||||
foreach (var key in RequestRowMarkers.CertDeviceBindingKeys)
|
||||
{
|
||||
if (context.Items.TryGetValue(key, out var value) && value is string text)
|
||||
requestTelemetry.Properties[key] = text;
|
||||
}
|
||||
|
||||
var reqCtx = context.GetRequestContext();
|
||||
var tenantId = ResolveTenantId(
|
||||
reqCtx.TenantId,
|
||||
|
||||
@@ -41,6 +41,9 @@ builder.UseMiddleware<SecurityHeadersMiddleware>();
|
||||
builder.UseMiddleware<NoStoreCacheMiddleware>();
|
||||
builder.UseMiddleware<GlobalExceptionMiddleware>();
|
||||
builder.UseMiddleware<AuthenticationMiddleware>();
|
||||
// Delegated tokens from foreign client apps: measured always, refused or capped when enforced. Must run
|
||||
// before policy enforcement, which resolves roles with the cap marker this step sets.
|
||||
builder.UseMiddleware<ClientAppBindingMiddleware>();
|
||||
builder.UseMiddleware<PolicyEnforcementMiddleware>();
|
||||
builder.UseMiddleware<UserRateLimitMiddleware>();
|
||||
// MCP quota (daily/monthly budget on top of the per-minute rate limit): only requests marked
|
||||
@@ -267,6 +270,7 @@ builder.Services.AddSingleton<DelegatedAdminService>();
|
||||
builder.Services.AddSingleton<DelegatedSlotService>();
|
||||
builder.Services.AddSingleton<DelegationSelfService>();
|
||||
builder.Services.AddSingleton<McpUserService>();
|
||||
builder.Services.AddSingleton<McpClientRegistrationService>();
|
||||
builder.Services.AddSingleton<PreviewWhitelistService>();
|
||||
// Shared activation path (whitelist add + auto-promote + welcome email) — used by the
|
||||
// Global Admin approve endpoint and the tenant auto-approve queue worker.
|
||||
|
||||
@@ -21,30 +21,36 @@ namespace AutopilotMonitor.Functions.Security;
|
||||
/// <param name="TenantId">The JWT <c>tid</c> (lowercase) — the caller's home tenant.</param>
|
||||
/// <param name="ObjectId">The JWT <c>oid</c> (lowercase) — the caller's object id in that tenant (for an
|
||||
/// application the service principal's object id, which differs per tenant).</param>
|
||||
public sealed record AdminIdentity(string Upn, string TenantId, string ObjectId)
|
||||
/// <param name="ClientCapped">True for a person whose token a foreign client application obtained and the
|
||||
/// client-app binding admitted (<see cref="ClientAppBinding"/>).</param>
|
||||
public sealed record AdminIdentity(string Upn, string TenantId, string ObjectId, bool ClientCapped = false)
|
||||
{
|
||||
/// <summary>
|
||||
/// True when the key names an application (app-only token). Applications are capped everywhere: never
|
||||
/// a platform role, never more than Viewer in a tenant, never more than DelegatedReader on a managed
|
||||
/// tenant — the caps live in the respective role services, this flag is what they switch on.
|
||||
/// tenant — the caps live in the respective role services, <see cref="IsCapped"/> is what they switch on.
|
||||
/// </summary>
|
||||
public bool IsApplication => Shared.Constants.PrincipalKeys.IsApplication(Upn);
|
||||
|
||||
/// <summary>The application caps apply: an application principal, or a person reached through a foreign client.</summary>
|
||||
public bool IsCapped => IsApplication || ClientCapped;
|
||||
|
||||
/// <summary>Builds the identity from a validated principal, or null when upn, tid or oid is missing.</summary>
|
||||
public static AdminIdentity? FromPrincipal(ClaimsPrincipal? principal)
|
||||
=> principal == null
|
||||
? null
|
||||
: Create(principal.GetUserPrincipalName(), principal.GetTenantId(), principal.GetObjectId());
|
||||
: Create(principal.GetUserPrincipalName(), principal.GetTenantId(), principal.GetObjectId(),
|
||||
principal.IsClientCapped());
|
||||
|
||||
/// <summary>Builds the identity from the middleware-resolved request context, or null when incomplete.</summary>
|
||||
public static AdminIdentity? FromRequestContext(RequestContext context)
|
||||
=> Create(context.UserPrincipalName, context.TenantId, context.ObjectId);
|
||||
=> Create(context.UserPrincipalName, context.TenantId, context.ObjectId, context.ClientCapped);
|
||||
|
||||
/// <summary>Normalizing factory: all three parts required (whitespace counts as missing), all lowercased.</summary>
|
||||
public static AdminIdentity? Create(string? upn, string? tenantId, string? objectId)
|
||||
public static AdminIdentity? Create(string? upn, string? tenantId, string? objectId, bool clientCapped = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(upn) || string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(objectId))
|
||||
return null;
|
||||
return new AdminIdentity(upn.ToLowerInvariant(), tenantId.ToLowerInvariant(), objectId.ToLowerInvariant());
|
||||
return new AdminIdentity(upn.ToLowerInvariant(), tenantId.ToLowerInvariant(), objectId.ToLowerInvariant(), clientCapped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +268,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
Thumbprint = thumbprint,
|
||||
Subject = certificate.Subject,
|
||||
Issuer = certificate.Issuer,
|
||||
NotBefore = new DateTimeOffset(notBeforeUtc, TimeSpan.Zero),
|
||||
CertTenantId = certTenantId,
|
||||
CertTenantIdStatus = certTenantIdStatus
|
||||
};
|
||||
@@ -412,6 +413,12 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of the certificate's validity (UTC), set on success. The Intune MDM Device CA
|
||||
/// issues the certificate at enrollment, so its age bounds how recently the device enrolled.
|
||||
/// </summary>
|
||||
public DateTimeOffset? NotBefore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Entra TenantId stamped into the certificate by the Intune MDM Device CA
|
||||
/// (OID <c>1.2.840.113556.5.14</c>), or <c>null</c> when
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Client-app binding for delegated (user) tokens. Entra issues a token for this API to any application a
|
||||
/// tenant consented to; the token names that application in <c>azp</c> (v2.0) / <c>appid</c> (v1.0). A token
|
||||
/// obtained by one of the platform's own registrations (the audience trust set: primary, legacy and
|
||||
/// additional client ids) is the portal or the MCP server and passes untouched. Any other client — a
|
||||
/// self-hosted AI client using on-behalf-of, an in-house tool — is "foreign": it is measured on every
|
||||
/// request and, while enforcement is on, admitted only when that application is an enabled member of the
|
||||
/// caller's tenant (the <c>app:<client-id></c> row a service principal is granted under), with the
|
||||
/// caller capped like an application principal (<see cref="AdminIdentity.IsCapped"/>).
|
||||
/// </summary>
|
||||
public static class ClientAppBinding
|
||||
{
|
||||
/// <summary><c>FunctionContext.Items</c> key: the foreign client's application id (absent when the token names none).</summary>
|
||||
public const string ClientAppIdItemKey = "ClientAppId";
|
||||
|
||||
/// <summary><c>FunctionContext.Items</c> key: one of <see cref="Outcomes"/> — set only for a foreign client.</summary>
|
||||
public const string OutcomeItemKey = "ClientAppBinding";
|
||||
|
||||
/// <summary>
|
||||
/// Authentication type of the marker identity added to an admitted foreign-client principal. A token
|
||||
/// cannot produce it: every token-derived identity carries the handler's authentication type, so the
|
||||
/// marker is only ever the middleware's own statement.
|
||||
/// </summary>
|
||||
public const string CappedAuthenticationType = "AutopilotMonitor.ClientAppBinding";
|
||||
|
||||
/// <summary>Claim on the marker identity naming the admitted client application.</summary>
|
||||
public const string ClientAppClaimType = "client_app";
|
||||
|
||||
public static class Outcomes
|
||||
{
|
||||
/// <summary>The application is an enabled member of the caller's tenant.</summary>
|
||||
public const string Registered = "registered";
|
||||
|
||||
/// <summary>The application is not a member of the caller's tenant, or its row is disabled.</summary>
|
||||
public const string Unregistered = "unregistered";
|
||||
|
||||
/// <summary>The token names no client application at all (never seen from Entra; fail-closed when enforced).</summary>
|
||||
public const string Unidentified = "unidentified";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="principal"/> is a delegated token obtained by a client outside
|
||||
/// <paramref name="trustedClientIds"/>. App-only tokens are never foreign here — they have their own gate
|
||||
/// (<c>access_as_application</c>) and are keyed on their own member row. <paramref name="clientAppId"/> is
|
||||
/// the token's client id (lowercase) or null when it names none.
|
||||
/// </summary>
|
||||
public static bool IsForeignDelegatedToken(
|
||||
ClaimsPrincipal principal, IReadOnlyCollection<string> trustedClientIds, out string? clientAppId)
|
||||
{
|
||||
clientAppId = null;
|
||||
if (principal.IsApplicationPrincipal())
|
||||
return false;
|
||||
|
||||
clientAppId = principal.GetApplicationId();
|
||||
return clientAppId == null
|
||||
|| !trustedClientIds.Contains(clientAppId, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Marks an admitted foreign-client principal so every role resolution applies the application caps.</summary>
|
||||
public static void MarkCapped(ClaimsPrincipal principal, string clientAppId)
|
||||
=> principal.AddIdentity(new ClaimsIdentity(
|
||||
new[] { new Claim(ClientAppClaimType, clientAppId) }, CappedAuthenticationType));
|
||||
|
||||
/// <summary>True when the middleware admitted this principal as a foreign client (see <see cref="MarkCapped"/>).</summary>
|
||||
public static bool IsCapped(ClaimsPrincipal principal)
|
||||
=> principal.Identities.Any(i => string.Equals(i.AuthenticationType, CappedAuthenticationType, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>The 403 text for a refused foreign client: names the application and the one fix.</summary>
|
||||
public static string RefusalMessage(string? clientAppId)
|
||||
=> clientAppId == null
|
||||
? "This token names no client application and is not accepted. Sign in through the Autopilot Monitor portal or a supported MCP client."
|
||||
: $"The application {clientAppId} that obtained this token is not registered in your organization. " +
|
||||
"A Tenant Admin can add it under Settings > Access Management as a service principal; its users then have read-only (Viewer) access.";
|
||||
}
|
||||
@@ -134,11 +134,24 @@ public sealed class EndpointPolicyEntry
|
||||
/// </summary>
|
||||
public bool ApplicationAllowed { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, the tenant suspension gate lets the caller through while the ONLY reason the tenant
|
||||
/// is suspended is the offboarding tombstone (<c>Disabled=true</c> with the offboarding reason,
|
||||
/// <see cref="Functions.Admin.TenantOffboardFunction.IsOffboardingTombstone"/>). An operator
|
||||
/// suspension (any other reason, or a DisabledUntil window) still denies. Exists for the one route
|
||||
/// a departing admin must reach AFTER their tenant has been tombstoned: the farewell feedback
|
||||
/// (<c>POST tenants/{tenantId}/offboard/feedback</c>), which the offboard endpoint tombstones
|
||||
/// before it even answers 202. Never grants anything else — policy tier, cross-tenant check and
|
||||
/// the function's own active-marker check stay in force.
|
||||
/// </summary>
|
||||
public bool AllowedDuringOffboarding { get; }
|
||||
|
||||
// Pre-compiled regex for matching actual request paths against the route template
|
||||
internal Regex RouteRegex { get; }
|
||||
|
||||
public EndpointPolicyEntry(string httpMethod, string routeTemplate, EndpointPolicy policy,
|
||||
TenantScoping tenantScoping = TenantScoping.None, bool excludeDelegated = false, bool applicationAllowed = false)
|
||||
TenantScoping tenantScoping = TenantScoping.None, bool excludeDelegated = false, bool applicationAllowed = false,
|
||||
bool allowedDuringOffboarding = false)
|
||||
{
|
||||
HttpMethod = httpMethod.ToUpperInvariant();
|
||||
RouteTemplate = routeTemplate;
|
||||
@@ -146,6 +159,7 @@ public sealed class EndpointPolicyEntry
|
||||
TenantScoping = tenantScoping;
|
||||
ExcludeDelegated = excludeDelegated;
|
||||
ApplicationAllowed = applicationAllowed;
|
||||
AllowedDuringOffboarding = allowedDuringOffboarding;
|
||||
RouteRegex = BuildRouteRegex(routeTemplate);
|
||||
}
|
||||
|
||||
@@ -363,11 +377,17 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("DELETE", "tenants/{tenantId}/admins/{adminUpn}", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
// Self-hosted MCP client registrations (the OAuth callback a tenant's own client uses).
|
||||
new("GET", "tenants/{tenantId}/mcp-client-registrations", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "tenants/{tenantId}/mcp-client-registrations", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("DELETE", "tenants/{tenantId}/mcp-client-registrations/{registrationId}", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("PATCH", "tenants/{tenantId}/admins/{adminUpn}/disable", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("PATCH", "tenants/{tenantId}/admins/{adminUpn}/enable", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("PATCH", "tenants/{tenantId}/admins/{adminUpn}/permissions", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("DELETE", "tenants/{tenantId}/offboard", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "tenants/{tenantId}/offboard/feedback", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
// Farewell feedback is submitted AFTER the offboard endpoint has tombstoned the tenant (Disabled=true
|
||||
// before the 202), so the suspension gate must let the departing admin through — for this route only.
|
||||
new("POST", "tenants/{tenantId}/offboard/feedback", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam, allowedDuringOffboarding: true),
|
||||
new("GET", "config/{tenantId}/autopilot-device-validation/consent-url", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("GET", "config/{tenantId}/autopilot-device-validation/consent-status", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "config/{tenantId}/autopilot-device-validation/consent-failure", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
@@ -399,6 +419,9 @@ public static class EndpointAccessPolicyCatalog
|
||||
// The MCP front door: the only roleless-tier route an application principal may reach — the
|
||||
// handler resolves membership / delegation itself (McpUserService.IsAllowedAsync).
|
||||
new("GET", "auth/mcp", EndpointPolicy.AuthenticatedUser, applicationAllowed: true),
|
||||
// The MCP server's OAuth proxy resolves a tenant-bound client id before any user token exists:
|
||||
// anonymous, per-IP rate-limited in the function, 404 while the operator switch is off.
|
||||
new("GET", "auth/mcp/client-registrations/{registrationId}", EndpointPolicy.PublicAnonymous),
|
||||
|
||||
// ── MCP Usage (self-service) ──────────────────────────────────
|
||||
new("GET", "metrics/mcp-usage/me", EndpointPolicy.AuthenticatedUser),
|
||||
@@ -456,8 +479,8 @@ public static class EndpointAccessPolicyCatalog
|
||||
// ?tenantId= path — never the no-tenantId aggregate (which the delegated-rescue cannot satisfy).
|
||||
new("GET", "global/metrics/summary", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/config", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("PUT", "global/config", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "global/config", EndpointPolicy.GlobalAdminOnly),
|
||||
// Changed fields only (D-285); the former full-model PUT/POST is gone.
|
||||
new("PATCH", "global/config", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "global/config/test-ops-channel", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "config/all", EndpointPolicy.GlobalReadOrDelegatedSubset),
|
||||
// Transactional field-level config writes + snapshot list/revert (MCP + web Settings).
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -17,55 +21,97 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// <para>
|
||||
/// <see cref="CertTenantBinding"/> already proves the certificate was issued to this tenant.
|
||||
/// This adds the second half: that the specific device is still one of the tenant's enrolled
|
||||
/// devices, so a certificate lifted from a decommissioned or wiped machine no longer resolves.
|
||||
/// devices. That is proof enough to admit a device without any pre-registration, which is
|
||||
/// what the Intune Enrollment Validation option does (<see cref="ValidateAsync"/>). For
|
||||
/// devices admitted by another validator the same lookup only observes
|
||||
/// (<see cref="ObserveInBackground"/>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Built as a near-sibling of <see cref="CloudPcDeviceValidator"/> — same DI dependencies and
|
||||
/// the same cache/retry/transient contract (30 min positive, 5 min negative, 2 attempts with a
|
||||
/// 2s back-off, transient on Graph 5xx / token failure / exception). Two deliberate differences:
|
||||
/// Same cache/retry/budget contract as <see cref="DeviceAssociationValidator"/> (30 min
|
||||
/// positive, 5 min negative, 2 attempts inside the chain budget, transient never cached), with
|
||||
/// three deliberate differences:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>A point-GET on <c>managedDevices/{id}</c> rather than an OData
|
||||
/// <c>$filter</c>. There is no filter literal to inject into, and a 404 is an unambiguous
|
||||
/// <c>$filter</c> (D-125): no filter literal to inject into, and a 404 is an unambiguous
|
||||
/// "not this tenant's device".</description></item>
|
||||
/// <item><description><see cref="IntuneDeviceBindingResult.EnrolledDateTime"/> is carried out
|
||||
/// so callers can record how old the device object was at request time. That is the open
|
||||
/// question this check must answer before it could ever gate enrollment: a device whose object
|
||||
/// appears only after the agent's first call is a race, not an attack.</description></item>
|
||||
/// <item><description>A 404 for a certificate younger than <see cref="RecentEnrollmentWindow"/>
|
||||
/// is <see cref="IntuneDeviceBindingOutcome.NotFoundRecentEnrollment"/>: the device object may
|
||||
/// simply not be visible in Graph yet, so the caller answers 503 Retry-After instead of 403 and
|
||||
/// nothing is cached.</description></item>
|
||||
/// <item><description>Observation never runs without the Graph permission: whether the tenant
|
||||
/// granted it is read from the cached app token's roles claim, so a tenant without the add-on
|
||||
/// costs no Graph call at all.</description></item>
|
||||
/// </list>
|
||||
/// SHADOW ONLY (stage 3): nothing consumes the result for an authorization decision. Grep
|
||||
/// marker for the enforcement change: <c>CERT-DEVICE-BINDING-SHADOW</c>.
|
||||
/// </remarks>
|
||||
public class IntuneDeviceBindingValidator
|
||||
{
|
||||
private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30);
|
||||
private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// How long after the certificate's NotBefore a 404 counts as "not visible yet" rather than
|
||||
/// "not this tenant's device". Generous on purpose: the window only turns a 403 into a 503
|
||||
/// and never admits anything, and whether Intune backdates NotBefore is unmeasured
|
||||
/// (rejected devices reported their first call at least ~12 min after NotBefore).
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan RecentEnrollmentWindow = TimeSpan.FromMinutes(60);
|
||||
|
||||
/// <summary>How long a tenant's permission verdict is reused before asking the detector again.</summary>
|
||||
private static readonly TimeSpan PermissionVerdictTtl = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>Upper bound for one background observation, so an in-flight marker cannot outlive a stuck call.</summary>
|
||||
private static readonly TimeSpan InFlightTtl = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Pause after an observation that ended transient (nothing was cached), so a device whose
|
||||
/// object is not visible yet costs one Graph call per interval, not one per request.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan TransientObservationCooldown = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// managementState values of a device that is being removed from management. Such a device
|
||||
/// still has an object, but it is on its way out, so it must not be admitted. Every other
|
||||
/// state (managed, unhealthy, discovered, *Failed, *Canceled) is a device Intune still manages.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> LeavingManagementStates = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"retirePending", "retireIssued", "wipePending", "wipeIssued", "deletePending",
|
||||
};
|
||||
|
||||
private readonly ILogger<IntuneDeviceBindingValidator> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly GraphTokenService _graphTokenService;
|
||||
private readonly IGraphFeatureDetector _graphFeatureDetector;
|
||||
|
||||
public IntuneDeviceBindingValidator(
|
||||
ILogger<IntuneDeviceBindingValidator> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache cache,
|
||||
GraphTokenService graphTokenService)
|
||||
GraphTokenService graphTokenService,
|
||||
IGraphFeatureDetector graphFeatureDetector)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_cache = cache;
|
||||
_graphTokenService = graphTokenService;
|
||||
_graphFeatureDetector = graphFeatureDetector;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the Intune device id (from the client certificate's Subject CN) against the
|
||||
/// tenant's managedDevices inventory. Never throws.
|
||||
/// tenant's managedDevices inventory. Runs regardless of whether the permission was
|
||||
/// detected: an admitting call must be able to pick up a freshly granted permission through
|
||||
/// the 401/403 token refresh (<see cref="GraphAuthFailure"/>). Never throws.
|
||||
/// </summary>
|
||||
public async Task<IntuneDeviceBindingResult> ValidateAsync(
|
||||
public virtual async Task<IntuneDeviceBindingResult> ValidateAsync(
|
||||
string tenantId,
|
||||
string? intuneDeviceId,
|
||||
string? sessionId = null)
|
||||
DateTimeOffset? certNotBefore,
|
||||
IntuneDeviceBindingRole role,
|
||||
string? sessionId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// The id becomes a URL path segment. The strict GUID gate keeps it from being anything
|
||||
// else - the same rule the rest of the security path applies.
|
||||
@@ -89,30 +135,119 @@ namespace AutopilotMonitor.Functions.Security
|
||||
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
var result = await TryValidateViaGraphAsync(tenantId, normalizedId, sessionId, cacheKey, attempt);
|
||||
var result = await TryValidateViaGraphAsync(tenantId, normalizedId, certNotBefore, cacheKey, attempt, ct);
|
||||
|
||||
if (!result.IsTransient)
|
||||
{
|
||||
LogLookup(result, tenantId, certNotBefore, role, sessionId);
|
||||
return result;
|
||||
}
|
||||
|
||||
lastTransient = result;
|
||||
if (attempt < maxAttempts)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Intune device binding transient failure for tenant {TenantId}, deviceId {IntuneDeviceId} (attempt {Attempt}/{MaxAttempts}). Retrying...",
|
||||
tenantId, normalizedId, attempt, maxAttempts);
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
// Budget spent (chain token cancelled): no second attempt on a request the agent
|
||||
// has abandoned - the transient result becomes the 503 Retry-After.
|
||||
if (attempt == maxAttempts || ct.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
await DelayBeforeRetryAsync(ct);
|
||||
}
|
||||
|
||||
LogLookup(lastTransient!, tenantId, certNotBefore, role, sessionId);
|
||||
return lastTransient!;
|
||||
}
|
||||
|
||||
private async Task<IntuneDeviceBindingResult> TryValidateViaGraphAsync(
|
||||
string tenantId, string normalizedId, string? sessionId, string cacheKey, int attempt)
|
||||
/// <summary>
|
||||
/// The cached result for this device, if a lookup already ran. Lets the caller stamp the
|
||||
/// observation on the request row without waiting for Graph.
|
||||
/// </summary>
|
||||
public virtual IntuneDeviceBindingResult? TryGetCached(string tenantId, string? intuneDeviceId)
|
||||
{
|
||||
if (!SecurityValidator.IsValidGuid(intuneDeviceId))
|
||||
return null;
|
||||
|
||||
return _cache.TryGetValue(BuildCacheKey(tenantId, intuneDeviceId!.Trim().ToLowerInvariant()), out IntuneDeviceBindingResult? cached)
|
||||
? cached?.AsCacheHit()
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observation for a device another validator admitted: fills the cache in the background so
|
||||
/// later requests can stamp the outcome, without adding Graph latency to an admitted request.
|
||||
/// Does nothing when the tenant has not granted DeviceManagementManagedDevices.Read.All
|
||||
/// (read from the cached app token, no Graph call), when the result is already cached, or
|
||||
/// when a lookup for the device is already running. Never throws.
|
||||
/// </summary>
|
||||
public virtual void ObserveInBackground(string tenantId, string? intuneDeviceId, DateTimeOffset? certNotBefore, string? sessionId)
|
||||
{
|
||||
if (!SecurityValidator.IsValidGuid(intuneDeviceId))
|
||||
return;
|
||||
|
||||
var normalizedId = intuneDeviceId!.Trim().ToLowerInvariant();
|
||||
var cacheKey = BuildCacheKey(tenantId, normalizedId);
|
||||
var permissionKey = BuildPermissionKey(tenantId);
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out _))
|
||||
return;
|
||||
if (_cache.TryGetValue(permissionKey, out bool granted) && !granted)
|
||||
return;
|
||||
|
||||
var inFlightKey = cacheKey + ":inflight";
|
||||
if (_cache.TryGetValue(inFlightKey, out _))
|
||||
return;
|
||||
_cache.Set(inFlightKey, true, InFlightTtl);
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var cooldown = false;
|
||||
try
|
||||
{
|
||||
using var budget = DeviceValidationBudget.CreateChainCts();
|
||||
|
||||
if (!_cache.TryGetValue(permissionKey, out bool hasPermission))
|
||||
{
|
||||
hasPermission = await _graphFeatureDetector.HasPermissionAsync(
|
||||
tenantId, GraphAppPermissions.DeviceManagementManagedDevicesReadAll, budget.Token);
|
||||
_cache.Set(permissionKey, hasPermission, PermissionVerdictTtl);
|
||||
}
|
||||
|
||||
if (hasPermission)
|
||||
{
|
||||
var result = await ValidateAsync(tenantId, normalizedId, certNotBefore, IntuneDeviceBindingRole.Observing, sessionId, budget.Token);
|
||||
cooldown = result.IsTransient;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
cooldown = true;
|
||||
_logger.LogWarning(ex, "AgentCertDeviceBinding observation failed for tenant {TenantId} - ignored.", tenantId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// A transient outcome caches nothing, so the in-flight marker doubles as the
|
||||
// retry cooldown; otherwise the cached result answers every later request.
|
||||
if (cooldown)
|
||||
_cache.Set(inFlightKey, true, TransientObservationCooldown);
|
||||
else
|
||||
_cache.Remove(inFlightKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Retry pause that yields early when the chain budget runs out (never throws).</summary>
|
||||
private static async Task DelayBeforeRetryAsync(CancellationToken ct)
|
||||
{
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(2), ct); }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
private async Task<IntuneDeviceBindingResult> TryValidateViaGraphAsync(
|
||||
string tenantId, string normalizedId, DateTimeOffset? certNotBefore, string cacheKey, int attempt, CancellationToken chain)
|
||||
{
|
||||
using var attemptCts = DeviceValidationBudget.CreateAttemptCts(chain);
|
||||
var ct = attemptCts.Token;
|
||||
try
|
||||
{
|
||||
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId);
|
||||
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId, ct);
|
||||
if (string.IsNullOrEmpty(tokenResult.AccessToken))
|
||||
{
|
||||
return new IntuneDeviceBindingResult
|
||||
@@ -128,16 +263,26 @@ namespace AutopilotMonitor.Functions.Security
|
||||
|
||||
// Point-GET: normalizedId passed the GUID gate, so the path segment is safe.
|
||||
var graphUrl = $"{Constants.GraphBaseUrl}/v1.0/deviceManagement/managedDevices/{normalizedId}"
|
||||
+ "?$select=id,deviceName,enrolledDateTime,azureADDeviceId,managementState";
|
||||
+ "?$select=id,deviceName,enrolledDateTime,azureADDeviceId,managementState,"
|
||||
+ "serialNumber,managedDeviceOwnerType,deviceEnrollmentType,azureADRegistered";
|
||||
|
||||
var response = await graphClient.GetAsync(graphUrl);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
var response = await graphClient.GetAsync(graphUrl, ct);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
// Definitive: this tenant has no managedDevice with that id. Either the device
|
||||
// belongs elsewhere, or its object has not appeared yet - the race this shadow
|
||||
// pass exists to measure.
|
||||
// A young certificate means the device may simply not be visible yet: say so and
|
||||
// cache nothing, so the next attempt sees the object as soon as it appears.
|
||||
if (IsRecentEnrollment(certNotBefore, DateTimeOffset.UtcNow))
|
||||
{
|
||||
return new IntuneDeviceBindingResult
|
||||
{
|
||||
Outcome = IntuneDeviceBindingOutcome.NotFoundRecentEnrollment,
|
||||
IntuneDeviceId = normalizedId,
|
||||
ErrorMessage = $"Device '{normalizedId}' is not visible in Intune yet (recent enrollment)"
|
||||
};
|
||||
}
|
||||
|
||||
return CacheAndReturn(cacheKey, new IntuneDeviceBindingResult
|
||||
{
|
||||
Outcome = IntuneDeviceBindingOutcome.NotFound,
|
||||
@@ -146,8 +291,8 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}, isPositive: false);
|
||||
}
|
||||
|
||||
// 401/403 on attempt 1: the cached token may predate the tenant's consent (see
|
||||
// GraphAuthFailure) — drop it and let the loop retry with a fresh one before the
|
||||
// 401/403 on attempt 1: the cached token may predate the tenant's grant (see
|
||||
// GraphAuthFailure) - drop it and let the loop retry with a fresh one before the
|
||||
// definitive "not granted" verdict below is cached against a stale token.
|
||||
if (GraphAuthFailure.TryRecoverStaleToken(_graphTokenService, _logger, nameof(IntuneDeviceBindingValidator), tenantId, response.StatusCode, attempt))
|
||||
{
|
||||
@@ -159,7 +304,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
};
|
||||
}
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||
if (GraphAuthFailure.IsAuthFailure(response.StatusCode))
|
||||
{
|
||||
GraphAuthFailure.LogPermissionMissing(_logger, nameof(IntuneDeviceBindingValidator), tenantId, response.StatusCode);
|
||||
// Configuration state, not an outage: retrying cannot fix a missing grant.
|
||||
@@ -189,12 +334,22 @@ namespace AutopilotMonitor.Functions.Security
|
||||
var result = ParseManagedDeviceResponse(responseBody, normalizedId);
|
||||
return CacheAndReturn(cacheKey, result, isPositive: result.IsValid);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Budget exhausted (per-attempt or chain) - transient, never cached.
|
||||
return new IntuneDeviceBindingResult
|
||||
{
|
||||
Outcome = IntuneDeviceBindingOutcome.Transient,
|
||||
IntuneDeviceId = normalizedId,
|
||||
ErrorMessage = "Managed device lookup timed out"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error during Intune device binding for tenant {TenantId}, session {SessionId}, deviceId {IntuneDeviceId} (attempt {Attempt})",
|
||||
tenantId, sessionId ?? "<none>", normalizedId, attempt);
|
||||
"Error during Intune device binding for tenant {TenantId}, deviceId {IntuneDeviceId} (attempt {Attempt})",
|
||||
tenantId, normalizedId, attempt);
|
||||
|
||||
return new IntuneDeviceBindingResult
|
||||
{
|
||||
@@ -205,10 +360,37 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One Warning line per real Graph lookup (never for cache hits: one enrollment produced 136
|
||||
/// requests but a single Graph call). Carries the two ages that answer the enrollment-race
|
||||
/// question: how old the device object and the certificate were at lookup time.
|
||||
/// </summary>
|
||||
private void LogLookup(IntuneDeviceBindingResult result, string tenantId, DateTimeOffset? certNotBefore, IntuneDeviceBindingRole role, string? sessionId)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var enrolledAgeSeconds = result.EnrolledDateTime.HasValue
|
||||
? (long)Math.Round((now - result.EnrolledDateTime.Value).TotalSeconds)
|
||||
: -1;
|
||||
var certAgeSeconds = certNotBefore.HasValue
|
||||
? (long)Math.Round((now - certNotBefore.Value).TotalSeconds)
|
||||
: -1;
|
||||
|
||||
_logger.LogWarning(
|
||||
"AgentCertDeviceBinding outcome={Outcome} role={Role} tenant={TenantId} "
|
||||
+ "certDeviceId={CertDeviceId} device={DeviceName} enrolledAgeSeconds={EnrolledAgeSeconds} "
|
||||
+ "certAgeSeconds={CertAgeSeconds} mgmtState={ManagementState} ownerType={OwnerType} "
|
||||
+ "enrollmentType={EnrollmentType} session={SessionId} detail={Detail}",
|
||||
result.Outcome, role, tenantId,
|
||||
result.IntuneDeviceId ?? "n/a", result.DeviceName ?? "n/a", enrolledAgeSeconds,
|
||||
certAgeSeconds, result.ManagementState ?? "n/a", result.OwnerType ?? "n/a",
|
||||
result.EnrollmentType ?? "n/a", sessionId ?? "n/a", result.ErrorMessage ?? "n/a");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure-function: maps a <c>managedDevices/{id}</c> Graph response body to a result.
|
||||
/// Re-checks the returned <c>id</c> so a redirected or widened lookup can never produce a
|
||||
/// false positive.
|
||||
/// false positive, and turns a device on its way out of management into
|
||||
/// <see cref="IntuneDeviceBindingOutcome.NotManaged"/>.
|
||||
/// </summary>
|
||||
internal static IntuneDeviceBindingResult ParseManagedDeviceResponse(string responseBody, string normalizedId)
|
||||
{
|
||||
@@ -236,17 +418,36 @@ namespace AutopilotMonitor.Functions.Security
|
||||
return notFound;
|
||||
}
|
||||
|
||||
var managementState = device?["managementState"]?.ToString();
|
||||
var leaving = managementState != null && LeavingManagementStates.Contains(managementState);
|
||||
|
||||
return new IntuneDeviceBindingResult
|
||||
{
|
||||
Outcome = IntuneDeviceBindingOutcome.Match,
|
||||
Outcome = leaving ? IntuneDeviceBindingOutcome.NotManaged : IntuneDeviceBindingOutcome.Match,
|
||||
IntuneDeviceId = normalizedId,
|
||||
DeviceName = device?["deviceName"]?.ToString(),
|
||||
AzureAdDeviceId = device?["azureADDeviceId"]?.ToString(),
|
||||
ManagementState = device?["managementState"]?.ToString(),
|
||||
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString())
|
||||
ManagementState = managementState,
|
||||
SerialNumber = NullIfEmpty(device?["serialNumber"]?.ToString()),
|
||||
OwnerType = NullIfEmpty(device?["managedDeviceOwnerType"]?.ToString()),
|
||||
EnrollmentType = NullIfEmpty(device?["deviceEnrollmentType"]?.ToString()),
|
||||
AzureAdRegistered = device?["azureADRegistered"]?.Type == JTokenType.Boolean
|
||||
? device["azureADRegistered"]!.Value<bool>()
|
||||
: null,
|
||||
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString()),
|
||||
ErrorMessage = leaving
|
||||
? $"Device '{normalizedId}' is being removed from Intune management (state {managementState})"
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the certificate was issued less than <see cref="RecentEnrollmentWindow"/> ago.
|
||||
/// An unknown NotBefore is never recent: without it there is no evidence of a race.
|
||||
/// </summary>
|
||||
internal static bool IsRecentEnrollment(DateTimeOffset? certNotBefore, DateTimeOffset now)
|
||||
=> certNotBefore.HasValue && now - certNotBefore.Value < RecentEnrollmentWindow;
|
||||
|
||||
/// <summary>
|
||||
/// Parses Graph's <c>enrolledDateTime</c>. Intune returns <c>0001-01-01T00:00:00Z</c> for
|
||||
/// devices it has no enrollment timestamp for; that is absence, not a date, so it maps to
|
||||
@@ -269,9 +470,14 @@ namespace AutopilotMonitor.Functions.Security
|
||||
return parsed.Year <= 1 ? null : parsed;
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
internal static string BuildCacheKey(string tenantId, string intuneDeviceId)
|
||||
=> $"intune-device-binding:{tenantId}:{intuneDeviceId}";
|
||||
|
||||
internal static string BuildPermissionKey(string tenantId)
|
||||
=> $"intune-device-binding-permission:{tenantId}";
|
||||
|
||||
private IntuneDeviceBindingResult CacheAndReturn(
|
||||
string cacheKey, IntuneDeviceBindingResult result, bool isPositive)
|
||||
{
|
||||
@@ -283,6 +489,13 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Why a lookup ran: to admit a device, or to observe one another validator admitted.</summary>
|
||||
public enum IntuneDeviceBindingRole
|
||||
{
|
||||
Admitting = 0,
|
||||
Observing = 1
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stable outcome codes for the device-binding check. Emitted verbatim in the
|
||||
/// <c>AgentCertDeviceBinding</c> log and as the <c>CertDeviceBinding</c> request dimension —
|
||||
@@ -290,10 +503,10 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// </summary>
|
||||
public enum IntuneDeviceBindingOutcome
|
||||
{
|
||||
/// <summary>Device id from the certificate resolves to a managedDevice in this tenant.</summary>
|
||||
/// <summary>Device id from the certificate resolves to a managed device in this tenant.</summary>
|
||||
Match = 0,
|
||||
|
||||
/// <summary>No managedDevice with that id in this tenant (foreign device, or not yet created).</summary>
|
||||
/// <summary>No managedDevice with that id in this tenant, and the certificate is not recent.</summary>
|
||||
NotFound = 1,
|
||||
|
||||
/// <summary>Certificate subject carried no usable Intune device id.</summary>
|
||||
@@ -302,8 +515,14 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// <summary>DeviceManagementManagedDevices.Read.All not granted in this tenant.</summary>
|
||||
PermissionMissing = 3,
|
||||
|
||||
/// <summary>Graph outage, token failure or exception — says nothing about the device.</summary>
|
||||
Transient = 4
|
||||
/// <summary>Graph outage, token failure, timeout or exception — says nothing about the device.</summary>
|
||||
Transient = 4,
|
||||
|
||||
/// <summary>The device exists but is being retired, wiped or deleted.</summary>
|
||||
NotManaged = 5,
|
||||
|
||||
/// <summary>No managedDevice yet, but the certificate is recent: the object may not be visible yet.</summary>
|
||||
NotFoundRecentEnrollment = 6
|
||||
}
|
||||
|
||||
public class IntuneDeviceBindingResult
|
||||
@@ -314,10 +533,11 @@ namespace AutopilotMonitor.Functions.Security
|
||||
public bool IsValid => Outcome == IntuneDeviceBindingOutcome.Match;
|
||||
|
||||
/// <summary>
|
||||
/// True when the failure says nothing about the device (Graph error, token issue).
|
||||
/// Transient results are never cached.
|
||||
/// True when the failure says nothing definitive about the device (Graph error, token
|
||||
/// issue, or a device object that may not be visible yet). Transient results are never cached.
|
||||
/// </summary>
|
||||
public bool IsTransient => Outcome == IntuneDeviceBindingOutcome.Transient;
|
||||
public bool IsTransient => Outcome == IntuneDeviceBindingOutcome.Transient
|
||||
|| Outcome == IntuneDeviceBindingOutcome.NotFoundRecentEnrollment;
|
||||
|
||||
/// <summary>Intune device id taken from the client certificate's Subject CN.</summary>
|
||||
public string? IntuneDeviceId { get; set; }
|
||||
@@ -328,9 +548,21 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// <summary>Entra device id of the same device, if Intune knows one — diagnostic only.</summary>
|
||||
public string? AzureAdDeviceId { get; set; }
|
||||
|
||||
/// <summary>Intune management state — diagnostic only.</summary>
|
||||
/// <summary>Intune management state; a leaving state turns a match into <see cref="IntuneDeviceBindingOutcome.NotManaged"/>.</summary>
|
||||
public string? ManagementState { get; set; }
|
||||
|
||||
/// <summary>Serial number Intune recorded for the device — compared against the agent's serial header.</summary>
|
||||
public string? SerialNumber { get; set; }
|
||||
|
||||
/// <summary>Intune ownership (<c>company</c>, <c>personal</c>, <c>unknown</c>) — telemetry only.</summary>
|
||||
public string? OwnerType { get; set; }
|
||||
|
||||
/// <summary>Intune deviceEnrollmentType (e.g. <c>windowsAzureADJoin</c>) — telemetry only.</summary>
|
||||
public string? EnrollmentType { get; set; }
|
||||
|
||||
/// <summary>True for a workplace-joined (Entra registered) device — telemetry only.</summary>
|
||||
public bool? AzureAdRegistered { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When Intune recorded the enrollment. The age of this value at request time is what
|
||||
/// tells a genuine race (object created seconds ago) apart from a foreign certificate.
|
||||
@@ -341,9 +573,6 @@ namespace AutopilotMonitor.Functions.Security
|
||||
|
||||
/// <summary>
|
||||
/// True when this result came from the in-memory cache rather than a fresh Graph lookup.
|
||||
/// Callers use it to log once per real lookup instead of once per request: a single
|
||||
/// enrollment produced 136 requests but only one Graph call, so logging per request said
|
||||
/// the same thing 136 times.
|
||||
/// </summary>
|
||||
public bool ServedFromCache { get; set; }
|
||||
|
||||
@@ -358,6 +587,10 @@ namespace AutopilotMonitor.Functions.Security
|
||||
DeviceName = DeviceName,
|
||||
AzureAdDeviceId = AzureAdDeviceId,
|
||||
ManagementState = ManagementState,
|
||||
SerialNumber = SerialNumber,
|
||||
OwnerType = OwnerType,
|
||||
EnrollmentType = EnrollmentType,
|
||||
AzureAdRegistered = AzureAdRegistered,
|
||||
EnrolledDateTime = EnrolledDateTime,
|
||||
ErrorMessage = ErrorMessage,
|
||||
ServedFromCache = true
|
||||
|
||||
@@ -41,6 +41,66 @@ namespace AutopilotMonitor.Functions.Security
|
||||
public static string DeviceValidationValue(ValidatorType validatedBy)
|
||||
=> validatedBy == ValidatorType.Unknown ? DeviceValidation.None : validatedBy.ToString();
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the Intune device lookup behind the client certificate
|
||||
/// (<see cref="IntuneDeviceBindingOutcome"/> name). Absent when no lookup result exists for
|
||||
/// the request: the tenant granted no permission, or an observation is still running.
|
||||
/// </summary>
|
||||
public const string CertDeviceBindingKey = "CertDeviceBinding";
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IntuneDeviceBindingRole"/> of that lookup: <c>Admitting</c> when it decided
|
||||
/// admission, <c>Observing</c> when another validator had already admitted the device.
|
||||
/// </summary>
|
||||
public const string CertDeviceBindingRoleKey = "CertDeviceBindingRole";
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> / <c>false</c>: whether the agent's serial header equals the serial Intune
|
||||
/// recorded for the certificate's device. Absent when either side is unknown. Observation
|
||||
/// only: nothing rejects on it.
|
||||
/// </summary>
|
||||
public const string CertDeviceSerialMatchKey = "CertDeviceSerialMatch";
|
||||
|
||||
/// <summary>Intune managedDeviceOwnerType of the certificate's device (company / personal / unknown).</summary>
|
||||
public const string CertDeviceOwnerTypeKey = "CertDeviceOwnerType";
|
||||
|
||||
/// <summary>Intune deviceEnrollmentType of the certificate's device.</summary>
|
||||
public const string CertDeviceEnrollmentTypeKey = "CertDeviceEnrollmentType";
|
||||
|
||||
/// <summary>Every cert-device key; <c>RequestTelemetryMiddleware</c> copies them onto the request row.</summary>
|
||||
public static readonly string[] CertDeviceBindingKeys =
|
||||
{
|
||||
CertDeviceBindingKey, CertDeviceBindingRoleKey, CertDeviceSerialMatchKey,
|
||||
CertDeviceOwnerTypeKey, CertDeviceEnrollmentTypeKey,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Stamps one Intune device lookup onto the request row. <paramref name="headerSerial"/> is
|
||||
/// the agent-supplied serial, compared case-insensitively against the one Intune recorded.
|
||||
/// </summary>
|
||||
public static void StampCertDeviceBinding(
|
||||
HttpRequestData req, IntuneDeviceBindingResult result, IntuneDeviceBindingRole role, string? headerSerial)
|
||||
{
|
||||
Stamp(req, CertDeviceBindingKey, result.Outcome.ToString());
|
||||
Stamp(req, CertDeviceBindingRoleKey, role.ToString());
|
||||
|
||||
var serialMatch = SerialMatch(headerSerial, result.SerialNumber);
|
||||
if (serialMatch.HasValue)
|
||||
Stamp(req, CertDeviceSerialMatchKey, serialMatch.Value ? "true" : "false");
|
||||
if (!string.IsNullOrEmpty(result.OwnerType))
|
||||
Stamp(req, CertDeviceOwnerTypeKey, result.OwnerType!);
|
||||
if (!string.IsNullOrEmpty(result.EnrollmentType))
|
||||
Stamp(req, CertDeviceEnrollmentTypeKey, result.EnrollmentType!);
|
||||
}
|
||||
|
||||
/// <summary>Null when either serial is unknown; otherwise a trimmed, case-insensitive comparison.</summary>
|
||||
internal static bool? SerialMatch(string? headerSerial, string? intuneSerial)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(headerSerial) || string.IsNullOrWhiteSpace(intuneSerial))
|
||||
return null;
|
||||
return string.Equals(headerSerial!.Trim(), intuneSerial!.Trim(), System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static void Stamp(HttpRequestData req, string key, string value)
|
||||
{
|
||||
var items = req.FunctionContext?.Items;
|
||||
|
||||
@@ -291,14 +291,14 @@ namespace AutopilotMonitor.Functions.Security
|
||||
// Security validation is always enforced (no longer configurable per tenant)
|
||||
// Hard gate: tenant must enable at least one device validation method before agent traffic is accepted.
|
||||
// Global Admins can set AllowInsecureAgentRequests=true in the config row for test tenants.
|
||||
if (!config.ValidateAutopilotDevice && !config.ValidateCorporateIdentifier && !config.ValidateDeviceAssociation && !config.ValidateCloudPcDevice && !config.AllowInsecureAgentRequests)
|
||||
if (!config.HasAnyDeviceValidation() && !config.AllowInsecureAgentRequests)
|
||||
{
|
||||
return new SecurityValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
StatusCode = HttpStatusCode.Forbidden,
|
||||
ErrorMessage = "Device validation is required",
|
||||
Details = "Enable 'Autopilot Device Validation', 'Corporate Identifier Validation', 'Device Association Validation' or 'Windows 365 Cloud PC Validation' in Configuration before using the agent ingestion endpoints."
|
||||
Details = "Enable at least one device validation method (Autopilot, Corporate Identifier, Device Association, Windows 365 Cloud PC or Intune Enrollment) in Configuration before using the agent ingestion endpoints."
|
||||
};
|
||||
}
|
||||
|
||||
@@ -413,6 +413,8 @@ namespace AutopilotMonitor.Functions.Security
|
||||
string? serialNumber = req.Headers.Contains("X-Device-SerialNumber")
|
||||
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
|
||||
: null;
|
||||
// TLS-proven device identity: the Intune managedDevice id in the certificate CN.
|
||||
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var certIntuneDeviceId);
|
||||
string? autopilotDeviceId = null;
|
||||
bool deviceValidated = false;
|
||||
bool deviceValidationTransient = false;
|
||||
@@ -494,8 +496,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
// ends in the 403 below.
|
||||
if (!deviceValidated && config.ValidateCloudPcDevice && _cloudPcDeviceValidator != null)
|
||||
{
|
||||
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var intuneDeviceId);
|
||||
var cloudPcResult = await _cloudPcDeviceValidator.ValidateCloudPcAsync(tenantId, intuneDeviceId, sessionId, validationCt);
|
||||
var cloudPcResult = await _cloudPcDeviceValidator.ValidateCloudPcAsync(tenantId, certIntuneDeviceId, sessionId, validationCt);
|
||||
if (cloudPcResult.IsValid)
|
||||
{
|
||||
deviceValidated = true;
|
||||
@@ -508,7 +509,44 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
}
|
||||
|
||||
if ((config.ValidateAutopilotDevice || config.ValidateCorporateIdentifier || config.ValidateDeviceAssociation || config.ValidateCloudPcDevice) && !deviceValidated)
|
||||
// Intune Enrollment Validation — the last accepting method and the only one that needs no
|
||||
// pre-registration: the certificate's Intune device id must resolve to a device this
|
||||
// tenant still manages. Same TLS-proven identity as the Cloud PC stage, so nothing here
|
||||
// depends on a header. Runs even when the permission was not detected, so a freshly
|
||||
// granted one is picked up through the 401/403 token refresh instead of after the
|
||||
// detector's token cache expires.
|
||||
if (!deviceValidated && config.ValidateIntuneDeviceBinding && _intuneDeviceBindingValidator != null)
|
||||
{
|
||||
var binding = await _intuneDeviceBindingValidator.ValidateAsync(
|
||||
tenantId, certIntuneDeviceId, certValidation.NotBefore, IntuneDeviceBindingRole.Admitting, sessionId, validationCt);
|
||||
RequestRowMarkers.StampCertDeviceBinding(req, binding, IntuneDeviceBindingRole.Admitting, serialNumber);
|
||||
|
||||
if (binding.IsValid)
|
||||
{
|
||||
deviceValidated = true;
|
||||
validatedBy = ValidatorType.IntuneEnrollment;
|
||||
}
|
||||
else
|
||||
{
|
||||
deviceValidationError = CombineValidationErrors(deviceValidationError, binding.ErrorMessage);
|
||||
// Transient covers a Graph outage and a device object that is not visible yet
|
||||
// (recent certificate). A missing grant is a configuration gap, not evidence
|
||||
// about the device, so it is answered like the missing core consent of the
|
||||
// Autopilot and Device Association lookups (503, 120 s) rather than as
|
||||
// "device not registered" (GraphAuthFailure).
|
||||
if (binding.IsTransient)
|
||||
{
|
||||
deviceValidationTransient = true;
|
||||
}
|
||||
else if (binding.Outcome == IntuneDeviceBindingOutcome.PermissionMissing)
|
||||
{
|
||||
deviceValidationTransient = true;
|
||||
deviceValidationRetryAfter = MaxRetryAfter(deviceValidationRetryAfter, GraphAuthFailure.PermissionMissingRetryAfterSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.HasAnyDeviceValidation() && !deviceValidated)
|
||||
{
|
||||
// Transient failures (Graph API down, token issues) → 503 Retry-After so agent retries
|
||||
// Definitive failures (device not registered) → 403 Forbidden
|
||||
@@ -542,74 +580,23 @@ namespace AutopilotMonitor.Functions.Security
|
||||
};
|
||||
}
|
||||
|
||||
// 5. CERT-DEVICE-BINDING-SHADOW - cert-to-device binding (Global-Admin-only preview).
|
||||
// Stage 1 (CertTenantBinding) proves the certificate was issued to THIS tenant; this
|
||||
// proves the specific device is one the tenant actually enrolled, so a certificate
|
||||
// lifted from a decommissioned machine stops resolving. Observation only: it never
|
||||
// touches deviceValidated or the returned result.
|
||||
//
|
||||
// Fire-and-forget: a cold Graph round-trip can take seconds and an observation-only
|
||||
// check must never sit in the agent's request path. That rules out the request-row
|
||||
// dimension stage 1 uses (the row is written the moment the function returns, before
|
||||
// this task finishes), so the outcome is logged at Warning instead (one line per real
|
||||
// Graph lookup, see ServedFromCache). Volume is bounded: only Global Admins can enable the
|
||||
// toggle. Widening it beyond preview should move to an inline call plus the
|
||||
// CertTenantBinding-style request dimension.
|
||||
if (config.ValidateIntuneDeviceBinding && _intuneDeviceBindingValidator != null)
|
||||
// 5. CERT-DEVICE-BINDING — observation for a device another validator admitted (or an
|
||||
// AllowInsecureAgentRequests tenant). Never admits, never rejects, and never waits for
|
||||
// Graph: the request row gets the cached outcome, and a cache miss starts a background
|
||||
// lookup — only when the tenant granted DeviceManagementManagedDevices.Read.All.
|
||||
if (validatedBy != ValidatorType.IntuneEnrollment && _intuneDeviceBindingValidator != null)
|
||||
{
|
||||
var bindingValidator = _intuneDeviceBindingValidator;
|
||||
var bindingTenant = tenantId;
|
||||
var bindingSession = sessionId;
|
||||
var bindingSubject = certValidation.Subject;
|
||||
var bindingThumbprint = certValidation.Thumbprint;
|
||||
var bindingLogger = _logger;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
TryGetIntuneDeviceIdFromCertSubject(bindingSubject, out var certDeviceId);
|
||||
var binding = await bindingValidator.ValidateAsync(bindingTenant, certDeviceId, bindingSession);
|
||||
|
||||
// One line per real Graph lookup, not per request. Measured on the first
|
||||
// live enrollment: 136 requests produced exactly ONE Graph call (the 30 min
|
||||
// positive cache did its job), so per-request logging repeated the same
|
||||
// finding 136 times. Cached repeats add nothing - the age just counts up
|
||||
// mechanically from the same enrolledDateTime. Uncached outcomes
|
||||
// (Transient, NoDeviceIdInCert) still log every time, which is what we want
|
||||
// for anomalies.
|
||||
if (binding.ServedFromCache)
|
||||
return;
|
||||
|
||||
// Age of the device object at request time: the discriminator between a
|
||||
// genuine enrollment race (object created moments ago, or not yet) and a
|
||||
// certificate that never belonged to this tenant.
|
||||
var enrolledAgeSeconds = binding.EnrolledDateTime.HasValue
|
||||
? (long)Math.Round((DateTimeOffset.UtcNow - binding.EnrolledDateTime.Value).TotalSeconds)
|
||||
: -1;
|
||||
|
||||
bindingLogger.LogWarning(
|
||||
"AgentCertDeviceBinding outcome={Outcome} enforced={Enforced} tenant={TenantId} "
|
||||
+ "certDeviceId={CertDeviceId} device={DeviceName} enrolledAgeSeconds={EnrolledAgeSeconds} "
|
||||
+ "mgmtState={ManagementState} thumbprint={Thumbprint} session={SessionId} detail={Detail}",
|
||||
binding.Outcome, false, bindingTenant,
|
||||
certDeviceId ?? "n/a", binding.DeviceName ?? "n/a", enrolledAgeSeconds,
|
||||
binding.ManagementState ?? "n/a", bindingThumbprint ?? "n/a",
|
||||
bindingSession ?? "n/a", binding.ErrorMessage ?? "n/a");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
bindingLogger.LogWarning(ex,
|
||||
"AgentCertDeviceBinding (shadow) failed for tenant {TenantId} - ignored.", bindingTenant);
|
||||
}
|
||||
});
|
||||
var observed = _intuneDeviceBindingValidator.TryGetCached(tenantId, certIntuneDeviceId);
|
||||
if (observed != null)
|
||||
RequestRowMarkers.StampCertDeviceBinding(req, observed, IntuneDeviceBindingRole.Observing, serialNumber);
|
||||
else
|
||||
_intuneDeviceBindingValidator.ObserveInBackground(tenantId, certIntuneDeviceId, certValidation.NotBefore, sessionId);
|
||||
}
|
||||
|
||||
// All checks passed. The admitting validator rides on the request row: the Sessions
|
||||
// row keeps ValidatedBy per session, this is the per-request counter and timestamp.
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.DeviceValidationKey,
|
||||
RequestRowMarkers.DeviceValidationValue(validatedBy));
|
||||
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var certIntuneDeviceId);
|
||||
return new SecurityValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
@@ -626,7 +613,8 @@ namespace AutopilotMonitor.Functions.Security
|
||||
|
||||
/// <summary>
|
||||
/// Joins the per-validator failure messages so the 403 Details / rejection log carries
|
||||
/// every miss in chain order (Autopilot | CorporateIdentifier | CloudPc), not just the
|
||||
/// every miss in chain order (Autopilot | CorporateIdentifier | DeviceAssociation | CloudPc |
|
||||
/// IntuneEnrollment), not just the
|
||||
/// last validator's.
|
||||
/// </summary>
|
||||
internal static string? CombineValidationErrors(string? accumulated, string? next)
|
||||
|
||||
@@ -59,4 +59,18 @@ public class TenantMemberRoleResolver
|
||||
var (config, _) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
return EntraAppRoleResolver.Resolve(state, tableRole, appRoles, config.EntraAppRolesEnabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ResolveAsync(string, string, IReadOnlyList{string}?)"/> with the application cap applied
|
||||
/// when <paramref name="clientCapped"/>: a person reached through an admitted foreign client keeps
|
||||
/// their membership but never acts above Viewer and never manages bootstrap tokens.
|
||||
/// </summary>
|
||||
public async Task<MemberRoleInfo?> ResolveAsync(
|
||||
string tenantId, string upn, IReadOnlyList<string>? appRoles, bool clientCapped)
|
||||
{
|
||||
var role = await ResolveAsync(tenantId, upn, appRoles);
|
||||
return clientCapped && role != null
|
||||
? new MemberRoleInfo { Role = Constants.TenantRoles.Viewer, CanManageBootstrapTokens = false }
|
||||
: role;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,8 +78,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
try
|
||||
{
|
||||
// Save the default configuration via repository
|
||||
await _configRepo.SaveAdminConfigurationAsync(defaultConfig);
|
||||
// Create-only: a concurrent first write (or a row this read missed) is never replaced.
|
||||
await _configRepo.CreateAdminConfigurationIfMissingAsync(defaultConfig);
|
||||
|
||||
_cache.Set(CacheKey, defaultConfig, CacheDuration);
|
||||
|
||||
@@ -191,38 +191,24 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves global admin configuration and syncs rate limit to all tenant configurations
|
||||
/// Changes the admin configuration through the repository's changed-columns write (D-285):
|
||||
/// fresh read, <paramref name="mutate"/> on a copy, only the columns it changed are written.
|
||||
/// Never pass a configuration object around to save it — a cached or page-loaded copy is
|
||||
/// exactly what used to revert other writers' changes. Invalidates this instance's cache
|
||||
/// after a write; other instances catch up within the 5-minute TTL.
|
||||
/// </summary>
|
||||
public async Task SaveConfigurationAsync(AdminConfiguration config)
|
||||
public virtual async Task<AdminConfigurationUpdateResult> UpdateAsync(
|
||||
Func<AdminConfiguration, string?> mutate, string updatedBy, string? source = null)
|
||||
{
|
||||
if (config == null)
|
||||
var result = await _configRepo.UpdateAdminConfigurationAsync(mutate, updatedBy, source);
|
||||
if (result.Error == null && result.ChangedColumns.Count > 0)
|
||||
{
|
||||
throw new ArgumentException("Configuration is required");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
config.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
await _configRepo.SaveAdminConfigurationAsync(config);
|
||||
|
||||
// Invalidate cache
|
||||
_cache.Remove(CacheKey);
|
||||
|
||||
_logger.LogInformation($"Admin configuration saved by {config.UpdatedBy}");
|
||||
|
||||
// NOTE: Rate limits are no longer mirrored into every tenant row. The effective
|
||||
// per-tenant limit is computed at read time as `tenantOverride ?? global`
|
||||
// (see SecurityValidator for the device path and UserRateLimitMiddleware for the
|
||||
// user path). This removes the former background sync job — and the clobbering
|
||||
// foot-gun where a per-tenant edit to the base field was overwritten on the next
|
||||
// global save.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving admin configuration");
|
||||
throw;
|
||||
_logger.LogInformation(
|
||||
"Admin configuration changed by {UpdatedBy}: {Columns}",
|
||||
updatedBy, string.Join(", ", result.ChangedColumns));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -85,7 +85,7 @@ public class DelegatedAdminService
|
||||
// An application principal reads managed tenants, it never administers them: whatever role its rows
|
||||
// carry (a GA grant on global/delegated-admins could say DelegatedAdmin), the scope it presents is
|
||||
// capped to DelegatedReader. Enforced at resolution so no grant path has to remember the rule.
|
||||
return identity.IsApplication ? gated.AsReaderOnly() : gated;
|
||||
return identity.IsCapped ? gated.AsReaderOnly() : gated;
|
||||
}
|
||||
|
||||
/// <summary>The scope the assignment ROWS (direct + group-derived) confer on a UPN, ignoring identity binding
|
||||
|
||||
@@ -47,6 +47,11 @@ public class DelegationSelfService
|
||||
{
|
||||
public const string SourceSelfService = "self-service";
|
||||
public const string SourceOperator = "operator";
|
||||
/// <summary>
|
||||
/// The one label a managed tenant sees for every operator-provisioned access (operator-created groups and
|
||||
/// direct grants alike). Operator groups are internal organisation — their names never reach a customer.
|
||||
/// </summary>
|
||||
public const string OperatorLabel = "Platform support";
|
||||
private const string AuditGroupAccess = "DelegatedGroupAccess";
|
||||
private const string AuditInvitation = "DelegationInvitation";
|
||||
private const string AuditManagedTenant = "DelegationManagedTenant";
|
||||
@@ -377,8 +382,11 @@ public class DelegationSelfService
|
||||
.Select(r => r.AcceptedAt)
|
||||
.Max();
|
||||
}
|
||||
result.Add(new TenantManagerView(group.GroupId, owner, owner != null ? await DomainAsync(owner) : null,
|
||||
group.Name, owner != null ? SourceSelfService : SourceOperator, group.Assignees, since, Revocable: owner != null));
|
||||
// An owned group carries its managing tenant's name; an operator group is internal and shows
|
||||
// only the neutral label (its assignees stay visible — the customer must know who can read it).
|
||||
result.Add(owner != null
|
||||
? new TenantManagerView(group.GroupId, owner, await DomainAsync(owner), group.Name, SourceSelfService, group.Assignees, since, Revocable: true)
|
||||
: new TenantManagerView(group.GroupId, null, null, OperatorLabel, SourceOperator, group.Assignees, null, Revocable: false));
|
||||
}
|
||||
|
||||
var direct = (await _adminRepo.GetDelegatedAssigneesAsync(target))
|
||||
@@ -386,7 +394,7 @@ public class DelegationSelfService
|
||||
.Select(r => new TenantGroupAssignment { Upn = r.Upn, GroupId = string.Empty, Role = r.Role, IsEnabled = r.IsEnabled, AssignedBy = r.GrantedBy, AssignedAt = r.GrantedAt })
|
||||
.ToList();
|
||||
if (direct.Count > 0)
|
||||
result.Add(new TenantManagerView(null, null, null, "Platform operators", SourceOperator, direct, direct.Min(d => d.AssignedAt), Revocable: false));
|
||||
result.Add(new TenantManagerView(null, null, null, OperatorLabel, SourceOperator, direct, direct.Min(d => d.AssignedAt), Revocable: false));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -94,18 +94,21 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
|
||||
|
||||
/// <summary>
|
||||
/// Sends the post-offboarding "sorry to see you go" farewell email.
|
||||
/// No-op if the API key or recipient email is not configured. Best-effort: failures
|
||||
/// are logged as warnings and never propagated (the offboarding correctness contract
|
||||
/// does not depend on email delivery).
|
||||
/// No-op (returns false) if the API key or recipient email is not configured. Best-effort:
|
||||
/// failures are logged as warnings and never propagated (the offboarding correctness
|
||||
/// contract does not depend on email delivery); the caller turns the result into the
|
||||
/// FarewellEmailSent / FarewellEmailFailed ops events.
|
||||
/// </summary>
|
||||
public async Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
|
||||
public async Task<bool> SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_apiKey))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
// Warning, not Debug: an unconfigured provider is operator misconfiguration, and worker
|
||||
// logs below Warning never reach Application Insights.
|
||||
_logger.LogWarning(
|
||||
"{ConfigKey} not configured — skipping offboard farewell email for tenant {TenantId}",
|
||||
ApiKeyConfigKey, tenantId);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(toEmail))
|
||||
@@ -113,7 +116,7 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
|
||||
_logger.LogDebug(
|
||||
"No notification email captured — skipping offboard farewell email for tenant {TenantId} ({Domain})",
|
||||
tenantId, domainName);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var sent = await SendViaMandrillAsync(
|
||||
@@ -129,6 +132,8 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
|
||||
"Offboard farewell email sent to {ToEmail} for tenant {TenantId} ({Domain})",
|
||||
toEmail, tenantId, domainName);
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -75,8 +75,8 @@ public class GlobalAdminService
|
||||
|
||||
// An application principal never holds a platform role (D-133): a GlobalAdmins row keyed
|
||||
// app:<client-id> is inert even if one were written. Tenant membership and delegation are the only
|
||||
// grant paths for a service.
|
||||
if (identity.IsApplication)
|
||||
// grant paths for a service. A person reached through a foreign client app is capped the same way.
|
||||
if (identity.IsCapped)
|
||||
return null;
|
||||
|
||||
var role = await GetRowRoleAsync(identity.Upn);
|
||||
|
||||
@@ -7,9 +7,13 @@ namespace AutopilotMonitor.Functions.Services;
|
||||
/// Invocation point: <c>TenantOffboardingHandler.RunPostDrainPhasesAsync</c> immediately
|
||||
/// after the History terminal write (Side-effect 6). Always fail-soft — implementations
|
||||
/// must NOT throw; the offboarding correctness contract does not depend on email delivery.
|
||||
/// The return value is what separates "sent" from "provider refused / not configured" for the
|
||||
/// <c>FarewellEmailSent</c> / <c>FarewellEmailFailed</c> ops events the handler records.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IOffboardFarewellEmailSender
|
||||
{
|
||||
Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default);
|
||||
/// <returns>True when the provider accepted the message; false when the send was skipped
|
||||
/// (provider not configured, empty recipient) or the provider refused it.</returns>
|
||||
Task<bool> SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Self-hosted MCP client registrations (see <see cref="IMcpClientRegistrationRepository"/>): validation,
|
||||
/// the per-tenant cap, the operator switch and the audit trail. The MCP server's OAuth proxy resolves a
|
||||
/// client id <c>amc_<id></c> through <see cref="LookupAsync"/> and binds the flow to the registering tenant.
|
||||
/// </summary>
|
||||
public class McpClientRegistrationService
|
||||
{
|
||||
/// <summary>Registrations a tenant may hold unless a Global Admin raised its limit on request.</summary>
|
||||
public const int DefaultRegistrationLimit = 1;
|
||||
/// <summary>Upper bound of the per-tenant override (<c>TenantConfiguration.McpClientRegistrationLimit</c>).</summary>
|
||||
public const int MaxRegistrationLimit = 10;
|
||||
public const int MaxNameLength = 64;
|
||||
/// <summary>Mirrors the MCP proxy's redirect_uri length limit (oauth-limits.ts).</summary>
|
||||
public const int MaxRedirectUriLength = 1024;
|
||||
public const string ClientIdPrefix = "amc_";
|
||||
private const string AuditEntity = "McpClientRegistration";
|
||||
|
||||
private static readonly Regex RegistrationIdPattern = new("^[0-9a-f]{32}$", RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly IMcpClientRegistrationRepository _repo;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private readonly ILogger<McpClientRegistrationService> _logger;
|
||||
|
||||
public McpClientRegistrationService(
|
||||
IMcpClientRegistrationRepository repo,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TenantConfigurationService tenantConfigService,
|
||||
ILogger<McpClientRegistrationService> logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_adminConfigService = adminConfigService;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>The tenant's registration limit: its Global-Admin override, else the default of one.</summary>
|
||||
public virtual async Task<int> GetLimitAsync(string tenantId)
|
||||
{
|
||||
var (config, _) = await _tenantConfigService.TryGetConfigurationAsync(tenantId.ToLowerInvariant());
|
||||
return config.McpClientRegistrationLimit is int limit && limit >= 1 ? Math.Min(limit, MaxRegistrationLimit) : DefaultRegistrationLimit;
|
||||
}
|
||||
|
||||
/// <summary>The operator switch (5-minute cached admin configuration).</summary>
|
||||
public virtual async Task<bool> IsEnabledAsync()
|
||||
=> (await _adminConfigService.GetConfigurationAsync()).McpClientRegistrationEnabled;
|
||||
|
||||
public static string ClientIdOf(string registrationId) => ClientIdPrefix + registrationId;
|
||||
|
||||
/// <summary>Null when the name is acceptable, else the reason.</summary>
|
||||
public static string? ValidateName(string? name)
|
||||
{
|
||||
var trimmed = name?.Trim() ?? string.Empty;
|
||||
if (trimmed.Length == 0) return "A name is required.";
|
||||
if (trimmed.Length > MaxNameLength) return $"The name may have at most {MaxNameLength} characters.";
|
||||
if (trimmed.Any(char.IsControl)) return "The name contains control characters.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null when the callback is acceptable, else the reason: an absolute https URL, or http on a loopback host,
|
||||
/// with no query, fragment, user info or wildcard — the MCP proxy redirects authorization codes to it.
|
||||
/// </summary>
|
||||
public static string? ValidateRedirectUri(string? redirectUri)
|
||||
{
|
||||
var value = redirectUri?.Trim() ?? string.Empty;
|
||||
if (value.Length == 0) return "A callback URL is required.";
|
||||
if (value.Length > MaxRedirectUriLength) return $"The callback URL may have at most {MaxRedirectUriLength} characters.";
|
||||
if (value.Contains('*')) return "The callback URL must be exact; wildcards are not allowed.";
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) return "The callback URL is not a valid absolute URL.";
|
||||
var loopback = uri.Host is "localhost" or "127.0.0.1" or "[::1]";
|
||||
if (uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && loopback))
|
||||
return "The callback URL must use https (http is allowed only for localhost).";
|
||||
if (!string.IsNullOrEmpty(uri.Query)) return "The callback URL must not contain a query.";
|
||||
if (!string.IsNullOrEmpty(uri.Fragment) || value.Contains('#')) return "The callback URL must not contain a fragment.";
|
||||
if (!string.IsNullOrEmpty(uri.UserInfo)) return "The callback URL must not contain user information.";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool IsRegistrationId(string? registrationId)
|
||||
=> registrationId != null && RegistrationIdPattern.IsMatch(registrationId);
|
||||
|
||||
public virtual async Task<List<McpClientRegistration>> ListAsync(string tenantId)
|
||||
=> (await _repo.GetForTenantAsync(tenantId)).OrderBy(r => r.CreatedAt).ToList();
|
||||
|
||||
/// <summary>Creates a registration for <paramref name="tenantId"/>; the result names the HTTP status to answer.</summary>
|
||||
public virtual async Task<McpClientRegistrationResult> CreateAsync(string tenantId, string? name, string? redirectUri, string actor)
|
||||
{
|
||||
if (!await IsEnabledAsync())
|
||||
return McpClientRegistrationResult.Fail(HttpStatusCode.Forbidden, "Self-hosted MCP client registration is not enabled on this platform.");
|
||||
|
||||
var nameError = ValidateName(name);
|
||||
if (nameError != null) return McpClientRegistrationResult.Fail(HttpStatusCode.BadRequest, nameError);
|
||||
var uriError = ValidateRedirectUri(redirectUri);
|
||||
if (uriError != null) return McpClientRegistrationResult.Fail(HttpStatusCode.BadRequest, uriError);
|
||||
|
||||
var tenant = tenantId.ToLowerInvariant();
|
||||
var callback = redirectUri!.Trim();
|
||||
var existing = await _repo.GetForTenantAsync(tenant);
|
||||
var limit = await GetLimitAsync(tenant);
|
||||
if (existing.Count >= limit)
|
||||
return McpClientRegistrationResult.Fail(HttpStatusCode.Conflict,
|
||||
$"This tenant can register {limit} self-hosted AI client{(limit == 1 ? "" : "s")}. Delete one first, or ask us to raise the limit.");
|
||||
if (existing.Any(r => string.Equals(r.RedirectUri, callback, StringComparison.OrdinalIgnoreCase)))
|
||||
return McpClientRegistrationResult.Fail(HttpStatusCode.Conflict, "This callback URL is already registered.");
|
||||
|
||||
var registration = new McpClientRegistration
|
||||
{
|
||||
RegistrationId = Guid.NewGuid().ToString("N"),
|
||||
TenantId = tenant,
|
||||
Name = name!.Trim(),
|
||||
RedirectUri = callback,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
if (!await _repo.CreateAsync(registration))
|
||||
return McpClientRegistrationResult.Fail(HttpStatusCode.InternalServerError, "The registration could not be stored.");
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(tenant, "CREATE", AuditEntity, registration.RegistrationId, actor,
|
||||
new Dictionary<string, string> { { "Name", registration.Name }, { "RedirectUri", registration.RedirectUri } });
|
||||
_logger.LogWarning("MCP client registration {RegistrationId} created for tenant {TenantId} by {Actor}",
|
||||
registration.RegistrationId, tenant, actor);
|
||||
return McpClientRegistrationResult.Ok(registration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes one of <paramref name="tenantId"/>'s registrations — also while the switch is off, so a tenant can
|
||||
/// always clean up. False when the id is unknown or belongs to another tenant.
|
||||
/// </summary>
|
||||
public virtual async Task<bool> DeleteAsync(string tenantId, string registrationId, string actor)
|
||||
{
|
||||
if (!IsRegistrationId(registrationId)) return false;
|
||||
var registration = await _repo.GetAsync(registrationId);
|
||||
if (registration == null || !string.Equals(registration.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
if (!await _repo.DeleteAsync(registrationId)) return false;
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(registration.TenantId, "DELETE", AuditEntity, registrationId, actor,
|
||||
new Dictionary<string, string> { { "Name", registration.Name }, { "RedirectUri", registration.RedirectUri } });
|
||||
_logger.LogWarning("MCP client registration {RegistrationId} deleted for tenant {TenantId} by {Actor}",
|
||||
registrationId, registration.TenantId, actor);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The MCP proxy's lookup: the registration, or null when unknown or while the switch is off.</summary>
|
||||
public virtual async Task<McpClientRegistration?> LookupAsync(string registrationId)
|
||||
{
|
||||
if (!IsRegistrationId(registrationId)) return null;
|
||||
if (!await IsEnabledAsync()) return null;
|
||||
return await _repo.GetAsync(registrationId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Outcome of <see cref="McpClientRegistrationService.CreateAsync"/>.</summary>
|
||||
public sealed record McpClientRegistrationResult(McpClientRegistration? Registration, HttpStatusCode Status, string? Error)
|
||||
{
|
||||
public static McpClientRegistrationResult Ok(McpClientRegistration registration) => new(registration, HttpStatusCode.Created, null);
|
||||
public static McpClientRegistrationResult Fail(HttpStatusCode status, string error) => new(null, status, error);
|
||||
}
|
||||
@@ -87,7 +87,8 @@ public class McpUserService
|
||||
/// <paramref name="appRoles"/> claim — a null tenant id is no member of anything.
|
||||
/// </summary>
|
||||
public virtual async Task<McpAccessCheckResult> IsAllowedAsync(
|
||||
string? upn, string? homeTenantId, string? objectId, IReadOnlyList<string>? appRoles = null)
|
||||
string? upn, string? homeTenantId, string? objectId, IReadOnlyList<string>? appRoles = null,
|
||||
bool clientCapped = false)
|
||||
{
|
||||
// The principal key: a person's UPN, or app:<client-id> for an application principal. A token
|
||||
// that yields neither carries no identity we could grant anything to.
|
||||
@@ -95,7 +96,7 @@ public class McpUserService
|
||||
return McpAccessCheckResult.Denied("Missing principal identity (no upn / preferred_username, and not an app-only token)");
|
||||
|
||||
upn = upn.ToLowerInvariant();
|
||||
var identity = AdminIdentity.Create(upn, homeTenantId, objectId);
|
||||
var identity = AdminIdentity.Create(upn, homeTenantId, objectId, clientCapped);
|
||||
|
||||
// Always resolve the platform role — needed by the MCP server for cross-tenant routing
|
||||
// decisions (global scope → /api/global/* with tenantId-as-filter; non-global → /api/* JWT-bound).
|
||||
@@ -184,7 +185,7 @@ public class McpUserService
|
||||
// end-users), resolves to no role and is denied here instead of failing on every tool call.
|
||||
var memberRole = string.IsNullOrWhiteSpace(homeTenantId)
|
||||
? null
|
||||
: await _memberRoleResolver.ResolveAsync(homeTenantId, upn, appRoles);
|
||||
: await _memberRoleResolver.ResolveAsync(homeTenantId, upn, appRoles, clientCapped);
|
||||
if (memberRole != null)
|
||||
return McpAccessCheckResult.Allowed(upn, "AllMembers", false, null, delegatedTenantIds, delegatedRole);
|
||||
return McpAccessCheckResult.Denied(Constants.PrincipalKeys.IsApplication(upn)
|
||||
|
||||
+48
-16
@@ -142,6 +142,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
(Constants.TableNames.BootstrapSessions, "CodeLookup"),
|
||||
(Constants.TableNames.SessionReports, "reports"),
|
||||
(Constants.TableNames.RuleSubmissions, "submissions"),
|
||||
(Constants.TableNames.McpClientRegistrations, "registrations"),
|
||||
};
|
||||
|
||||
// Plan §6.3.2 — Variant C (TenantId property only).
|
||||
@@ -774,22 +775,10 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
// Side-effect 6: post-completion farewell email to the captured Preview-Notification-
|
||||
// Email. Fail-soft: any sender exception MUST NOT propagate, the offboarding
|
||||
// correctness contract is independent of email delivery and the History row is
|
||||
// already Completed. Skips silently when no email was captured at Phase 1
|
||||
// (tenant never set a preview notification address).
|
||||
if (!string.IsNullOrWhiteSpace(history.NotificationEmail))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _farewellEmail.SendAsync(
|
||||
history.NotificationEmail!, history.DomainName ?? string.Empty, tenantId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Offboard farewell email send threw for tenant={Tenant} — ignored (best-effort)",
|
||||
tenantId);
|
||||
}
|
||||
}
|
||||
// already Completed. Every outcome leaves an ops event (Sent / Skipped / Failed):
|
||||
// the send's own success log is Information and never reaches Application
|
||||
// Insights from the worker, which made a lost farewell mail invisible.
|
||||
await SendFarewellEmailAsync(history, tenantId, ct);
|
||||
|
||||
// ── 2.F-final — TenantConfiguration delete (PR3.B Codex Finding 2 reorder) ──
|
||||
//
|
||||
@@ -831,6 +820,49 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Side-effect 6 — the farewell mail plus its ops-event record. Never throws: the History
|
||||
/// row is already Completed and the TenantConfiguration delete that follows must run.
|
||||
/// Outcomes: no address captured at Phase 1 → <c>FarewellEmailSkipped</c> (Warning: the
|
||||
/// customer left in silence, nothing is broken); provider accepted → <c>FarewellEmailSent</c>
|
||||
/// (Info); provider refused, provider not configured or the send threw →
|
||||
/// <c>FarewellEmailFailed</c> (Error: an address was there and the mail did not go out).
|
||||
/// </summary>
|
||||
private async Task SendFarewellEmailAsync(OffboardingHistoryEntry history, string tenantId, CancellationToken ct)
|
||||
{
|
||||
var domainName = history.DomainName ?? string.Empty;
|
||||
var toEmail = history.NotificationEmail;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(toEmail))
|
||||
{
|
||||
await _opsEvents.RecordFarewellEmailSkippedAsync(tenantId, domainName,
|
||||
"no contact address was set before the offboarding");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sent = await _farewellEmail.SendAsync(toEmail!, domainName, tenantId, ct);
|
||||
if (sent)
|
||||
{
|
||||
await _opsEvents.RecordFarewellEmailSentAsync(tenantId, domainName, toEmail!);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _opsEvents.RecordFarewellEmailFailedAsync(tenantId, domainName, toEmail!,
|
||||
"the email provider did not accept the message (or is not configured)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Offboard farewell email send threw for tenant={Tenant} — ignored (best-effort)",
|
||||
tenantId);
|
||||
await _opsEvents.RecordFarewellEmailFailedAsync(tenantId, domainName, toEmail!,
|
||||
$"{ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR3.B §3 — Phase 2.D-archive. For each rules table, archive every row to
|
||||
/// <see cref="ITenantCustomsArchiveRepository"/> THEN delete that exact (PK, RK)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user