mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66f5543fb7 | |||
| 21d200a1e4 | |||
| 8c0cb2c423 |
+22
@@ -94,6 +94,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
Assert.Equal("interactive_console_with_command", info.Classification);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"cmd /k rem /c")]
|
||||
[InlineData(@"cmd.exe /k ""title /c""")]
|
||||
public void Cmd_k_with_slash_c_in_payload_is_not_demoted_to_scripted(string commandLine)
|
||||
{
|
||||
// cmd.exe honors only the FIRST /c-or-/k switch; a '/c' inside the /k payload is inert, so
|
||||
// the console is a persistent human-usable shell and must still be surfaced (low confidence).
|
||||
using var rig = new Rig(Probe(commandLine));
|
||||
|
||||
rig.Sut.HandleStart(2223, 1, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
|
||||
|
||||
var info = Assert.Single(rig.Detected);
|
||||
Assert.Equal("low", info.Confidence);
|
||||
Assert.Equal("interactive_console_with_command", info.Classification);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Instant_close_unreadable_commandline_raises_low_confidence()
|
||||
{
|
||||
@@ -146,6 +162,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
[InlineData(2, @"C:\x\cmd.exe /K bar", "InteractiveWithCommand")] // L12: /k stays interactive
|
||||
[InlineData(1, @"cmd.exe /k whoami", "InteractiveWithCommand")] // technician-planted shell
|
||||
[InlineData(1, @"cmd.exe /d/q/c exit 9", "Ignore")] // combined run-and-exit wins
|
||||
[InlineData(1, @"cmd /k rem /c", "InteractiveWithCommand")] // cmd.exe honors the FIRST switch: /k stays interactive
|
||||
[InlineData(1, @"cmd.exe /k ""title /c""", "InteractiveWithCommand")] // /c inside the /k payload is not a switch
|
||||
[InlineData(1, @"cmd /k /c", "InteractiveWithCommand")] // /c after /k is just the /k payload
|
||||
[InlineData(1, @"cmd.exe /c cmd /k", "Ignore")] // first switch /c wins the other way round
|
||||
public void Classify_maps_session_and_commandline(int sessionId, string? commandLine, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ConsoleBypassWatcher.Classify(sessionId, commandLine).ToString());
|
||||
@@ -165,6 +185,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
[InlineData(@"cmd.exe /k", false)] // L12: /k leaves an interactive shell
|
||||
[InlineData(@"cmd.exe /d/q/c exit 9", true)] // combined switches — the field case
|
||||
[InlineData(@"cmd.exe /D/Q/K stay", false)] // L12: run-and-stay is not scripted
|
||||
[InlineData(@"cmd.exe /k rem /c", false)] // first switch /k wins — payload /c is inert
|
||||
[InlineData(@"cmd.exe /c rem /k", true)] // first switch /c wins
|
||||
[InlineData(@"""C:\WINDOWS\system32\cmd.exe"" /c ""x.bat""", true)]
|
||||
public void HasScriptArgument_detects_run_command_switch(string commandLine, bool expected)
|
||||
{
|
||||
|
||||
+30
-18
@@ -47,17 +47,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
/// </summary>
|
||||
internal static readonly string[] WatchedProcessNames = { "cmd.exe" };
|
||||
|
||||
// /c turns the shell into a one-shot scripted invocation (install/script) — ignored. Matched
|
||||
// as a token anywhere in the ARGUMENTS (the executable path is stripped first) so combined
|
||||
// switches like /d/q/c are caught too.
|
||||
private static readonly Regex RunAndExitSwitchRegex =
|
||||
new Regex(@"/c\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// cmd.exe honors only the FIRST of /c or /k; everything after that switch is the command
|
||||
// payload. /c turns the shell into a one-shot scripted invocation (install/script) — ignored.
|
||||
// /k runs its command and then STAYS interactive (L12) — unlike /c this leaves a shell a
|
||||
// human can type into (e.g. a technician-planted `cmd /k whoami`), so it is surfaced as a
|
||||
// low-confidence interactive console instead of being silently ignored.
|
||||
private static readonly Regex RunAndStaySwitchRegex =
|
||||
new Regex(@"/k\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
// low-confidence interactive console instead of being silently ignored. One ordered scan over
|
||||
// the ARGUMENTS (executable path stripped first) finds the first switch token, so combined
|
||||
// switches like /d/q/c are caught and a `/c` inside a /k payload (`cmd /k rem /c`) can no
|
||||
// longer demote an interactive shell to a scripted one.
|
||||
private static readonly Regex FirstRunSwitchRegex =
|
||||
new Regex(@"/(?<sw>[ck])\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private enum RunSwitch { None, RunAndExit, RunAndStay }
|
||||
|
||||
/// <summary>The first <c>/c</c> or <c>/k</c> switch in the argument tail — cmd.exe precedence.</summary>
|
||||
private static RunSwitch FirstRunSwitch(string arguments)
|
||||
{
|
||||
var m = FirstRunSwitchRegex.Match(arguments);
|
||||
if (!m.Success) return RunSwitch.None;
|
||||
return char.ToLowerInvariant(m.Groups["sw"].Value[0]) == 'c' ? RunSwitch.RunAndExit : RunSwitch.RunAndStay;
|
||||
}
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly Func<int, ProcessProbe?> _processProbe;
|
||||
@@ -282,20 +291,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
{
|
||||
if (sessionId == 0) return ConsoleVerdict.Ignore;
|
||||
if (commandLine == null) return ConsoleVerdict.UnclassifiedInteractive;
|
||||
var arguments = StripExecutable(commandLine);
|
||||
if (RunAndExitSwitchRegex.IsMatch(arguments)) return ConsoleVerdict.Ignore;
|
||||
if (RunAndStaySwitchRegex.IsMatch(arguments)) return ConsoleVerdict.InteractiveWithCommand;
|
||||
return ConsoleVerdict.InteractiveConsole;
|
||||
switch (FirstRunSwitch(StripExecutable(commandLine)))
|
||||
{
|
||||
case RunSwitch.RunAndExit: return ConsoleVerdict.Ignore;
|
||||
case RunSwitch.RunAndStay: return ConsoleVerdict.InteractiveWithCommand;
|
||||
default: return ConsoleVerdict.InteractiveConsole;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when the command line carries a <c>/c</c> run-and-exit switch (a scripted
|
||||
/// invocation), false for a bare interactive shell or a <c>/k</c> run-and-stay shell. The
|
||||
/// executable token is stripped first so a path can never spoof a switch, and switches may
|
||||
/// be combined (e.g. <c>cmd /d/q/c exit 9</c>).</summary>
|
||||
/// <summary>True when the FIRST <c>/c</c>-or-<c>/k</c> switch on the command line is <c>/c</c>
|
||||
/// (a scripted run-and-exit invocation, mirroring cmd.exe precedence), false for a bare
|
||||
/// interactive shell or a <c>/k</c> run-and-stay shell — even one whose payload contains a
|
||||
/// later <c>/c</c>. The executable token is stripped first so a path can never spoof a
|
||||
/// switch, and switches may be combined (e.g. <c>cmd /d/q/c exit 9</c>).</summary>
|
||||
internal static bool HasScriptArgument(string commandLine)
|
||||
{
|
||||
if (string.IsNullOrEmpty(commandLine)) return false;
|
||||
return RunAndExitSwitchRegex.IsMatch(StripExecutable(commandLine));
|
||||
return FirstRunSwitch(StripExecutable(commandLine)) == RunSwitch.RunAndExit;
|
||||
}
|
||||
|
||||
/// <summary>Returns the command line with the leading executable token (quoted or
|
||||
|
||||
@@ -6,6 +6,11 @@ using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
@@ -26,6 +31,7 @@ public class AuthFunctionSideEffectTests
|
||||
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
|
||||
private readonly Mock<IMetricsRepository> _metricsRepoMock;
|
||||
private readonly Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer> _autoApproveEnqueuerMock;
|
||||
private readonly FakeSignalRNotificationService _signalR;
|
||||
private readonly AuthFunction _sut;
|
||||
|
||||
public AuthFunctionSideEffectTests()
|
||||
@@ -82,6 +88,7 @@ public class AuthFunctionSideEffectTests
|
||||
_metricsRepoMock = new Mock<IMetricsRepository>();
|
||||
_autoApproveEnqueuerMock = new Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer>();
|
||||
|
||||
_signalR = new FakeSignalRNotificationService();
|
||||
_sut = new AuthFunction(
|
||||
Mock.Of<ILogger<AuthFunction>>(),
|
||||
globalAdminMock.Object,
|
||||
@@ -98,7 +105,8 @@ public class AuthFunctionSideEffectTests
|
||||
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
|
||||
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()),
|
||||
new Mock<AdminIdentityResolver>(
|
||||
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object);
|
||||
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
|
||||
_signalR);
|
||||
|
||||
// Default: all fire-and-forget calls succeed
|
||||
_tenantConfigMock
|
||||
@@ -123,6 +131,63 @@ public class AuthFunctionSideEffectTests
|
||||
// HandleNewTenantDomainAsync
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// ── RemoveGlobalAdmin cuts live SignalR streams (join-time-only group authz) ──
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveGlobalAdmin_DisconnectsTheRemovedAdminsSignalRConnections()
|
||||
{
|
||||
var (req, ctx) = BuildAuthenticatedRequest(callerUpn: "operator@contoso.com");
|
||||
|
||||
var response = await _sut.RemoveGlobalAdmin(req, "Removed@Contoso.com", ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new[] { "removed@contoso.com" }, _signalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveGlobalAdmin_SelfRemovalIsRejectedWithoutDisconnect()
|
||||
{
|
||||
var (req, ctx) = BuildAuthenticatedRequest(callerUpn: "operator@contoso.com");
|
||||
|
||||
var response = await _sut.RemoveGlobalAdmin(req, "operator@contoso.com", ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Empty(_signalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildAuthenticatedRequest(string callerUpn)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
services.Configure<WorkerOptions>(o => o.Serializer =
|
||||
new Azure.Core.Serialization.JsonObjectSerializer(
|
||||
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[] { new Claim("preferred_username", callerUpn) }, "test"));
|
||||
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(
|
||||
new Dictionary<object, object> { ["ClaimsPrincipal"] = principal });
|
||||
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
||||
|
||||
var req = new Mock<HttpRequestData>(context.Object);
|
||||
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
|
||||
req.SetupGet(r => r.Body).Returns(new MemoryStream());
|
||||
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context.Object));
|
||||
return (req.Object, context.Object);
|
||||
}
|
||||
|
||||
private sealed class FakeHttpResponseData : HttpResponseData
|
||||
{
|
||||
public FakeHttpResponseData(FunctionContext context) : base(context) { }
|
||||
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
public override HttpHeadersCollection Headers { get; set; } = new();
|
||||
public override Stream Body { get; set; } = new MemoryStream();
|
||||
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_WhenUpnDomainIsNotAHostName_DoesNotSeed()
|
||||
{
|
||||
|
||||
@@ -133,12 +133,64 @@ public class TenantAdminManagementFunctionTests
|
||||
TenantId, CallerUpn, Constants.TenantRoles.Admin, true), Times.Once);
|
||||
}
|
||||
|
||||
// ── revocation cuts live SignalR streams (join-time-only group authz) ────
|
||||
|
||||
[Fact]
|
||||
public async Task Remove_disconnects_the_removed_members_signalr_connections()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
var (req, ctx) = BuildRequest(new { });
|
||||
|
||||
var response = await h.Function.RemoveTenantAdmin(req, TenantId, "Demoted@Contoso.com", ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new[] { "demoted@contoso.com" }, h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disable_disconnects_the_disabled_members_signalr_connections()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
var (req, ctx) = BuildRequest(new { });
|
||||
|
||||
var response = await h.Function.DisableTenantAdmin(req, TenantId, "Demoted@Contoso.com", ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new[] { "demoted@contoso.com" }, h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enable_does_not_disconnect()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
var (req, ctx) = BuildRequest(new { });
|
||||
|
||||
await h.Function.EnableTenantAdmin(req, TenantId, "user@contoso.com", ctx);
|
||||
|
||||
Assert.Empty(h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Demotion_below_admin_disconnects_but_promotion_to_admin_does_not()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
|
||||
var (demote, ctx1) = BuildRequest(new { role = "viewer", canManageBootstrapTokens = false });
|
||||
await h.Function.UpdateMemberPermissions(demote, TenantId, "User@Contoso.com", ctx1);
|
||||
Assert.Equal(new[] { "user@contoso.com" }, h.SignalR.DisconnectedUsers);
|
||||
|
||||
var (promote, ctx2) = BuildRequest(new { role = "admin", canManageBootstrapTokens = false });
|
||||
await h.Function.UpdateMemberPermissions(promote, TenantId, "other@contoso.com", ctx2);
|
||||
Assert.Equal(new[] { "user@contoso.com" }, h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
// ── harness ─────────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public TenantAdminManagementFunction Function = default!;
|
||||
public Mock<IAdminRepository> AdminRepo = default!;
|
||||
public FakeSignalRNotificationService SignalR = default!;
|
||||
}
|
||||
|
||||
private static Harness BuildHarness()
|
||||
@@ -157,12 +209,14 @@ public class TenantAdminManagementFunctionTests
|
||||
NullLogger<TenantAdminsService>.Instance);
|
||||
|
||||
var maintenanceRepo = new Mock<IMaintenanceRepository>(MockBehavior.Loose);
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
|
||||
return new Harness
|
||||
{
|
||||
Function = new TenantAdminManagementFunction(
|
||||
NullLogger<TenantAdminManagementFunction>.Instance, service, maintenanceRepo.Object),
|
||||
NullLogger<TenantAdminManagementFunction>.Instance, service, maintenanceRepo.Object, signalR),
|
||||
AdminRepo = adminRepo,
|
||||
SignalR = signalR,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -680,6 +680,51 @@ public class TenantConfigPatchServiceTests
|
||||
Assert.Equal(schema.Count, schema.Select(f => f.Name).Distinct().Count());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The full-model PUT (config/{tenantId}) must honour the same deny-list as the field
|
||||
/// patch: a client body carrying ProDowngradedUtc (retention-downgrade grace anchor),
|
||||
/// OnboardedAt, DomainName, plan/trial or provenance fields must not change the stored
|
||||
/// values. Driven by the deny-list itself so a new denied field is covered automatically.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PreserveDeniedFields_OverwritesEveryDeniedModelPropertyFromStored()
|
||||
{
|
||||
var props = typeof(TenantConfiguration)
|
||||
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||
.Where(p => p.CanWrite && TenantConfigPatchService.BaseDeniedFields.Contains(p.Name))
|
||||
.ToList();
|
||||
Assert.Contains(props, p => p.Name == "ProDowngradedUtc");
|
||||
Assert.Contains(props, p => p.Name == "OnboardedAt");
|
||||
Assert.Contains(props, p => p.Name == "DomainName");
|
||||
|
||||
var stored = new TenantConfiguration { TenantId = "t1" };
|
||||
var incoming = new TenantConfiguration { TenantId = "t1" };
|
||||
foreach (var p in props)
|
||||
{
|
||||
p.SetValue(stored, Distinct(p.PropertyType, 1));
|
||||
p.SetValue(incoming, Distinct(p.PropertyType, 2));
|
||||
Assert.NotEqual(p.GetValue(stored), p.GetValue(incoming));
|
||||
}
|
||||
|
||||
TenantConfigPatchService.PreserveDeniedFields(incoming, stored);
|
||||
|
||||
foreach (var p in props)
|
||||
Assert.Equal(p.GetValue(stored), p.GetValue(incoming));
|
||||
}
|
||||
|
||||
private static object Distinct(Type t, int seed)
|
||||
{
|
||||
var u = Nullable.GetUnderlyingType(t) ?? t;
|
||||
if (u == typeof(string)) return $"value-{seed}";
|
||||
if (u == typeof(bool)) return seed % 2 == 0;
|
||||
if (u == typeof(int)) return seed;
|
||||
if (u == typeof(long)) return (long)seed;
|
||||
if (u == typeof(DateTime)) return new DateTime(2026, 1, seed, 0, 0, 0, DateTimeKind.Utc);
|
||||
if (u == typeof(DateTimeOffset)) return new DateTimeOffset(2026, 1, seed, 0, 0, 0, TimeSpan.Zero);
|
||||
if (u.IsEnum) return Enum.GetValues(u).GetValue(seed % Enum.GetValues(u).Length)!;
|
||||
throw new NotSupportedException($"Add a Distinct() case for {u.Name}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FieldsSchema_DeniedFields_MatchThePatchDenyList()
|
||||
{
|
||||
|
||||
+23
-1
@@ -18,17 +18,31 @@ public class TenantAdminManagementFunction
|
||||
private readonly ILogger<TenantAdminManagementFunction> _logger;
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
|
||||
public TenantAdminManagementFunction(
|
||||
ILogger<TenantAdminManagementFunction> logger,
|
||||
TenantAdminsService tenantAdminsService,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
ISignalRNotificationService signalRService)
|
||||
{
|
||||
_logger = logger;
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_signalRService = signalRService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SignalR group authorization is evaluated only at join time (SignalRAddToGroupFunction), so a
|
||||
/// member whose role was removed, disabled or lowered would keep their already-joined
|
||||
/// 'tenant-{tid}' broadcast, notify-group and session-group streams until the socket drops.
|
||||
/// Cutting the UPN's connections (negotiate binds userId = lowercased UPN) forces a reconnect that
|
||||
/// re-runs the join gates against the new role. Coarse by design — a still-authorized client
|
||||
/// simply reconnects — and mirrors the delegated-admin / tenant-group revoke paths.
|
||||
/// </summary>
|
||||
private Task CutLiveStreamsAsync(string adminUpn) =>
|
||||
_signalRService.DisconnectUserAsync(adminUpn.ToLowerInvariant());
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/tenants/{tenantId}/admins
|
||||
/// Gets all admins for a tenant
|
||||
@@ -158,6 +172,8 @@ public class TenantAdminManagementFunction
|
||||
upn!
|
||||
);
|
||||
|
||||
await CutLiveStreamsAsync(adminUpn);
|
||||
|
||||
_logger.LogInformation($"Tenant Admin removed: {adminUpn} from tenant {requestCtx.TargetTenantId} by {upn}");
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
@@ -194,6 +210,8 @@ public class TenantAdminManagementFunction
|
||||
new Dictionary<string, string> { { "Action", "Disable" } }
|
||||
);
|
||||
|
||||
await CutLiveStreamsAsync(adminUpn);
|
||||
|
||||
_logger.LogInformation($"Tenant Admin disabled: {adminUpn} for tenant {requestCtx.TargetTenantId} by {upn}");
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
@@ -308,6 +326,10 @@ public class TenantAdminManagementFunction
|
||||
}
|
||||
);
|
||||
|
||||
// Only Admin holds the admin-tier notify group; any other target role may have lost it.
|
||||
if (role != AutopilotMonitor.Shared.Constants.TenantRoles.Admin)
|
||||
await CutLiveStreamsAsync(adminUpn);
|
||||
|
||||
_logger.LogInformation("Member permissions updated: {AdminUpn} -> role={Role} in tenant {TenantId} by {Upn}", adminUpn, role, requestCtx.TargetTenantId, upn);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
+9
-35
@@ -110,11 +110,15 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// admins are served the FULL config so this is normally a no-op for them.)
|
||||
config.RestoreRedactedSecretsFrom(existingConfig);
|
||||
|
||||
// DomainName is tenant identity: seeded once from the first login's UPN (AuthFunction)
|
||||
// and never client-writable — for ANY caller, GA included — mirroring the patch flow
|
||||
// ("identity — never writable"). Without this preserve the full-model PUT persisted an
|
||||
// arbitrary string here, which is rendered into the welcome/farewell mails.
|
||||
config.DomainName = existingConfig.DomainName;
|
||||
// System-owned fields are never client-writable — for ANY caller, GA included:
|
||||
// identity (DomainName, seeded once from the first login's UPN), onboarding/auth
|
||||
// provenance (OnboardedAt/OnboardedBy/LastAuthClientId*), app-reg homing
|
||||
// (HomedAppClientId — the 2026-07-31 stale-round-trip incident), and plan/trial/
|
||||
// downgrade state (PlanTier/Trial*/ProDowngradedUtc — mutable only via the plan
|
||||
// and trial endpoints; a client-supplied ProDowngradedUtc would refresh the
|
||||
// retention-downgrade grace anchor indefinitely). The set is the PATCH endpoint's
|
||||
// BaseDeniedFields, so the two write paths cannot drift.
|
||||
TenantConfigPatchService.PreserveDeniedFields(config, existingConfig);
|
||||
|
||||
// Shared model validation (rate limits, contact address, webhook/Teams SSRF, custom
|
||||
// headers, notification channels, diagnostics SAS, retention cap) — single source
|
||||
@@ -164,26 +168,6 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
config.DisabledUntil = existingConfig.DisabledUntil;
|
||||
}
|
||||
|
||||
// App-reg homing is IMMUTABLE via the generic PUT — for ALL callers, GA included.
|
||||
// The only writers are the consent-driven auto-flip and POST config/{tenantId}/
|
||||
// app-homing (AppHomingFunction). Prod incident 2026-07-31 18:03Z: the consent
|
||||
// auto-flip landed, and 600ms later the frontend's routine "persist validation
|
||||
// toggle" PUT — round-tripping a config loaded BEFORE the flip — silently reverted
|
||||
// the homing to legacy, because a GA caller's stale null was indistinguishable
|
||||
// from an intentional revert. A full-model PUT can never carry that intent.
|
||||
config.HomedAppClientId = existingConfig.HomedAppClientId;
|
||||
|
||||
// Last-seen auth provenance is system-written (AuthFunction) — never via PUT,
|
||||
// for ANY caller: a round-tripped stale view must not rewind the observability.
|
||||
config.LastAuthClientId = existingConfig.LastAuthClientId;
|
||||
config.LastAuthClientIdSince = existingConfig.LastAuthClientIdSince;
|
||||
|
||||
// OnboardedBy is system-written once at first login (AuthFunction) and immutable
|
||||
// after that — never via PUT, for ANY caller: a revoke → re-approve cycle
|
||||
// auto-promotes whatever UPN is stored here, so a client-supplied value could
|
||||
// smuggle in an arbitrary promotable user (or null out the audit provenance).
|
||||
config.OnboardedBy = existingConfig.OnboardedBy;
|
||||
|
||||
// Safety: if GA gate is off, force UnrestrictedMode to false
|
||||
if (!config.UnrestrictedModeEnabled)
|
||||
{
|
||||
@@ -193,16 +177,6 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// MaxNdjsonPayloadSizeMB is table-only — always preserve existing value
|
||||
config.MaxNdjsonPayloadSizeMB = existingConfig.MaxNdjsonPayloadSizeMB;
|
||||
|
||||
// Plan/trial fields are mutable ONLY via the dedicated plan/trial endpoints
|
||||
// (PATCH config/{tenantId}/plan, POST config/{tenantId}/trial). The generic PUT
|
||||
// deserializes the full model, so without this preserve a round-tripped stale
|
||||
// view would silently reset the tenant's edition/trial — for ALL callers, GA included.
|
||||
config.PlanTier = existingConfig.PlanTier;
|
||||
config.TrialExpiresUtc = existingConfig.TrialExpiresUtc;
|
||||
config.TrialStartedUtc = existingConfig.TrialStartedUtc;
|
||||
config.TrialConsumed = existingConfig.TrialConsumed;
|
||||
config.TrialGrantedBy = existingConfig.TrialGrantedBy;
|
||||
|
||||
// Save configuration (retention cap already enforced by ValidateModel above)
|
||||
var writeSource = ResolveWriteSource(req.Query["intent"]);
|
||||
await _configService.SaveConfigurationAsync(config, writeSource, null);
|
||||
|
||||
@@ -34,6 +34,7 @@ public class AuthFunction
|
||||
private readonly Services.Activation.ITenantAutoApproveEnqueuer _tenantAutoApproveEnqueuer;
|
||||
private readonly EntraAppRegistry _appRegistry;
|
||||
private readonly AdminIdentityResolver _identityResolver;
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
|
||||
public AuthFunction(
|
||||
ILogger<AuthFunction> logger,
|
||||
@@ -48,10 +49,12 @@ public class AuthFunction
|
||||
McpUserService mcpUserService,
|
||||
Services.Activation.ITenantAutoApproveEnqueuer tenantAutoApproveEnqueuer,
|
||||
EntraAppRegistry appRegistry,
|
||||
AdminIdentityResolver identityResolver)
|
||||
AdminIdentityResolver identityResolver,
|
||||
ISignalRNotificationService signalRService)
|
||||
{
|
||||
_logger = logger;
|
||||
_identityResolver = identityResolver;
|
||||
_signalRService = signalRService;
|
||||
_globalAdminService = globalAdminService;
|
||||
_delegatedAdminService = delegatedAdminService;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
@@ -316,6 +319,12 @@ public class AuthFunction
|
||||
|
||||
await _globalAdminService.RemoveGlobalAdminAsync(upn);
|
||||
|
||||
// SignalR group authorization is join-time only: a removed Global Admin/Reader with a live
|
||||
// connection in 'global-admins' would keep receiving cross-tenant pushes until the socket
|
||||
// drops. Cut the UPN's connections (negotiate binds userId = lowercased UPN); a reconnect
|
||||
// re-runs the join gates against the now-missing platform role.
|
||||
await _signalRService.DisconnectUserAsync(upn.ToLowerInvariant());
|
||||
|
||||
_logger.LogInformation($"Global Admin removed: {upn} by {currentUpn}");
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
@@ -127,6 +127,25 @@ namespace AutopilotMonitor.Functions.Services
|
||||
"ProDowngradedUtc",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Full-model PUT counterpart of <see cref="BaseDeniedFields"/>: copies every denied model
|
||||
/// property from the stored config onto the incoming one so a client body can never write
|
||||
/// them — for ANY caller, GA included. The PUT deserializes the whole model, so a hand-kept
|
||||
/// preserve list drifts (ProDowngradedUtc/OnboardedAt were missing, letting a tenant admin
|
||||
/// refresh the retention-downgrade grace anchor forever). Deriving it from the single
|
||||
/// deny-list keeps both write paths in lock-step. Server-stamped fields (TenantId,
|
||||
/// UpdatedBy, LastUpdated) are copied too; the PUT overwrites them afterwards.
|
||||
/// </summary>
|
||||
internal static void PreserveDeniedFields(TenantConfiguration target, TenantConfiguration stored)
|
||||
{
|
||||
foreach (var name in BaseDeniedFields)
|
||||
{
|
||||
// PartitionKey/RowKey/Timestamp/ETag are table plumbing, not model properties.
|
||||
if (ModelProperties.TryGetValue(name, out var prop))
|
||||
prop.SetValue(target, prop.GetValue(stored));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Additional deny-list for non-GA tiers (phase 2): the GA-only toggles the PUT
|
||||
/// endpoint silently reverts for tenant admins are an explicit 400 here.
|
||||
|
||||
Reference in New Issue
Block a user