Compare commits

..

3 Commits

9 changed files with 279 additions and 57 deletions
@@ -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)
{
@@ -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()
{
@@ -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);
@@ -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.