mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71832b9deb | |||
| ce4d884abc | |||
| b56c774103 | |||
| 68767f436f | |||
| c7b06be8b7 | |||
| 479247cdae | |||
| b6ba87f072 | |||
| fa640ebe61 | |||
| dbc20a9f66 | |||
| cbda62d131 | |||
| 3951000b40 | |||
| c59842262e | |||
| c3e9621a78 | |||
| 3ed21c37d1 | |||
| 4a1194569e | |||
| d0794fc7c5 | |||
| f34c9d2322 | |||
| 380a8417c8 | |||
| 63826a4869 | |||
| 7df208fac9 |
@@ -0,0 +1,31 @@
|
||||
# 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:
|
||||
#
|
||||
# cs/exposure-of-sensitive-information
|
||||
# Audit and operational logs record WHO performed an action (actor UPN / email)
|
||||
# and notification services deliver mail to a recipient address. Writing that
|
||||
# identity to the log / notification IS the purpose of the field. Every such site
|
||||
# is a legitimate, reviewed use — the query cannot distinguish it from a leak.
|
||||
#
|
||||
# cs/cleartext-storage-of-sensitive-information
|
||||
# The rate limiters and distress guard use the trusted client IP as their bucket
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
# catching a future unsanitized sink.
|
||||
|
||||
query-filters:
|
||||
- exclude:
|
||||
id: cs/exposure-of-sensitive-information
|
||||
- exclude:
|
||||
id: cs/cleartext-storage-of-sensitive-information
|
||||
@@ -20,6 +20,11 @@ updates:
|
||||
web:
|
||||
patterns: ['*']
|
||||
update-types: [minor, patch]
|
||||
# Major bumps are breaking and reviewed deliberately, not auto-PR'd.
|
||||
# Security updates ignore this filter, so the Security tab stays clean.
|
||||
ignore:
|
||||
- dependency-name: '*'
|
||||
update-types: ['version-update:semver-major']
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/McpServer/autopilot-monitor-mcp
|
||||
@@ -30,6 +35,11 @@ updates:
|
||||
mcp:
|
||||
patterns: ['*']
|
||||
update-types: [minor, patch]
|
||||
# Major bumps are breaking and reviewed deliberately, not auto-PR'd.
|
||||
# Security updates ignore this filter, so the Security tab stays clean.
|
||||
ignore:
|
||||
- dependency-name: '*'
|
||||
update-types: ['version-update:semver-major']
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
|
||||
@@ -56,11 +56,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# SDK 10 = single build toolchain (parity with local build.ps1 / global.json).
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -352,13 +352,13 @@ jobs:
|
||||
subject-path: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}
|
||||
path: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
|
||||
- name: Upload version metadata
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: version-metadata
|
||||
path: ${{ env.MANIFEST_NAME }}
|
||||
|
||||
@@ -24,9 +24,9 @@ jobs:
|
||||
# windows-latest is required: the agent test projects target net48.
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
- uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -55,9 +55,9 @@ jobs:
|
||||
run:
|
||||
working-directory: src/Web/autopilot-monitor-web
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
@@ -78,9 +78,9 @@ jobs:
|
||||
run:
|
||||
working-directory: src/McpServer/autopilot-monitor-mcp
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
@@ -98,9 +98,9 @@ jobs:
|
||||
name: Generated guardrails match rules/guardrails.json
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
|
||||
@@ -35,24 +35,25 @@ jobs:
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
- uses: actions/setup-dotnet@v6
|
||||
if: matrix.language == 'csharp'
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
config-file: .github/codeql/codeql-config.yml
|
||||
|
||||
- name: Build
|
||||
if: matrix.build-mode == 'manual'
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -22,9 +22,9 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Build and Deploy Backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@v3
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Setup .NET 10
|
||||
uses: actions/setup-dotnet@v5
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
name: Build and Deploy MCP Server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# 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
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
# RUNNING SERVER; this workflow must be re-run. /health reports the SHA
|
||||
# captured below so that staleness is checkable.
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
path: mcp-docs
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Job
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
lfs: false
|
||||
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
|
||||
@@ -39,6 +39,9 @@ jobs:
|
||||
with:
|
||||
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
|
||||
# Dependabot PRs run without repo secrets, so the deploy token is absent.
|
||||
# Skip (green) instead of failing the check; real deploys on main still have the token.
|
||||
skip_deploy_on_missing_secrets: true
|
||||
action: "upload"
|
||||
###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
|
||||
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
|
||||
|
||||
+124
@@ -266,6 +266,130 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
$"expected the hung command to be killed at the timeout; took {sw.Elapsed}");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Every remaining collector actually calls its guard
|
||||
//
|
||||
// The guard functions have their own unit tests, but those prove nothing about
|
||||
// whether a collector consults them before touching the registry, WMI or disk.
|
||||
// These drive the collectors themselves: each blocked target must come back empty,
|
||||
// emit exactly one security_warning naming the target, and produce no rule output.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
private void AssertBlocked(Dictionary<string, object> result, string target)
|
||||
{
|
||||
Assert.Empty(result);
|
||||
var warning = SingleSecurityWarning();
|
||||
Assert.Equal(true, warning.Data["blocked"]);
|
||||
Assert.Equal(target, warning.Data["target"]);
|
||||
Assert.DoesNotContain(_events, e => e.EventType == "guard_test");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"SAM\SAM\Domains\Account\Users")]
|
||||
[InlineData(@"SECURITY\Policy\Secrets")]
|
||||
[InlineData(@"HKLM\SOFTWARE\Microsoft\EnrollmentsSomethingElse")] // prefix spoofing
|
||||
public void RegistryCollector_OffAllowlistPath_IsBlockedBeforeTheHiveIsOpened(string target)
|
||||
=> AssertBlocked(new RegistryCollector().Execute(Rule("registry", target), Context()), target);
|
||||
|
||||
[Theory]
|
||||
[InlineData("SELECT * FROM Win32_UserAccount")]
|
||||
[InlineData("SELECT * FROM Win32_ShadowCopy")]
|
||||
[InlineData("SELECT * FROM Win32_OperatingSystemExtra")] // prefix spoofing
|
||||
public void WmiCollector_OffAllowlistQuery_IsBlockedBeforeTheQueryRuns(string query)
|
||||
=> AssertBlocked(new WmiCollector().Execute(Rule("wmi", query), Context()), query);
|
||||
|
||||
[Fact]
|
||||
public void JsonCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
|
||||
{
|
||||
const string target = @"C:\Users\Public\Documents\secrets.json";
|
||||
AssertBlocked(
|
||||
new JsonCollector().Execute(
|
||||
Rule("json", target, new Dictionary<string, string> { ["jsonpath"] = "$.token" }),
|
||||
Context()),
|
||||
target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void XmlCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
|
||||
{
|
||||
const string target = @"C:\Users\Public\Documents\secrets.xml";
|
||||
AssertBlocked(
|
||||
new XmlCollector().Execute(
|
||||
Rule("xml", target, new Dictionary<string, string> { ["xpath"] = "//token" }),
|
||||
Context()),
|
||||
target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FileCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
|
||||
{
|
||||
const string target = @"C:\Users\Public\Documents\secrets.txt";
|
||||
AssertBlocked(new FileCollector().Execute(Rule("file", target), Context()), target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonCollector_AllowedPath_ReadsTheFile()
|
||||
{
|
||||
// Counterpart to the blocked cases: proves the guard is a gate, not a wall —
|
||||
// the collector does reach the file once the path is admitted.
|
||||
var path = Path.Combine(_outsideUsers, "inventory.json");
|
||||
File.WriteAllText(path, "{ \"agent\": { \"version\": \"2.4.0\" } }");
|
||||
|
||||
var result = new JsonCollector().Execute(
|
||||
Rule("json", path, new Dictionary<string, string> { ["jsonpath"] = "$.agent.version" }),
|
||||
Context(unrestricted: true));
|
||||
|
||||
Assert.DoesNotContain(_events, e => e.EventType == Constants.EventTypes.SecurityWarning);
|
||||
Assert.Contains("2.4.0", string.Join(",", result.Values));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Path traversal
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void FilePath_TraversalOutOfAnAllowedPrefix_IsBlocked()
|
||||
{
|
||||
// The raw string starts with an allowed prefix, so a naive StartsWith check would
|
||||
// admit it. Path.GetFullPath runs FIRST, so the allowlist sees C:\Secret.txt.
|
||||
const string traversal =
|
||||
@"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\..\..\..\..\Secret.txt";
|
||||
|
||||
Assert.Equal(@"C:\Secret.txt", Path.GetFullPath(traversal));
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilePath_TraversalIntoTheUsersHardBlock_StaysBlockedEvenUnrestricted()
|
||||
{
|
||||
// Unrestricted mode lifts the allowlist but never the C:\Users privacy block —
|
||||
// and it must not be reachable by spelling the path with '..' either.
|
||||
const string traversal =
|
||||
@"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\..\..\..\..\Users\Public\ntuser.dat";
|
||||
|
||||
Assert.StartsWith(@"C:\Users\", Path.GetFullPath(traversal), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal));
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal, unrestrictedMode: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilePath_TraversalIntoTheConfigHiveDirectory_StaysBlockedEvenUnrestricted()
|
||||
{
|
||||
const string traversal = @"C:\Windows\Logs\..\System32\config\SAM";
|
||||
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal));
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal, unrestrictedMode: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FileCollector_TraversalTarget_IsBlockedAndReported()
|
||||
{
|
||||
const string traversal =
|
||||
@"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\..\..\..\..\Users\Public\ntuser.dat";
|
||||
|
||||
AssertBlocked(new FileCollector().Execute(Rule("file", traversal), Context()), traversal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogParser_ImeLogPathOverride_StillHonoursTheUsersHardBlock()
|
||||
{
|
||||
|
||||
@@ -55,7 +55,7 @@ public class AuthFunctionSideEffectTests
|
||||
{ CallBase = false };
|
||||
|
||||
var previewMock = new Mock<PreviewWhitelistService>(
|
||||
configRepo, cache, Mock.Of<ILogger<PreviewWhitelistService>>())
|
||||
configRepo, cache, Mock.Of<ILogger<PreviewWhitelistService>>(), _tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
_telegramMock = new Mock<TelegramNotificationService>(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
public class LogSanitizerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Clean_WithCrlf_StripsNewlines()
|
||||
{
|
||||
// The core log-forging vector: a value that injects a fake log line.
|
||||
var forged = "/api/sessions\r\n[Auth Middleware] FAKE: admin granted";
|
||||
var cleaned = LogSanitizer.Clean(forged);
|
||||
Assert.Equal("/api/sessions[Auth Middleware] FAKE: admin granted", cleaned);
|
||||
Assert.DoesNotContain('\r', cleaned!);
|
||||
Assert.DoesNotContain('\n', cleaned!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_WithBareLineFeedOrCarriageReturn_StripsBoth()
|
||||
{
|
||||
Assert.Equal("ab", LogSanitizer.Clean("a\nb"));
|
||||
Assert.Equal("ab", LogSanitizer.Clean("a\rb"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_WithOtherControlChars_StripsThem()
|
||||
{
|
||||
// Tab (9), NUL (0) and ESC (27, terminal escape) are all removed. Built
|
||||
// from char codes so the source file carries no literal control bytes.
|
||||
var input = $"a{(char)9}b{(char)0}c{(char)27}de";
|
||||
Assert.Equal("abcde", LogSanitizer.Clean(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_WithUnicodeLineSeparators_StripsThem()
|
||||
{
|
||||
// U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are Unicode categories
|
||||
// Zl/Zp, not Cc — so a \p{Cc}-only pass would let them through, and a log viewer
|
||||
// or JavaScript-side reader would still break the line on them. Built from char
|
||||
// codes so the source file carries no literal separator bytes.
|
||||
var forged = $"/api/sessions{(char)0x2028}[Auth Middleware] FAKE: admin granted";
|
||||
var cleaned = LogSanitizer.Clean(forged);
|
||||
|
||||
Assert.Equal("/api/sessions[Auth Middleware] FAKE: admin granted", cleaned);
|
||||
Assert.DoesNotContain((char)0x2028, cleaned!);
|
||||
Assert.Equal("ab", LogSanitizer.Clean($"a{(char)0x2029}b"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/sessions/abc-123/events")]
|
||||
[InlineData("user@contoso.com")]
|
||||
[InlineData("11111111-2222-3333-4444-555555555555")]
|
||||
[InlineData("GET")]
|
||||
public void Clean_WithLegitimateValue_ReturnsUnchanged(string value)
|
||||
{
|
||||
// Real paths, UPNs, GUIDs and methods never contain control chars, so
|
||||
// tracing fidelity is fully preserved.
|
||||
Assert.Equal(value, LogSanitizer.Clean(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_WithNull_ReturnsNull()
|
||||
{
|
||||
Assert.Null(LogSanitizer.Clean(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_WithEmpty_ReturnsEmpty()
|
||||
{
|
||||
Assert.Equal("", LogSanitizer.Clean(""));
|
||||
}
|
||||
}
|
||||
@@ -868,4 +868,149 @@ public class PolicyEnforcementMiddlewareTests
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
}
|
||||
|
||||
// ── Empty-UPN contract for device / anonymous routes ───────────────────────────
|
||||
//
|
||||
// UserRateLimitMiddleware buckets on RequestContext.UserPrincipalName and skips only when it is
|
||||
// EMPTY. Publishing the "anonymous" placeholder there collapsed every agent of every tenant into a
|
||||
// single shared user_ratelimit_anonymous bucket, which 429'd agent telemetry fleet-wide once the
|
||||
// per-user limit was reached. These tests pin the contract: no JWT ⇒ empty UPN.
|
||||
|
||||
[Theory]
|
||||
[InlineData("POST", "/api/agent/telemetry")]
|
||||
[InlineData("GET", "/api/agent/config")]
|
||||
[InlineData("POST", "/api/agent/register-session")]
|
||||
[InlineData("POST", "/api/agent/upload-url")]
|
||||
[InlineData("POST", "/api/agent/error")]
|
||||
[InlineData("POST", "/api/bootstrap/register-session")]
|
||||
[InlineData("GET", "/api/bootstrap/config")]
|
||||
[InlineData("POST", "/api/bootstrap/error")]
|
||||
public async Task DeviceRoute_WithoutJwt_PublishesEmptyUpn(string method, string path)
|
||||
{
|
||||
var h = BuildHarness();
|
||||
|
||||
var result = await h.Middleware.DecideAsync(method, path, null, principal: null);
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
// The rate-limit bucket key: empty means "no user bucket applies to this call".
|
||||
Assert.Equal(string.Empty, result.Context!.UserPrincipalName);
|
||||
// The placeholder still renders in log lines — it just never reaches the RequestContext.
|
||||
Assert.Equal("anonymous", result.UserIdentifier);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/health")]
|
||||
[InlineData("GET", "/api/stats/platform")]
|
||||
[InlineData("POST", "/api/agent/distress")]
|
||||
[InlineData("GET", "/api/diagnostics/download")]
|
||||
public async Task AnonymousRoute_WithoutJwt_PublishesEmptyUpn(string method, string path)
|
||||
{
|
||||
var h = BuildHarness();
|
||||
|
||||
var result = await h.Middleware.DecideAsync(method, path, null, principal: null);
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Equal(string.Empty, result.Context!.UserPrincipalName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticatedRoute_StillPublishesTheRealUpn()
|
||||
{
|
||||
// Guard the other direction: the fix must not blank the UPN for genuine JWT callers, or
|
||||
// per-user rate limiting and presence tracking would silently stop working.
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/notifications", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Equal(upn, result.Context!.UserPrincipalName);
|
||||
Assert.Equal(upn, result.Context!.CallerId);
|
||||
}
|
||||
|
||||
// ── CallerId: throttle identity survives tokens that carry no UPN ──────────────
|
||||
//
|
||||
// AuthenticationMiddleware validates issuer/audience/lifetime/signature but requires NO upn and no
|
||||
// scp, so an app-only (client-credentials) token authenticates and reaches every AuthenticatedUser
|
||||
// route (feedback, realtime/negotiate, progress/*, health/detailed, …). UserRateLimitMiddleware
|
||||
// skips on an EMPTY throttle identity — so if CallerId fell back to the (absent) UPN, those tokens
|
||||
// would bypass rate limiting entirely. These tests pin the fallback chain.
|
||||
|
||||
/// <summary>Principal with a tid but NO upn/preferred_username — shaped like an app-only token.</summary>
|
||||
private static ClaimsPrincipal AppOnlyPrincipal(string tenantId, params (string Type, string Value)[] claims)
|
||||
{
|
||||
var all = new List<Claim> { new("tid", tenantId) };
|
||||
all.AddRange(claims.Select(c => new Claim(c.Type, c.Value)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(all, "TestAuth"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("oid", "aaaaaaaa-0000-0000-0000-000000000001")]
|
||||
[InlineData("http://schemas.microsoft.com/identity/claims/objectidentifier", "aaaaaaaa-0000-0000-0000-000000000002")]
|
||||
[InlineData("sub", "subject-abc")]
|
||||
[InlineData("appid", "bbbbbbbb-0000-0000-0000-000000000003")]
|
||||
[InlineData("azp", "cccccccc-0000-0000-0000-000000000004")]
|
||||
public async Task AuthenticatedUserRoute_TokenWithoutUpn_StillGetsAThrottleIdentity(string claimType, string claimValue)
|
||||
{
|
||||
var h = BuildHarness();
|
||||
|
||||
// /api/feedback/status is AuthenticatedUser — admits ANY valid principal, role unresolved.
|
||||
var result = await h.Middleware.DecideAsync(
|
||||
"GET", "/api/feedback/status", null, AppOnlyPrincipal(TenantA, (claimType, claimValue)));
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
// No UPN to report — audit/presence correctly see nothing...
|
||||
Assert.Equal(string.Empty, result.Context!.UserPrincipalName);
|
||||
// ...but the throttle still has something stable to bucket on.
|
||||
Assert.Equal(claimValue, result.Context!.CallerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticatedUserRoute_TokenWithoutUpn_PrefersOidOverSubAndAppId()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
var principal = AppOnlyPrincipal(TenantA,
|
||||
("appid", "cccccccc-0000-0000-0000-000000000009"),
|
||||
("sub", "subject-xyz"),
|
||||
("oid", "aaaaaaaa-0000-0000-0000-00000000000a"));
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/feedback/status", null, principal);
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Equal("aaaaaaaa-0000-0000-0000-00000000000a", result.Context!.CallerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticatedUserRoute_TokenWithNoIdentifyingClaim_FailsClosedToASharedBucket()
|
||||
{
|
||||
// Pathological (every real Entra token has a sub): must NOT resolve to empty, because empty
|
||||
// means "skip the limiter". A shared bucket is the safe answer.
|
||||
var h = BuildHarness();
|
||||
|
||||
var result = await h.Middleware.DecideAsync(
|
||||
"GET", "/api/feedback/status", null, AppOnlyPrincipal(TenantA));
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.NotEqual(string.Empty, result.Context!.CallerId);
|
||||
Assert.Equal(PolicyEnforcementMiddleware.UnidentifiedCallerBucket, result.Context!.CallerId);
|
||||
// Never reuse the log placeholder as a bucket key — that was the original fleet-wide collapse.
|
||||
Assert.NotEqual("anonymous", result.Context!.CallerId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("POST", "/api/agent/telemetry")]
|
||||
[InlineData("GET", "/api/health")]
|
||||
[InlineData("GET", "/api/stats/platform")]
|
||||
public async Task UnauthenticatedRoute_HasEmptyCallerId(string method, string path)
|
||||
{
|
||||
// Empty CallerId is the ONLY signal that skips the user limiter — it must mean
|
||||
// "no JWT at all", never "authenticated but unnamed".
|
||||
var h = BuildHarness();
|
||||
|
||||
var result = await h.Middleware.DecideAsync(method, path, null, principal: null);
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Equal(string.Empty, result.Context!.CallerId);
|
||||
}
|
||||
}
|
||||
|
||||
+49
-10
@@ -78,15 +78,16 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
[Fact]
|
||||
public async Task RunCore_emits_LongRunning_when_body_runs_past_warning_threshold()
|
||||
{
|
||||
// Set watchdog thresholds to sub-second; make the fanout sleep long enough to trip warning
|
||||
// but not severe.
|
||||
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(50), watchdogSevere: TimeSpan.FromMilliseconds(500));
|
||||
// Deterministic (no wall-clock race): the fanout body blocks until the WARN watchdog has
|
||||
// actually recorded its OpsEvent, then returns. Severe sits minutes out, so it can never
|
||||
// fire in the moment between warn firing and the body completing (which cancels it).
|
||||
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(20), watchdogSevere: TimeSpan.FromMinutes(10));
|
||||
harness.SetKillSwitch(false);
|
||||
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (SessionRetentionFanoutService.FanoutResult r, DateTime _, CancellationToken ct) =>
|
||||
{
|
||||
r.TenantsProcessed = 1;
|
||||
await Task.Delay(150, ct);
|
||||
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunning");
|
||||
});
|
||||
|
||||
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
|
||||
@@ -102,14 +103,17 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
// assigned only AFTER the fanout returned, so every LongRunning warning showed zeros.
|
||||
// The fanout now mutates the caller-supplied result incrementally; the watchdog snapshot
|
||||
// must reflect that progress mid-run.
|
||||
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(50), watchdogSevere: TimeSpan.FromSeconds(5));
|
||||
// Deterministic: counters are set at fanout entry, then the body blocks until the WARN
|
||||
// watchdog fires and snapshots them. Severe sits minutes out and never fires — no
|
||||
// body-sleep-vs-threshold race.
|
||||
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(50), watchdogSevere: TimeSpan.FromMinutes(10));
|
||||
harness.SetKillSwitch(false);
|
||||
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (SessionRetentionFanoutService.FanoutResult r, DateTime _, CancellationToken ct) =>
|
||||
{
|
||||
r.TenantsProcessed = 3;
|
||||
r.SessionsEnqueued = 7;
|
||||
await Task.Delay(200, ct); // long enough for the 50ms watchdog to fire mid-run
|
||||
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunning");
|
||||
});
|
||||
|
||||
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
|
||||
@@ -122,17 +126,20 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
[Fact]
|
||||
public async Task RunCore_emits_LongRunningSevere_when_body_runs_past_severe_threshold()
|
||||
{
|
||||
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(50), watchdogSevere: TimeSpan.FromMilliseconds(150));
|
||||
// Deterministic: the body blocks until the SEVERE watchdog records its OpsEvent. Warn has a
|
||||
// smaller threshold, so it is guaranteed to have fired first — neither depends on a body
|
||||
// sleep outlasting a fixed wall-clock threshold.
|
||||
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(20), watchdogSevere: TimeSpan.FromMilliseconds(60));
|
||||
harness.SetKillSwitch(false);
|
||||
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (SessionRetentionFanoutService.FanoutResult _, DateTime _, CancellationToken ct) =>
|
||||
{
|
||||
await Task.Delay(300, ct);
|
||||
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunningSevere");
|
||||
});
|
||||
|
||||
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
|
||||
|
||||
// Both watchdogs fire: warning at 50ms, severe at 150ms.
|
||||
// Both watchdogs fire: warning first (smaller threshold), then severe (which the body waited for).
|
||||
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunning");
|
||||
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunningSevere");
|
||||
}
|
||||
@@ -345,6 +352,12 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
private readonly Dictionary<string, List<TableEntity>> _sessionsByState = new(StringComparer.Ordinal);
|
||||
private readonly List<DeletionManifestBlobSummary> _ttlBlobs = new();
|
||||
|
||||
// Watchdog OpsEvents are recorded from concurrent watchdog tasks; _opsSync serializes the
|
||||
// OpsEvents append + waiter signalling, and _eventWaiters lets a test's fanout body block
|
||||
// until a specific watchdog OpsEvent has actually fired (see WaitForEventAsync).
|
||||
private readonly object _opsSync = new();
|
||||
private readonly Dictionary<string, TaskCompletionSource> _eventWaiters = new(StringComparer.Ordinal);
|
||||
|
||||
public Harness(TimeSpan? watchdogWarn = null, TimeSpan? watchdogSevere = null)
|
||||
{
|
||||
StorageMock = new Mock<TableStorageService>(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance);
|
||||
@@ -376,7 +389,12 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Returns<OpsEventEntry>(e =>
|
||||
{
|
||||
OpsEvents.Add(new CapturedOpsEvent(e.EventType, e.Severity, e.Message, e.Details ?? string.Empty));
|
||||
lock (_opsSync)
|
||||
{
|
||||
OpsEvents.Add(new CapturedOpsEvent(e.EventType, e.Severity, e.Message, e.Details ?? string.Empty));
|
||||
if (_eventWaiters.TryGetValue(e.EventType, out var waiter))
|
||||
waiter.TrySetResult();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
@@ -426,6 +444,27 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
public void SetKillSwitch(bool active) =>
|
||||
AdminConfig.Setup(a => a.IsSessionDeletionKillSwitchActiveAsync()).ReturnsAsync(active);
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic watchdog gate: returns a task that completes once an OpsEvent whose type is
|
||||
/// <paramref name="eventName"/> has been recorded. A test's fanout body awaits this instead of
|
||||
/// sleeping a fixed span, so the watchdog is guaranteed to have fired before the body returns —
|
||||
/// removing the wall-clock race (body sleep vs sub-second thresholds) that flaked under CI load.
|
||||
/// </summary>
|
||||
public Task WaitForEventAsync(string eventName)
|
||||
{
|
||||
lock (_opsSync)
|
||||
{
|
||||
if (OpsEvents.Any(e => e.EventName == eventName))
|
||||
return Task.CompletedTask;
|
||||
if (!_eventWaiters.TryGetValue(eventName, out var waiter))
|
||||
{
|
||||
waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_eventWaiters[eventName] = waiter;
|
||||
}
|
||||
return waiter.Task;
|
||||
}
|
||||
}
|
||||
|
||||
public void SeedTtlBlobs(params (string TenantId, string SessionId, string ManifestId, DateTime LastModified)[] entries)
|
||||
{
|
||||
foreach (var e in entries)
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
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 Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
@@ -9,6 +24,8 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// </summary>
|
||||
public class TenantContactEmailTests
|
||||
{
|
||||
private const string TenantId = "77777777-7777-7777-7777-777777777777";
|
||||
|
||||
[Fact]
|
||||
public void ContactEmail_defaults_to_null_so_absence_is_distinguishable_from_empty()
|
||||
{
|
||||
@@ -22,4 +39,348 @@ public class TenantContactEmailTests
|
||||
var config = new TenantConfiguration { ContactEmail = "it-operations@contoso.com" };
|
||||
Assert.Equal("it-operations@contoso.com", config.ContactEmail);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Server-side validation (UpdateTenantConfigurationFunction)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("it-operations@contoso.com")]
|
||||
[InlineData("first.last+autopilot@sub.fabrikam.co.uk")]
|
||||
[InlineData("UPPER@CONTOSO.COM")]
|
||||
public void ValidateContactEmail_accepts_real_addresses(string email)
|
||||
=> Assert.Null(UpdateTenantConfigurationFunction.ValidateContactEmail(email));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void ValidateContactEmail_treats_absence_as_valid(string? email)
|
||||
{
|
||||
// No contact address is a legitimate state — it means we cannot reach this tenant.
|
||||
Assert.Null(UpdateTenantConfigurationFunction.ValidateContactEmail(email));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-an-address", "single")] // no @ at all
|
||||
[InlineData("@contoso.com", "single")] // empty local part
|
||||
[InlineData("ops@", "single")] // empty domain
|
||||
[InlineData("ops@@contoso.com", "single")] // two @
|
||||
[InlineData("ops@localhost", "dotted")] // unreachable bare host
|
||||
[InlineData("ops@.contoso.com", "dotted")] // leading dot
|
||||
[InlineData("ops@contoso.", "dotted")] // trailing dot
|
||||
[InlineData("ops@contoso.com, attacker@evil.test", "single address")] // recipient list
|
||||
[InlineData("ops@contoso.com; attacker@evil.test", "single address")]
|
||||
[InlineData("Ops Team <ops@contoso.com>", "single address")] // display-name form
|
||||
public void ValidateContactEmail_rejects_values_that_are_not_a_single_address(string email, string expectedHint)
|
||||
{
|
||||
var error = UpdateTenantConfigurationFunction.ValidateContactEmail(email);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains(expectedHint, error!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateContactEmail_rejects_header_injection()
|
||||
{
|
||||
// Once this address is actually mailed, an embedded CR/LF would forge mail headers.
|
||||
var error = UpdateTenantConfigurationFunction.ValidateContactEmail(
|
||||
$"ops@contoso.com{(char)13}{(char)10}Bcc: attacker@evil.test");
|
||||
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateContactEmail_rejects_an_over_long_value()
|
||||
{
|
||||
var local = new string('a', UpdateTenantConfigurationFunction.MaxContactEmailLength);
|
||||
var error = UpdateTenantConfigurationFunction.ValidateContactEmail($"{local}@contoso.com");
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("at most", error!);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Seed contract (TenantConfigurationService — the single owner)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static (TenantConfigurationService service, Mock<IConfigRepository> repo, MemoryCache cache) BuildService()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new TenantConfigurationService(
|
||||
repo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
return (service, repo, cache);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySeedContactEmail_when_the_write_lands_reports_success_and_drops_the_cached_row()
|
||||
{
|
||||
var (service, repo, cache) = BuildService();
|
||||
repo.Setup(r => r.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com")).ReturnsAsync(true);
|
||||
cache.Set($"tenant-config:{TenantId}", new TenantConfiguration { TenantId = TenantId });
|
||||
|
||||
Assert.True(await service.TrySeedContactEmailAsync(TenantId, "ops@contoso.com"));
|
||||
|
||||
// The repository writes behind this cache; without the eviction the freshly seeded
|
||||
// address would stay invisible for the full 5-minute TTL.
|
||||
Assert.False(cache.TryGetValue($"tenant-config:{TenantId}", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySeedContactEmail_when_the_tenant_already_owns_an_address_leaves_the_cache_alone()
|
||||
{
|
||||
var (service, repo, cache) = BuildService();
|
||||
repo.Setup(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(false);
|
||||
var cached = new TenantConfiguration { TenantId = TenantId, ContactEmail = "owner@contoso.com" };
|
||||
cache.Set($"tenant-config:{TenantId}", cached);
|
||||
|
||||
Assert.False(await service.TrySeedContactEmailAsync(TenantId, "ops@contoso.com"));
|
||||
|
||||
Assert.True(cache.TryGetValue($"tenant-config:{TenantId}", out TenantConfiguration? still));
|
||||
Assert.Equal("owner@contoso.com", still!.ContactEmail);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task TrySeedContactEmail_with_no_address_never_reaches_storage(string? email)
|
||||
{
|
||||
var (service, repo, _) = BuildService();
|
||||
|
||||
Assert.False(await service.TrySeedContactEmailAsync(TenantId, email));
|
||||
|
||||
repo.Verify(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySeedContactEmail_with_no_tenant_never_reaches_storage()
|
||||
{
|
||||
var (service, repo, _) = BuildService();
|
||||
|
||||
Assert.False(await service.TrySeedContactEmailAsync(" ", "ops@contoso.com"));
|
||||
|
||||
repo.Verify(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySeedContactEmail_trims_before_it_reaches_storage()
|
||||
{
|
||||
var (service, repo, _) = BuildService();
|
||||
repo.Setup(r => r.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com")).ReturnsAsync(true);
|
||||
|
||||
Assert.True(await service.TrySeedContactEmailAsync(TenantId, " ops@contoso.com "));
|
||||
|
||||
repo.Verify(r => r.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySeedContactEmail_never_writes_the_whole_configuration()
|
||||
{
|
||||
// Regression guard: the seed used to be a read-modify-write of the full model through
|
||||
// SaveTenantConfigurationAsync, which replaces the row unconditionally — a concurrent
|
||||
// portal save was clobbered by the seeder's stale snapshot of EVERY field.
|
||||
var (service, repo, _) = BuildService();
|
||||
repo.Setup(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
await service.TrySeedContactEmailAsync(TenantId, "ops@contoso.com");
|
||||
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Seed trigger (PreviewWhitelistService)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static (PreviewWhitelistService preview, Mock<IConfigRepository> repo) BuildPreview()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tenantConfig = new TenantConfigurationService(
|
||||
repo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
var preview = new PreviewWhitelistService(
|
||||
repo.Object, cache, NullLogger<PreviewWhitelistService>.Instance, tenantConfig);
|
||||
return (preview, repo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveNotificationEmail_persists_the_address_and_seeds_the_contact_field()
|
||||
{
|
||||
var (preview, repo) = BuildPreview();
|
||||
repo.Setup(r => r.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com")).ReturnsAsync(true);
|
||||
|
||||
await preview.SaveNotificationEmailAsync(TenantId, "ops@contoso.com");
|
||||
|
||||
repo.Verify(r => r.SaveNotificationEmailAsync(TenantId, "ops@contoso.com"), Times.Once);
|
||||
repo.Verify(r => r.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveNotificationEmail_clearing_the_address_does_not_seed()
|
||||
{
|
||||
var (preview, repo) = BuildPreview();
|
||||
|
||||
await preview.SaveNotificationEmailAsync(TenantId, null);
|
||||
|
||||
repo.Verify(r => r.SaveNotificationEmailAsync(TenantId, null), Times.Once);
|
||||
repo.Verify(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveNotificationEmail_still_succeeds_when_the_seed_blows_up()
|
||||
{
|
||||
// The notification email has already been persisted at that point; a failing side
|
||||
// effect must not turn a completed write into a failed request.
|
||||
var (preview, repo) = BuildPreview();
|
||||
repo.Setup(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ThrowsAsync(new RequestFailedException(503, "throttled"));
|
||||
|
||||
await preview.SaveNotificationEmailAsync(TenantId, "ops@contoso.com");
|
||||
|
||||
repo.Verify(r => r.SaveNotificationEmailAsync(TenantId, "ops@contoso.com"), Times.Once);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Conditional write (TableConfigRepository) — the actual invariant
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private sealed class RepoHarness
|
||||
{
|
||||
public TableConfigRepository Sut { get; }
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public List<(TableEntity Entity, ETag IfMatch, TableUpdateMode Mode)> Updates { get; } = new();
|
||||
|
||||
public RepoHarness(TableEntity? stored, RequestFailedException? throwOnUpdate = null)
|
||||
{
|
||||
if (stored == null)
|
||||
{
|
||||
Table.Setup(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "Not Found", "ResourceNotFound", null));
|
||||
}
|
||||
else
|
||||
{
|
||||
Table.Setup(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(stored, 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, tag, mode, _) =>
|
||||
{
|
||||
if (throwOnUpdate != null)
|
||||
throw throwOnUpdate;
|
||||
Updates.Add((e, tag, mode));
|
||||
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),
|
||||
NullLogger<TableConfigRepository>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
private static TableEntity StoredConfig(string? contactEmail, string etag = "W/\"stored\"")
|
||||
{
|
||||
var entity = new TableEntity(TenantId, "config") { ["DomainName"] = "contoso.com" };
|
||||
if (contactEmail != null)
|
||||
entity["ContactEmail"] = contactEmail;
|
||||
entity.ETag = new ETag(etag);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repo_seeds_only_the_contact_field_and_only_conditionally()
|
||||
{
|
||||
var harness = new RepoHarness(StoredConfig(contactEmail: null));
|
||||
|
||||
Assert.True(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, " ops@contoso.com "));
|
||||
|
||||
var (entity, ifMatch, mode) = Assert.Single(harness.Updates);
|
||||
// Merge, not Replace: every other stored property must survive untouched.
|
||||
Assert.Equal(TableUpdateMode.Merge, mode);
|
||||
Assert.Equal(new ETag("W/\"stored\""), ifMatch);
|
||||
Assert.Equal("ops@contoso.com", entity["ContactEmail"]);
|
||||
Assert.False(entity.ContainsKey("DomainName"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repo_never_overwrites_an_address_the_tenant_already_owns()
|
||||
{
|
||||
var harness = new RepoHarness(StoredConfig("owner@contoso.com"));
|
||||
|
||||
Assert.False(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"));
|
||||
|
||||
Assert.Empty(harness.Updates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repo_treats_a_whitespace_only_stored_value_as_absent()
|
||||
{
|
||||
var harness = new RepoHarness(StoredConfig(" "));
|
||||
|
||||
Assert.True(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"));
|
||||
|
||||
Assert.Single(harness.Updates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repo_loses_the_race_rather_than_clobbering_a_concurrent_write()
|
||||
{
|
||||
// Someone wrote the row between our read and our write. The ETag precondition turns
|
||||
// that into a 412; the seed abandons rather than overwriting the winner.
|
||||
var harness = new RepoHarness(
|
||||
StoredConfig(contactEmail: null),
|
||||
throwOnUpdate: new RequestFailedException(412, "Precondition Failed", "UpdateConditionNotSatisfied", null));
|
||||
|
||||
Assert.False(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repo_with_no_configuration_row_reports_no_seed()
|
||||
{
|
||||
var harness = new RepoHarness(stored: null);
|
||||
|
||||
Assert.False(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"));
|
||||
|
||||
Assert.Empty(harness.Updates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repo_is_fail_soft_on_a_storage_fault()
|
||||
{
|
||||
// Callers are side effects of an already-committed write — a lost seed is recoverable
|
||||
// on the next maintenance run, a thrown exception is not.
|
||||
var harness = new RepoHarness(
|
||||
StoredConfig(contactEmail: null),
|
||||
throwOnUpdate: new RequestFailedException(503, "Server Busy"));
|
||||
|
||||
Assert.False(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "ops@contoso.com")]
|
||||
[InlineData("", "ops@contoso.com")]
|
||||
[InlineData(TenantId, null)]
|
||||
[InlineData(TenantId, " ")]
|
||||
public async Task Repo_rejects_incomplete_input_without_touching_storage(string? tenantId, string? email)
|
||||
{
|
||||
var harness = new RepoHarness(StoredConfig(contactEmail: null));
|
||||
|
||||
Assert.False(await harness.Sut.TrySeedTenantContactEmailAsync(tenantId!, email!));
|
||||
|
||||
Assert.Empty(harness.Updates);
|
||||
harness.Table.Verify(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,11 @@ public sealed class TenantOffboardFunctionTests
|
||||
return new PreviewWhitelistService(
|
||||
configRepoMock.Object,
|
||||
cache,
|
||||
NullLogger<PreviewWhitelistService>.Instance);
|
||||
NullLogger<PreviewWhitelistService>.Instance,
|
||||
new TenantConfigurationService(
|
||||
configRepoMock.Object,
|
||||
NullLogger<TenantConfigurationService>.Instance,
|
||||
cache));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<!-- Security pins: Microsoft.Identity.Web 4.3.0 pulls these transitively at 10.0.0, which carries
|
||||
known advisories (GHSA-9mv3-2cwr-p262 DataProtection; GHSA-37gx-xxp4-5rgx and siblings on
|
||||
Cryptography.Xml). Force the patched 10.0.10 servicing build. Remove once Identity.Web ships
|
||||
a dependency floor past the fix. -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.10" />
|
||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
|
||||
<PackageReference Include="Resend" Version="0.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
+48
@@ -71,6 +71,54 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conditional, single-property seed of ContactEmail (see <see cref="IConfigRepository"/>).
|
||||
/// Merge mode leaves every other stored property untouched, and the ETag precondition turns
|
||||
/// a concurrent write into a 412 rather than a silent overwrite. Both matter: the caller is
|
||||
/// a background/side-effect path that must never beat a tenant admin's own edit.
|
||||
/// </summary>
|
||||
public async Task<bool> TrySeedTenantContactEmailAsync(string tenantId, string email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(email))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var existing = await _tenantConfigTableClient.GetEntityAsync<TableEntity>(tenantId, "config");
|
||||
|
||||
// The tenant already owns an address — the seed is one-way and never re-syncs.
|
||||
if (!string.IsNullOrWhiteSpace(existing.Value.GetString("ContactEmail")))
|
||||
return false;
|
||||
|
||||
var patch = new TableEntity(tenantId, "config")
|
||||
{
|
||||
["ContactEmail"] = email.Trim()
|
||||
};
|
||||
|
||||
await _tenantConfigTableClient.UpdateEntityAsync(
|
||||
patch, existing.Value.ETag, TableUpdateMode.Merge);
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// No configuration row yet — nothing to seed onto.
|
||||
return false;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 412)
|
||||
{
|
||||
// Someone wrote the row between the read and the write. They win by construction:
|
||||
// a lost seed is recoverable (the backfill retries), a lost admin edit is not.
|
||||
_logger.LogInformation(
|
||||
"Contact address seed for {TenantId} skipped — the configuration changed concurrently", tenantId);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error seeding contact address for tenant {TenantId}", tenantId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TenantConfiguration>> GetAllTenantConfigurationsAsync()
|
||||
{
|
||||
try
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -72,7 +73,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
|
||||
_logger.LogWarning("Failed to increment user usage after {MaxRetries} retries: user={UserId}, endpoint={Endpoint}",
|
||||
maxRetries, userId, endpoint);
|
||||
maxRetries, LogSanitizer.Clean(userId), LogSanitizer.Clean(endpoint));
|
||||
}
|
||||
|
||||
public async Task<List<UserUsageRecord>> GetUsageByUserAsync(string userId, string? dateFrom = null, string? dateTo = null)
|
||||
|
||||
@@ -52,6 +52,34 @@ public static class ClaimsPrincipalExtensions
|
||||
?? principal.FindFirst("preferred_username")?.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stable per-caller identity for THROTTLING keys — never for authorization or audit.
|
||||
/// <para>
|
||||
/// Prefers the UPN so a human caller keeps the same bucket as before. Falls back to the token
|
||||
/// subject (<c>oid</c> → <c>sub</c> → <c>appid</c>/<c>azp</c>) because a valid token need not carry
|
||||
/// a user identity at all: AuthenticationMiddleware validates issuer/audience/lifetime/signature
|
||||
/// but requires no <c>upn</c>, so an app-only (client-credentials) token authenticates fine and
|
||||
/// reaches every AuthenticatedUser route. Without this fallback such a caller would present an
|
||||
/// empty identity and skip per-user rate limiting entirely.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both the raw and the WS-Fed-mapped claim names are checked: JwtSecurityTokenHandler runs with
|
||||
/// inbound claim mapping ON, so <c>oid</c>/<c>sub</c> may arrive under their schema URIs.
|
||||
/// </para>
|
||||
/// Returns null only for a principal carrying no identifying claim whatsoever; callers must
|
||||
/// decide fail-closed what that means (see <c>RequestContext.CallerId</c>).
|
||||
/// </summary>
|
||||
public static string? GetCallerId(this ClaimsPrincipal principal)
|
||||
{
|
||||
return principal.GetUserPrincipalName()
|
||||
?? principal.FindFirst("oid")?.Value
|
||||
?? principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value
|
||||
?? principal.FindFirst("sub")?.Value
|
||||
?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? principal.FindFirst("appid")?.Value
|
||||
?? principal.FindFirst("azp")?.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user's display name from token claims
|
||||
/// Supports both v1.0 and v2.0 token formats
|
||||
|
||||
+59
@@ -76,6 +76,19 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// The contact address is where enforcement actions and service notices are sent,
|
||||
// so it must be an address rather than whatever string a direct API caller posts.
|
||||
var contactEmailError = ValidateContactEmail(config.ContactEmail);
|
||||
if (contactEmailError != null)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid contact email: {contactEmailError}" });
|
||||
return badRequest;
|
||||
}
|
||||
// Normalize so the stored value never carries surrounding whitespace, and an
|
||||
// all-whitespace submission clears the field instead of masquerading as a value.
|
||||
config.ContactEmail = string.IsNullOrWhiteSpace(config.ContactEmail) ? null : config.ContactEmail.Trim();
|
||||
|
||||
// Load the stored config up-front so we can (1) restore any redacted secret placeholders
|
||||
// before validation/save and (2) protect GA-only fields below.
|
||||
var existingConfig = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
@@ -251,6 +264,52 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
private const int MaxCustomHeaderCount = 25;
|
||||
private const int MaxNotificationChannelsJsonLength = 65536;
|
||||
|
||||
// RFC 5321 caps a forward path at 254 characters.
|
||||
internal const int MaxContactEmailLength = 254;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the tenant contact address. Returns an error message, or null when valid/empty.
|
||||
/// Empty is legitimate — it means we have no way to reach the tenant.
|
||||
/// <para>
|
||||
/// Deliberately not an RFC 5322 parser: the job is to reject values that are not addresses
|
||||
/// at all. Specifically it rejects recipient lists (a comma would silently widen who receives
|
||||
/// service notices), display-name forms, and control characters (which would let a caller
|
||||
/// forge mail headers once this address is actually mailed).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static string? ValidateContactEmail(string? email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return null;
|
||||
|
||||
var trimmed = email.Trim();
|
||||
|
||||
if (trimmed.Length > MaxContactEmailLength)
|
||||
return $"must be at most {MaxContactEmailLength} characters.";
|
||||
|
||||
foreach (var ch in trimmed)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
return "must not contain control characters.";
|
||||
if (char.IsWhiteSpace(ch) || ch == ',' || ch == ';' || ch == '<' || ch == '>')
|
||||
return "must be a single address, without spaces, separators or angle brackets.";
|
||||
}
|
||||
|
||||
var at = trimmed.IndexOf('@');
|
||||
if (at <= 0 || at != trimmed.LastIndexOf('@') || at == trimmed.Length - 1)
|
||||
return "must contain a single \"@\" with text on both sides.";
|
||||
|
||||
// A bare host with no dot is unreachable from our sender, so it is a typo, not an address.
|
||||
var domain = trimmed.Substring(at + 1);
|
||||
if (!domain.Contains('.') || domain.StartsWith(".", StringComparison.Ordinal)
|
||||
|| domain.EndsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
return "the domain part must be a dotted host name.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the notification-channel list JSON. Returns an error message, or null when
|
||||
/// valid/empty. Strict counterpart of the fail-soft <c>NotificationChannel.ParseList</c>:
|
||||
|
||||
+30
-3
@@ -1,5 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using AutopilotMonitor.Shared.Diagnostics;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -17,21 +19,29 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
/// from the query string, so a ticket cannot be retargeted.
|
||||
/// <para>
|
||||
/// Replay is bounded by the 10-min ticket TTL, the admin size cap, and the fact that the ticket
|
||||
/// only ever points at the minting caller's own authorized blob; HMAC forgery is infeasible, so
|
||||
/// no additional rate limiting is layered here.
|
||||
/// only ever points at the minting caller's own authorized blob; HMAC forgery is infeasible. On top
|
||||
/// of that the route carries a per-client-IP limit as defense in depth — it is unauthenticated and
|
||||
/// streams whole blobs, so a valid ticket replayed in a loop would otherwise be unbounded egress.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class DiagnosticsTicketDownloadFunction
|
||||
{
|
||||
// Bounds replay of a still-valid ticket. A real client downloads a package once; the ceiling
|
||||
// only has to stay clear of a legitimate retry-after-timeout.
|
||||
private const int MaxRequestsPerMinutePerIp = 30;
|
||||
|
||||
private readonly ILogger<DiagnosticsTicketDownloadFunction> _logger;
|
||||
private readonly DiagnosticsBlobStreamer _streamer;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
|
||||
public DiagnosticsTicketDownloadFunction(
|
||||
ILogger<DiagnosticsTicketDownloadFunction> logger,
|
||||
DiagnosticsBlobStreamer streamer)
|
||||
DiagnosticsBlobStreamer streamer,
|
||||
RateLimitService rateLimitService)
|
||||
{
|
||||
_logger = logger;
|
||||
_streamer = streamer;
|
||||
_rateLimitService = rateLimitService;
|
||||
}
|
||||
|
||||
[Function("DiagnosticsTicketDownload")]
|
||||
@@ -40,6 +50,23 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
{
|
||||
try
|
||||
{
|
||||
// Rightmost X-Forwarded-For hop only — leftmost entries are caller-controlled.
|
||||
var clientIp = ClientIpExtractor.GetTrustedClientIp(req);
|
||||
var rateLimitResult = _rateLimitService.CheckRateLimit(
|
||||
$"diag_ticket_download_{clientIp}", MaxRequestsPerMinutePerIp);
|
||||
|
||||
if (!rateLimitResult.IsAllowed)
|
||||
{
|
||||
_logger.LogWarning("DiagnosticsTicketDownload rate limit exceeded for IP {ClientIp} ({Count} requests)",
|
||||
clientIp, rateLimitResult.RequestsInWindow);
|
||||
|
||||
var tooMany = req.CreateResponse(HttpStatusCode.TooManyRequests);
|
||||
if (rateLimitResult.RetryAfter.HasValue)
|
||||
tooMany.Headers.Add("Retry-After", ((int)rateLimitResult.RetryAfter.Value.TotalSeconds).ToString());
|
||||
await tooMany.WriteAsJsonAsync(new { success = false, message = "Rate limit exceeded." });
|
||||
return tooMany;
|
||||
}
|
||||
|
||||
var ticket = HttpUtility.ParseQueryString(req.Url.Query)["t"];
|
||||
if (string.IsNullOrEmpty(ticket))
|
||||
{
|
||||
|
||||
+32
-1
@@ -1,4 +1,6 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -9,16 +11,27 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
/// <summary>
|
||||
/// Public endpoint - no authentication required.
|
||||
/// Returns pre-computed platform stats for the landing page.
|
||||
/// Rate-limited per client IP: the route is unauthenticated and hits Table Storage on every call,
|
||||
/// so it needs a limit of its own — no user bucket applies to an anonymous caller.
|
||||
/// </summary>
|
||||
public class GetPlatformStatsFunction
|
||||
{
|
||||
// Landing-page widget: one call per page load. Generous enough that a shared corporate NAT
|
||||
// never trips it, tight enough to bound an unauthenticated storage read.
|
||||
private const int MaxRequestsPerMinutePerIp = 60;
|
||||
|
||||
private readonly ILogger<GetPlatformStatsFunction> _logger;
|
||||
private readonly IMetricsRepository _metricsRepo;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
|
||||
public GetPlatformStatsFunction(ILogger<GetPlatformStatsFunction> logger, IMetricsRepository metricsRepo)
|
||||
public GetPlatformStatsFunction(
|
||||
ILogger<GetPlatformStatsFunction> logger,
|
||||
IMetricsRepository metricsRepo,
|
||||
RateLimitService rateLimitService)
|
||||
{
|
||||
_logger = logger;
|
||||
_metricsRepo = metricsRepo;
|
||||
_rateLimitService = rateLimitService;
|
||||
}
|
||||
|
||||
[Function("GetPlatformStats")]
|
||||
@@ -27,6 +40,24 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
{
|
||||
try
|
||||
{
|
||||
// Rightmost X-Forwarded-For hop only — leftmost entries are caller-controlled and would
|
||||
// let one client rotate the rate-limit key on every request.
|
||||
var clientIp = ClientIpExtractor.GetTrustedClientIp(req);
|
||||
var rateLimitResult = _rateLimitService.CheckRateLimit(
|
||||
$"platform_stats_{clientIp}", MaxRequestsPerMinutePerIp);
|
||||
|
||||
if (!rateLimitResult.IsAllowed)
|
||||
{
|
||||
_logger.LogWarning("Platform stats rate limit exceeded for IP {ClientIp} ({Count} requests)",
|
||||
clientIp, rateLimitResult.RequestsInWindow);
|
||||
|
||||
var tooMany = req.CreateResponse(HttpStatusCode.TooManyRequests);
|
||||
if (rateLimitResult.RetryAfter.HasValue)
|
||||
tooMany.Headers.Add("Retry-After", ((int)rateLimitResult.RetryAfter.Value.TotalSeconds).ToString());
|
||||
await tooMany.WriteAsJsonAsync(new { error = "Rate limit exceeded" });
|
||||
return tooMany;
|
||||
}
|
||||
|
||||
var stats = await _metricsRepo.GetPlatformStatsAsync();
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Strips control characters from user-controlled values before they reach a log
|
||||
/// sink, so a caller cannot forge additional log lines (CRLF injection) or smuggle
|
||||
/// terminal escape sequences through the message. Structured logging already stores
|
||||
/// each value as a discrete property, so this is defense-in-depth — it also clears
|
||||
/// the CodeQL cs/log-forging alerts by removing the newline characters they key on.
|
||||
/// Only ever apply to the LOGGED copy of a value; never to a value used for routing,
|
||||
/// authorization, or storage keys.
|
||||
/// </summary>
|
||||
public static class LogSanitizer
|
||||
{
|
||||
// \p{Cc} = Unicode "Other, control" (C0 + C1 ranges), which includes CR and LF.
|
||||
// \p{Zl} and \p{Zp} are the line and paragraph separators (U+2028 / U+2029). They
|
||||
// are separators, not control characters, so \p{Cc} misses them — yet a log viewer,
|
||||
// a JSON-lines consumer, or any JavaScript-side reader still breaks a line on them.
|
||||
private static readonly Regex ControlChars = new(@"[\p{Cc}\p{Zl}\p{Zp}]", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Removes CR, LF, the Unicode line/paragraph separators, and every other control
|
||||
/// character from <paramref name="value"/>. Returns null/empty inputs unchanged.
|
||||
/// </summary>
|
||||
public static string? Clean(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return value;
|
||||
|
||||
// Explicit CR/LF removal first — this is the log-injection barrier the CodeQL
|
||||
// taint model recognizes; the regex pass then drops any remaining separators.
|
||||
var sanitized = value.Replace("\r", string.Empty).Replace("\n", string.Empty);
|
||||
return ControlChars.Replace(sanitized, string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,23 @@ public sealed record RequestContext
|
||||
/// <summary>The user's UPN (from JWT upn/preferred_username claim).</summary>
|
||||
public string UserPrincipalName { get; init; } = string.Empty;
|
||||
|
||||
/// <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).
|
||||
/// <para>
|
||||
/// Empty ⇔ the request carried no JWT at all (device/bootstrap or anonymous route). Those routes
|
||||
/// bring their own limits — per cert thumbprint / per bootstrap token in SecurityValidator, per
|
||||
/// client IP on the public endpoints — so an empty value means "no user bucket applies here".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is deliberately NOT the same field as <see cref="UserPrincipalName"/>: a valid token need
|
||||
/// not carry a UPN (app-only/client-credentials tokens do not), and such a caller still reaches
|
||||
/// every AuthenticatedUser route. Keying the throttle off the UPN alone let those tokens through
|
||||
/// unlimited; keying audit off this would write object ids where a UPN is expected.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string CallerId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>True if the user is a Global Admin of the platform (full read + write).</summary>
|
||||
public bool IsGlobalAdmin { get; init; }
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Middleware;
|
||||
|
||||
@@ -57,9 +58,12 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
// JWT validation. The exempt set is DERIVED from EndpointAccessPolicyCatalog — never a
|
||||
// hand-kept parallel list — so a new anonymous/device route can't drift out of sync here.
|
||||
var requestPath = httpContext.Request.Path.Value ?? string.Empty;
|
||||
// Sanitized copies for logging only — never used for routing/authorization decisions.
|
||||
var logPath = LogSanitizer.Clean(requestPath);
|
||||
var logMethod = LogSanitizer.Clean(httpContext.Request.Method);
|
||||
if (SkipsJwtValidation(httpContext.Request.Method, requestPath))
|
||||
{
|
||||
_logger.LogDebug("[Auth Middleware] JWT-exempt route: {Method} {Path}", httpContext.Request.Method, requestPath);
|
||||
_logger.LogDebug("[Auth Middleware] JWT-exempt route: {Method} {Path}", logMethod, logPath);
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
@@ -68,7 +72,7 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
var authHeader = httpContext.Request.Headers.Authorization.ToString();
|
||||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("[Auth Middleware] Blocked unauthenticated request to {Path}", requestPath);
|
||||
_logger.LogWarning("[Auth Middleware] Blocked unauthenticated request to {Path}", logPath);
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
await httpContext.Response.WriteAsJsonAsync(new
|
||||
@@ -96,7 +100,7 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
// MSA, tid 9188040d-…) carries a GUID tid, so this reject is loss-free.
|
||||
if (!IsValidTenantId(tenantId))
|
||||
{
|
||||
_logger.LogWarning("[Auth Middleware] Rejected token with missing/non-GUID tid for {Path}", requestPath);
|
||||
_logger.LogWarning("[Auth Middleware] Rejected token with missing/non-GUID tid for {Path}", logPath);
|
||||
throw new SecurityTokenValidationException("Token tid claim is missing or not a valid GUID");
|
||||
}
|
||||
|
||||
@@ -207,7 +211,7 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
|
||||
if (!authenticated)
|
||||
{
|
||||
_logger.LogWarning("[Auth Middleware] Blocked request with invalid token to {Path}", requestPath);
|
||||
_logger.LogWarning("[Auth Middleware] Blocked request with invalid token to {Path}", logPath);
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
await httpContext.Response.WriteAsJsonAsync(new
|
||||
|
||||
@@ -159,7 +159,7 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "[McpQuota] Failed to record usage: user={UserId}, endpoint={Endpoint}", oid, normalizedEndpoint);
|
||||
logger.LogWarning(ex, "[McpQuota] Failed to record usage: user={UserId}, endpoint={Endpoint}", LogSanitizer.Clean(oid), LogSanitizer.Clean(normalizedEndpoint));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,15 @@ namespace AutopilotMonitor.Functions.Middleware;
|
||||
/// </summary>
|
||||
public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Throttle bucket for an authenticated token that carries no identifying claim at all (no upn,
|
||||
/// oid, sub, appid or azp). Every real Entra token has at least a <c>sub</c>, so this is a
|
||||
/// fail-closed backstop rather than a live code path: such callers share one bucket instead of
|
||||
/// escaping the limiter. Deliberately NOT "anonymous" — that value is reserved for the log
|
||||
/// rendering of unauthenticated calls and must never become a rate-limit key again.
|
||||
/// </summary>
|
||||
internal const string UnidentifiedCallerBucket = "unidentified-token";
|
||||
|
||||
private readonly ILogger<PolicyEnforcementMiddleware> _logger;
|
||||
private readonly GlobalAdminService _globalAdminService;
|
||||
private readonly DelegatedAdminService _delegatedAdminService;
|
||||
@@ -57,6 +66,10 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
var httpMethod = httpContext.Request.Method;
|
||||
var queryTenantId = httpContext.Request.Query["tenantId"].FirstOrDefault();
|
||||
|
||||
// Sanitized copies for logging only — never used for routing/authorization decisions.
|
||||
var logPath = LogSanitizer.Clean(requestPath);
|
||||
var logMethod = LogSanitizer.Clean(httpMethod);
|
||||
|
||||
// Authenticated principal set by AuthenticationMiddleware (null on anonymous/device routes).
|
||||
// Read from FunctionContext.Items (reliable in the isolated worker) rather than httpContext.User.
|
||||
ClaimsPrincipal? principal = null;
|
||||
@@ -76,7 +89,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[PolicyEnforcement] Service error evaluating policy for {Method} {Path}", httpMethod, requestPath);
|
||||
_logger.LogError(ex, "[PolicyEnforcement] Service error evaluating policy for {Method} {Path}", logMethod, logPath);
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
httpContext.Response.Headers["Retry-After"] = "5";
|
||||
@@ -92,13 +105,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
context.Items[RequestContext.ItemsKey] = result.Context!;
|
||||
_logger.LogDebug("[PolicyEnforcement] ALLOW {Method} {Path} user={User} role={Role}",
|
||||
httpMethod, requestPath, result.UserIdentifier, result.UserRole);
|
||||
logMethod, logPath, LogSanitizer.Clean(result.UserIdentifier), result.UserRole);
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning("[PolicyEnforcement] DENIED {Method} {Path} status={Status} user={User} role={Role} reason={Reason}",
|
||||
httpMethod, requestPath, result.StatusCode, result.UserIdentifier, result.UserRole, result.LogReason);
|
||||
logMethod, logPath, result.StatusCode, LogSanitizer.Clean(result.UserIdentifier), result.UserRole, result.LogReason);
|
||||
httpContext.Response.StatusCode = result.StatusCode;
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
await httpContext.Response.WriteAsJsonAsync(new { error = result.ErrorCode, message = result.ErrorMessage });
|
||||
@@ -115,12 +128,16 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
internal async Task<PolicyResult> DecideAsync(
|
||||
string httpMethod, string requestPath, string? queryTenantId, ClaimsPrincipal? principal)
|
||||
{
|
||||
// Sanitized copies for logging only — never used for routing/authorization decisions.
|
||||
var logPath = LogSanitizer.Clean(requestPath);
|
||||
var logMethod = LogSanitizer.Clean(httpMethod);
|
||||
|
||||
var catalogEntry = EndpointAccessPolicyCatalog.FindPolicy(httpMethod, requestPath);
|
||||
if (catalogEntry == null)
|
||||
{
|
||||
// Fail-closed: unregistered route → 403
|
||||
_logger.LogError("[PolicyEnforcement] BLOCKED unregistered route: {Method} {Path} — not in catalog (fail-closed)",
|
||||
httpMethod, requestPath);
|
||||
logMethod, logPath);
|
||||
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "Forbidden", "Access denied.", "anonymous", "N/A", "Unregistered");
|
||||
}
|
||||
|
||||
@@ -188,7 +205,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
if (crossTenant && !hasGlobalScope && !delegatedGrantsRead)
|
||||
{
|
||||
_logger.LogWarning("[PolicyEnforcement] BLOCKED cross-tenant access: user={User} jwtTenant={JwtTenant} target={Target} scoping={Scoping} path={Path}",
|
||||
decision.UserIdentifier, jwtTenantId, namedTarget, catalogEntry.TenantScoping, requestPath);
|
||||
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(jwtTenantId), LogSanitizer.Clean(namedTarget), catalogEntry.TenantScoping, logPath);
|
||||
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "CrossTenantAccessDenied",
|
||||
"Access denied. You can only access your own tenant's resources.",
|
||||
decision.UserIdentifier, decision.UserRole, "CrossTenant");
|
||||
@@ -206,7 +223,21 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
TenantId = jwtTenantId,
|
||||
TargetTenantId = targetTenantId,
|
||||
UserPrincipalName = decision.UserIdentifier,
|
||||
// ONLY a genuine JWT UPN lands here — never the "anonymous" placeholder that
|
||||
// decision.UserIdentifier carries on device/anonymous routes. The placeholder used to be
|
||||
// published here, which collapsed the entire agent fleet (every tenant) into one shared
|
||||
// user_ratelimit_anonymous bucket and 429'd agent telemetry fleet-wide. It stays on
|
||||
// decision.UserIdentifier for log lines, where "anonymous" is the useful rendering.
|
||||
UserPrincipalName = upn ?? string.Empty,
|
||||
// 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:
|
||||
// those authenticate fine and reach every AuthenticatedUser route, so keying the throttle
|
||||
// off the UPN alone would let them through unlimited. Fail closed if a token somehow
|
||||
// carries no identifying claim at all — a shared bucket is the safe answer there.
|
||||
CallerId = principal is null
|
||||
? string.Empty
|
||||
: (principal.GetCallerId() ?? UnidentifiedCallerBucket),
|
||||
IsGlobalAdmin = isGlobalAdmin,
|
||||
IsGlobalReader = isGlobalReader,
|
||||
// Delegated flags reflect the caller's role for THIS request (null unless a delegated grant
|
||||
|
||||
@@ -9,9 +9,14 @@ using Microsoft.Extensions.Logging;
|
||||
namespace AutopilotMonitor.Functions.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Per-user rate limiting for authenticated (JWT) requests.
|
||||
/// Runs after PolicyEnforcementMiddleware so RequestContext (UPN, IsGlobalAdmin) is already resolved.
|
||||
/// Agent/device routes have no RequestContext and are automatically skipped.
|
||||
/// Per-caller rate limiting for authenticated (JWT) requests.
|
||||
/// Runs after PolicyEnforcementMiddleware so RequestContext (CallerId, IsGlobalAdmin) is already resolved.
|
||||
/// Buckets on RequestContext.CallerId — the throttle identity, which is the UPN for a human caller and
|
||||
/// the token subject (oid/sub/appid) for a token that carries none. Device/bootstrap and anonymous
|
||||
/// routes resolve to an EMPTY CallerId and are skipped here; they carry their own limits (per cert
|
||||
/// thumbprint / per bootstrap token in SecurityValidator, per client IP on the public endpoints).
|
||||
/// Keying on CallerId rather than UserPrincipalName is what keeps BOTH holes shut: the agent fleet out
|
||||
/// of one shared bucket, and app-only tokens inside a bucket at all.
|
||||
/// </summary>
|
||||
public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
@@ -45,9 +50,12 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
}
|
||||
|
||||
var requestContext = context.GetRequestContext();
|
||||
if (string.IsNullOrEmpty(requestContext.UserPrincipalName))
|
||||
if (string.IsNullOrEmpty(requestContext.CallerId))
|
||||
{
|
||||
// Anonymous or device-auth route — no user to rate limit
|
||||
// No JWT on this request — device-auth or anonymous route. Those limit themselves
|
||||
// (per cert thumbprint / bootstrap token in SecurityValidator, per client IP on the
|
||||
// public endpoints). Gate on CallerId, NOT UserPrincipalName: a valid app-only token
|
||||
// carries no upn, and skipping on that would exempt it from throttling entirely.
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
@@ -96,13 +104,13 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
config.GlobalAdminRateLimitRequestsPerMinute,
|
||||
entitlementFloor);
|
||||
|
||||
var key = $"user_ratelimit_{requestContext.UserPrincipalName.ToLowerInvariant()}";
|
||||
var key = $"user_ratelimit_{requestContext.CallerId.ToLowerInvariant()}";
|
||||
result = _rateLimitService.CheckRateLimit(key, limit);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[UserRateLimit] Rate limit check failed for user={User}, allowing request (fail-open)",
|
||||
requestContext.UserPrincipalName);
|
||||
_logger.LogError(ex, "[UserRateLimit] Rate limit check failed for caller={Caller}, allowing request (fail-open)",
|
||||
requestContext.CallerId);
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
@@ -127,8 +135,8 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
: 60;
|
||||
|
||||
_logger.LogWarning(
|
||||
"[UserRateLimit] THROTTLED user={User} requests={Count}/{Max} retryAfter={RetryAfter}s",
|
||||
requestContext.UserPrincipalName, result.RequestsInWindow, result.MaxRequests, retryAfterSeconds);
|
||||
"[UserRateLimit] THROTTLED caller={Caller} requests={Count}/{Max} retryAfter={RetryAfter}s",
|
||||
requestContext.CallerId, result.RequestsInWindow, result.MaxRequests, retryAfterSeconds);
|
||||
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.TooManyRequests;
|
||||
httpContext.Response.Headers["Retry-After"] = retryAfterSeconds.ToString();
|
||||
|
||||
@@ -527,6 +527,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// the seed happens when the notification address is saved, so this converges to a
|
||||
/// no-op and can be removed once every existing tenant has been covered.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The enumerated snapshot is only a cheap pre-filter; the authoritative "is it still
|
||||
/// empty" check happens under an ETag inside
|
||||
/// <see cref="TenantConfigurationService.TrySeedContactEmailAsync"/>. A full-model save
|
||||
/// here would write a snapshot that is minutes old by the time the loop reaches this
|
||||
/// tenant, silently reverting whatever an admin changed in between.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>Number of tenants that received a contact address.</returns>
|
||||
private async Task<int> BackfillTenantContactEmailsAsync()
|
||||
@@ -546,9 +553,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
continue;
|
||||
|
||||
config.ContactEmail = email.Trim();
|
||||
await _tenantConfigService.SaveConfigurationAsync(config);
|
||||
filled++;
|
||||
if (await _tenantConfigService.TrySeedContactEmailAsync(config.TenantId, email))
|
||||
filled++;
|
||||
}
|
||||
|
||||
if (filled > 0)
|
||||
|
||||
@@ -16,16 +16,19 @@ public class PreviewWhitelistService
|
||||
private readonly IConfigRepository _configRepo;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<PreviewWhitelistService> _logger;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
public PreviewWhitelistService(
|
||||
IConfigRepository configRepo,
|
||||
IMemoryCache cache,
|
||||
ILogger<PreviewWhitelistService> logger)
|
||||
ILogger<PreviewWhitelistService> logger,
|
||||
TenantConfigurationService tenantConfigService)
|
||||
{
|
||||
_configRepo = configRepo;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -104,42 +107,15 @@ public class PreviewWhitelistService
|
||||
/// <summary>
|
||||
/// Saves (or clears) the notification email for a tenant, and seeds the tenant's
|
||||
/// contact address from it the first time one is given.
|
||||
/// <para>
|
||||
/// The seed is a side effect of a write that has already been persisted, so it must never
|
||||
/// fail this call — <see cref="TenantConfigurationService.TrySeedContactEmailAsync"/> is
|
||||
/// fail-soft and owns the "never overwrite what the tenant owns" invariant.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task SaveNotificationEmailAsync(string tenantId, string? email)
|
||||
{
|
||||
await _configRepo.SaveNotificationEmailAsync(tenantId, email);
|
||||
await SeedContactEmailAsync(tenantId, email);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-way seed: copies the preview notification address into
|
||||
/// <see cref="TenantConfiguration.ContactEmail"/> only while that field is still empty.
|
||||
/// Once the tenant owns a contact address — seeded or edited in the portal — this never
|
||||
/// touches it again, so a later change here cannot silently overwrite the tenant's choice.
|
||||
/// Best-effort: the notification email has already been persisted by the caller, and a
|
||||
/// failure to seed must not fail that write.
|
||||
/// </summary>
|
||||
private async Task SeedContactEmailAsync(string tenantId, string? email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var config = await _configRepo.GetTenantConfigurationAsync(tenantId);
|
||||
if (config == null || !string.IsNullOrWhiteSpace(config.ContactEmail))
|
||||
return;
|
||||
|
||||
config.ContactEmail = email.Trim();
|
||||
await _configRepo.SaveTenantConfigurationAsync(config);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Seeded tenant contact address for {TenantId} from the preview notification email", tenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Could not seed tenant contact address for {TenantId} — the notification email was still saved", tenantId);
|
||||
}
|
||||
await _tenantConfigService.TrySeedContactEmailAsync(tenantId, email);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,48 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-way seed of the tenant contact address: writes <paramref name="email"/> only while
|
||||
/// the tenant has none, and only if nothing else wrote the configuration in the meantime.
|
||||
/// Returns true when the seed landed.
|
||||
/// <para>
|
||||
/// Single owner for both seed paths (preview notification-email save and the maintenance
|
||||
/// backfill), because both need the same three things: the conditional single-property
|
||||
/// write, the "never overwrite what the tenant owns" invariant, and the cache invalidation
|
||||
/// below — the repository writes behind this cache, so without it a freshly seeded address
|
||||
/// stays invisible for the 5-minute TTL.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fail-soft by design, unlike <see cref="SaveConfigurationAsync"/>: every caller is a side
|
||||
/// effect of an operation that has already succeeded, and a lost seed is recoverable on the
|
||||
/// next maintenance run. Enforced here rather than left to the repository, so the guarantee
|
||||
/// holds for any implementation.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public virtual async Task<bool> TrySeedContactEmailAsync(string tenantId, string? email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(email))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var seeded = await _configRepo.TrySeedTenantContactEmailAsync(tenantId, email!.Trim());
|
||||
if (seeded)
|
||||
{
|
||||
_cache.Remove($"tenant-config:{tenantId}");
|
||||
_logger.LogInformation("Seeded contact address for tenant {TenantId}", tenantId);
|
||||
}
|
||||
|
||||
return seeded;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Could not seed contact address for tenant {TenantId} — the triggering write still stands", tenantId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates cache for a tenant (forces reload on next request)
|
||||
/// </summary>
|
||||
|
||||
+450
-357
File diff suppressed because it is too large
Load Diff
@@ -17,18 +17,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"compression": "^1.8.1",
|
||||
"express": "^5.2.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"zod": "^3.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"@types/node": "^22.20.1",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.2"
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"overrides": {
|
||||
"sharp": "^0.35.0",
|
||||
"fast-uri": "^3.1.4",
|
||||
"hono": "^4.12.27",
|
||||
"@hono/node-server": "^2.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,21 @@ describe('loadDocsCorpus', () => {
|
||||
expect(chunks.map((c) => c.metadata.heading)).toContain('General › Is Autopilot Monitor free?');
|
||||
});
|
||||
|
||||
it('strips inline HTML tags from a <summary> title, leaving no angle brackets', async () => {
|
||||
const chunks = await withBundle(
|
||||
{
|
||||
'troubleshooting/faq.md': page(
|
||||
'type: FAQ\ntags: [faq]',
|
||||
['# FAQ', '', '## General', '', '<details>', '', '<summary>Does <b>keep-awake</b> work during <i>ESP</i>?</summary>', '', 'Keep-awake is an opt-in behavior during the user ESP phase for supported devices.', '', '</details>'].join('\n'),
|
||||
),
|
||||
},
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
const headings = chunks.map((c) => c.metadata.heading);
|
||||
expect(headings).toContain('General › Does keep-awake work during ESP?');
|
||||
expect(headings.every((h) => h == null || (!h.includes('<') && !h.includes('>')))).toBe(true);
|
||||
});
|
||||
|
||||
it('never emits a heading from inside a fenced code block', async () => {
|
||||
const chunks = await withBundle(
|
||||
{
|
||||
|
||||
@@ -232,7 +232,7 @@ async function checkAccess(upn: string, token: string, clientIp: string): Promis
|
||||
}, MAX_ACCESS_CACHE_ENTRIES);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`[access-guard] Backend check failed for ${upn}:`, err);
|
||||
console.error('[access-guard] Backend check failed for %s:', upn, err);
|
||||
// Fail-closed: deny on backend error
|
||||
return { allowed: false, reason: 'Backend access check unavailable', isGlobalAdmin: false, isGlobalReader: false, infraError: true };
|
||||
}
|
||||
|
||||
@@ -217,6 +217,24 @@ interface Section {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove HTML tags from a string. The pattern is applied repeatedly until the
|
||||
* string stops changing, so tag removal can never re-form a tag from the joined
|
||||
* remainder. A single `.replace(/<[^>]+>/g, '')` happens to be complete for this
|
||||
* pattern, but the looped form is the shape CodeQL accepts as a robust sanitizer
|
||||
* (js/incomplete-multi-character-sanitization) and is defensive against any
|
||||
* future change to the tag regex.
|
||||
*/
|
||||
function stripHtmlTags(input: string): string {
|
||||
let prev: string;
|
||||
let out = input;
|
||||
do {
|
||||
prev = out;
|
||||
out = out.replace(/<[^>]+>/g, '');
|
||||
} while (out !== prev);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a page body into heading-delimited sections.
|
||||
*
|
||||
@@ -262,7 +280,7 @@ function splitIntoSections(body: string): Section[] {
|
||||
const summary = /^\s*<summary>(.*?)<\/summary>\s*$/i.exec(line);
|
||||
if (summary) {
|
||||
flush(detailsTitle ?? h3 ?? h2);
|
||||
detailsTitle = summary[1].replace(/<[^>]+>/g, '').trim();
|
||||
detailsTitle = stripHtmlTags(summary[1]).trim();
|
||||
continue;
|
||||
}
|
||||
if (/^\s*<\/details>\s*$/i.test(line)) {
|
||||
|
||||
@@ -24,6 +24,21 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
/// </summary>
|
||||
Task<RawPage<TenantConfiguration>> GetTenantConfigurationsPageAsync(int pageSize, string? continuation);
|
||||
|
||||
/// <summary>
|
||||
/// Writes <paramref name="email"/> to the tenant's ContactEmail ONLY while that field is
|
||||
/// still empty, and only if nothing else wrote the row in the meantime. Returns true when
|
||||
/// the seed landed, false when the tenant already owns an address, has no config row, or
|
||||
/// lost the race.
|
||||
/// <para>
|
||||
/// Exists because the seed cannot be expressed as a read-modify-write of the whole model:
|
||||
/// <see cref="SaveTenantConfigurationAsync"/> replaces the entire row unconditionally, so a
|
||||
/// concurrent portal save would be clobbered by the seeder's stale snapshot — of every
|
||||
/// field, not just this one. The implementation must therefore write conditionally and
|
||||
/// touch no other property.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
Task<bool> TrySeedTenantContactEmailAsync(string tenantId, string email);
|
||||
|
||||
// --- Admin Configuration ---
|
||||
Task<AdminConfiguration?> GetAdminConfigurationAsync();
|
||||
Task<bool> SaveAdminConfigurationAsync(AdminConfiguration config);
|
||||
|
||||
@@ -218,7 +218,7 @@ export default function AboutPage() {
|
||||
title: "Integrations",
|
||||
items: [
|
||||
"Microsoft Intune — agent deployment target",
|
||||
"Microsoft Teams, Slack & generic JSON webhooks — start, success, failure and SLA alerts",
|
||||
"Microsoft Teams, Slack & generic JSON webhooks — start, success, failure, hardware rejection and SLA alerts",
|
||||
"Intune Management Extension (IME) log — event source for log pattern detection",
|
||||
"WMI & Registry — extended data gather rules",
|
||||
],
|
||||
|
||||
+605
-512
File diff suppressed because it is too large
Load Diff
@@ -14,38 +14,39 @@
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^3.28.0",
|
||||
"@azure/msal-react": "^2.1.3",
|
||||
"@microsoft/applicationinsights-web": "^3.3.11",
|
||||
"@microsoft/applicationinsights-web": "^3.4.3",
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"dagre": "^0.8.5",
|
||||
"fflate": "^0.8.2",
|
||||
"fflate": "^0.8.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^15.1.6",
|
||||
"next": "^15.5.21",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"recharts": "^2.15.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^16.2.7",
|
||||
"@next/bundle-analyzer": "^16.2.11",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-next": "^15.1.6",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^15.5.21",
|
||||
"postcss": "^8.5.10",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.1.2"
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-win32-x64-msvc": "npm:null@*"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "^8.5.10",
|
||||
"@nevware21/ts-utils": "^0.14.0"
|
||||
"@nevware21/ts-utils": "^0.14.0",
|
||||
"sharp": "^0.35.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,13 +65,19 @@ const ALLOWED_USER_PROFILE_SUBDIRS = ["AppData\\Local", "AppData\\Roaming"];
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Escape a literal string so it can be embedded in a RegExp without its
|
||||
* characters being interpreted as regex metacharacters. */
|
||||
function escapeRegExp(literal: string): string {
|
||||
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Expand known Windows environment variables and custom tokens (case-insensitive). */
|
||||
function expandEnvVars(path: string): string {
|
||||
let result = path;
|
||||
// Expand custom agent token first
|
||||
result = result.replace(new RegExp(USER_PROFILE_TOKEN.replace(/%/g, "%"), "gi"), USER_PROFILE_PLACEHOLDER);
|
||||
result = result.replace(new RegExp(escapeRegExp(USER_PROFILE_TOKEN), "gi"), USER_PROFILE_PLACEHOLDER);
|
||||
for (const [envVar, replacement] of Object.entries(COMMON_ENV_VARS)) {
|
||||
const regex = new RegExp(envVar.replace(/%/g, "%"), "gi");
|
||||
const regex = new RegExp(escapeRegExp(envVar), "gi");
|
||||
result = result.replace(regex, replacement);
|
||||
}
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user