diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationChangedFieldsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationChangedFieldsTests.cs index 26176dea..c085d360 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationChangedFieldsTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationChangedFieldsTests.cs @@ -273,4 +273,30 @@ public class AdminConfigurationChangedFieldsTests Assert.NotNull(typeError); Assert.DoesNotContain("secret.example", typeError); } + + [Fact] + public void Ops_channels_refuse_a_second_push_channel_but_keep_a_stored_pair_saveable() + { + var one = OpsPushChannels("phones"); + var pair = OpsPushChannels("phones", "phones-2"); + + var draft = AdminConfiguration.CreateDefault(); + draft.OpsNotificationChannelsJson = one; + Assert.Equal( + "Invalid ops notification channels: only one Push channel is allowed — a single channel already reaches every paired device.", + PatchAdminConfigurationFunction.ApplyFields(draft, new JObject { ["opsNotificationChannelsJson"] = pair })); + + // A pair stored before the rule saves unchanged and may be reduced. + var stored = AdminConfiguration.CreateDefault(); + stored.OpsNotificationChannelsJson = pair; + Assert.Null(PatchAdminConfigurationFunction.ApplyFields(stored, new JObject { ["opsNotificationChannelsJson"] = pair })); + Assert.Null(PatchAdminConfigurationFunction.ApplyFields(stored, new JObject { ["opsNotificationChannelsJson"] = one })); + } + + private static string OpsPushChannels(params string[] ids) + => AutopilotMonitor.Shared.Models.Notifications.NotificationChannel.SerializeList(ids.Select(id => + new AutopilotMonitor.Shared.Models.Notifications.NotificationChannel + { + Id = id, Name = id, ProviderType = (int)AutopilotMonitor.Shared.Models.Notifications.WebhookProviderType.Push, Enabled = true, + })); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs index e0d43f39..8b88d728 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs @@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.Tests.Offboarding; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models.Offboarding; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Functions.Services.Push; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Caching.Memory; @@ -41,6 +42,8 @@ public class AuthFunctionSideEffectTests private readonly FakeSignalRNotificationService _signalR; private readonly FakeOffboardingAuditRepository _offboardingRepo = new(); private readonly StoredTenantConfig _store; + private readonly Mock _globalAdminMock; + private readonly Func _buildSut; private readonly AuthFunction _sut; public AuthFunctionSideEffectTests() @@ -56,9 +59,10 @@ public class AuthFunctionSideEffectTests { CallBase = false }; var bindings = new StubAdminIdentityBindingService(bound: true); - var globalAdminMock = new Mock( + _globalAdminMock = new Mock( adminRepo, bindings, cache, Mock.Of>()) { CallBase = false }; + var globalAdminMock = _globalAdminMock; var delegatedAdminMock = new Mock( adminRepo, @@ -102,7 +106,7 @@ public class AuthFunctionSideEffectTests _opsEventsMock = new Mock(null!, null!, null!) { CallBase = false }; _opsEventsMock.Setup(x => x.RecordTenantSignupAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); - _sut = new AuthFunction( + _buildSut = pushSignIn => new AuthFunction( Mock.Of>(), globalAdminMock.Object, delegatedAdminMock.Object, @@ -122,7 +126,9 @@ public class AuthFunctionSideEffectTests _signalR, adminConfigService.Object, _offboardingRepo, + pushSignIn: pushSignIn, opsEvents: _opsEventsMock.Object); + _sut = _buildSut(null); // Writes go through UpdateAsync / CreateOrUpdateAsync over one stored row (none by default: // a brand-new tenant). Default: all fire-and-forget calls succeed. @@ -860,7 +866,7 @@ public class AuthFunctionSideEffectTests /// A validated token as the middleware hands it on: tid, oid and idp only under their mapped names. The /// member's idp names its own tenant, the guest's another one — the same principal shape otherwise. /// - private static ClaimsPrincipal SignedIn(bool external) => new(new ClaimsIdentity(new[] + private static ClaimsPrincipal SignedIn(bool external, params string[] appRoles) => new(new ClaimsIdentity(new[] { new Claim("http://schemas.microsoft.com/identity/claims/tenantid", TenantId), new Claim("http://schemas.microsoft.com/identity/claims/objectidentifier", ObjectId), @@ -868,7 +874,7 @@ public class AuthFunctionSideEffectTests new Claim("http://schemas.microsoft.com/identity/claims/identityprovider", external ? "https://sts.windows.net/bbbbbbbb-1111-2222-3333-444444444444/" : $"https://sts.windows.net/{TenantId}/"), - }, "test")); + }.Concat(appRoles.Select(role => new Claim("roles", role))), "test")); private void ArrangeAuthMe(TenantConfiguration config, bool exists, bool approved) { @@ -933,4 +939,69 @@ public class AuthFunctionSideEffectTests external ? Times.Never() : Times.Once()); Assert.Contains(external ? "\"isTenantAdmin\":false" : "\"isTenantAdmin\":true", await ReadBodyAsync(response)); } + + // ------------------------------------------------------------------------- + // GetCurrentUser: the push sign-in (stamp with the app-role claim, resume) + // ------------------------------------------------------------------------- + + /// An onboarded, activated tenant with app roles on and an admin already present (no auto-admin). + private void ArrangeActivatedTenantWithAppRoles(TableMemberState memberRow) + { + var config = DefaultConfig(); + config.DomainName = "contoso.com"; + config.EntraAppRolesEnabled = true; + ArrangeAuthMe(config, exists: true, approved: true); + _tenantAdminsMock.Setup(x => x.GetTenantAdminsAsync(TenantId)) + .ReturnsAsync(new List { new() { TenantId = TenantId, Upn = "first@contoso.com" } }); + _tenantAdminsMock.Setup(x => x.GetTableMembershipAsync(TenantId, Upn)).ReturnsAsync((memberRow, + memberRow == TableMemberState.Enabled ? new MemberRoleInfo { Role = Constants.TenantRoles.Operator } : null)); + } + + private (AuthFunction Sut, Push.PushDeliveryServiceTests.Harness Push) BuildWithPushSignIn() + { + var push = new Push.PushDeliveryServiceTests.Harness(); + var signIn = new PushOwnerSignInService(push.Repo, new Mock().Object, push.Settings, push.Sut, + Mock.Of>()); + return (_buildSut(signIn), push); + } + + [Theory] + [InlineData(TableMemberState.NotPresent, Constants.TenantRoles.Admin)] + [InlineData(TableMemberState.Enabled, null)] + [InlineData(TableMemberState.Disabled, null)] + public async Task GetCurrentUser_PushStamp_CarriesTheAppRoleClaimOnlyWithoutAMemberRow(TableMemberState memberRow, string? expected) + { + ArrangeActivatedTenantWithAppRoles(memberRow); + var (sut, push) = BuildWithPushSignIn(); + var (req, ctx) = BuildRequest(SignedIn(external: false, Constants.TenantRoles.Admin)); + + var response = await sut.GetCurrentUser(req, ctx); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var owner = push.Repo.Owners[(TenantId, ObjectId)]; + Assert.Equal(expected, owner.ClaimRole); + Assert.Equal(Upn, owner.Upn); + } + + [Fact] + public async Task GetCurrentUser_GlobalAdminBackFromInactivity_ResumesTheirPlatformPause() + { + ArrangeActivatedTenantWithAppRoles(TableMemberState.Enabled); + _globalAdminMock.Setup(x => x.GetGlobalRoleAsync(It.IsAny())).ReturnsAsync(Constants.GlobalRoles.GlobalAdmin); + var (sut, push) = BuildWithPushSignIn(); + push.Repo.Owners[(TenantId, ObjectId)] = new PushOwner + { + HomeTenantId = TenantId, ObjectId = ObjectId, Upn = Upn, LastSignInUtc = DateTime.UtcNow.AddDays(-45), + }; + var device = push.AddDevice("ga-phone", Constants.Push.DeviceStatus.Paused, upn: Upn, + scope: Constants.Push.PlatformScope, statusReason: "owner_inactive"); + device.OwnerObjectId = ObjectId; + device.OwnerHomeTenantId = TenantId; + var (req, ctx) = BuildRequest(SignedIn(external: false)); + + var response = await sut.GetCurrentUser(req, ctx); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(Constants.Push.DeviceStatus.Active, push.Repo.Devices[(Constants.Push.PlatformScope, "ga-phone")].Status); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GenericWebhookTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GenericWebhookTests.cs index a5c76646..a0ee8714 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GenericWebhookTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GenericWebhookTests.cs @@ -64,6 +64,21 @@ public class GenericWebhookTests Assert.Equal(WebhookProviderType.GenericJson, new GenericJsonRenderer().ProviderType); } + [Fact] + public void GenericJsonRenderer_SlaAlert_CarriesTheTenantNameAndTheTenantIdAsFacts() + { + var alert = NotificationAlertBuilder.BuildSlaResolvedAlert("11111111-1111-1111-1111-111111111111", "Contoso", + "Duration", 42, 60, firstBreachAt: null, resolvedAt: new DateTime(2026, 10, 20, 9, 0, 0, DateTimeKind.Utc)); + + using var doc = JsonDocument.Parse(new GenericJsonRenderer().RenderToJson(alert)); + var facts = doc.RootElement.GetProperty("facts"); + + Assert.Equal("Tenant", facts[0].GetProperty("name").GetString()); + Assert.Equal("Contoso", facts[0].GetProperty("value").GetString()); + Assert.Equal("Tenant ID", facts[1].GetProperty("name").GetString()); + Assert.Equal("11111111-1111-1111-1111-111111111111", facts[1].GetProperty("value").GetString()); + } + [Theory] [InlineData(true, "enrollment_succeeded")] [InlineData(false, "enrollment_failed")] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/NotificationAlertBuilderSlaTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/NotificationAlertBuilderSlaTests.cs new file mode 100644 index 00000000..04b5fbbe --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/NotificationAlertBuilderSlaTests.cs @@ -0,0 +1,82 @@ +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Models.Notifications; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// The SLA alerts name the tenant the way its people know it: the summary and the "Tenant" fact +/// carry the display name (company name, else domain, else id — ), +/// and the id travels on in the "Tenant ID" fact right after it, for integrations that key on it. +/// +public class NotificationAlertBuilderSlaTests +{ + private const string TenantId = "11111111-1111-1111-1111-111111111111"; + private static readonly DateTime ResolvedAt = new(2026, 10, 20, 9, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Breach_alert_names_the_tenant_and_carries_its_id_right_after() + { + var alert = NotificationAlertBuilder.BuildSlaBreachAlert(TenantId, "Contoso", 80, 95, 10, 2, "SuccessRate"); + + AssertTenantFacts(alert, "Contoso"); + Assert.DoesNotContain(TenantId, alert.Summary); + } + + [Fact] + public void Resolved_alert_names_the_tenant_in_its_summary() + { + var alert = NotificationAlertBuilder.BuildSlaResolvedAlert(TenantId, "Contoso", "SuccessRate", 97, 95, + ResolvedAt.AddHours(-3), ResolvedAt); + + Assert.Equal("SLA breach resolved for Contoso: Success Rate is back within target.", alert.Summary); + AssertTenantFacts(alert, "Contoso"); + Assert.Equal("Breach Type", alert.Facts[2].Name); + } + + [Fact] + public void Consecutive_failures_alert_names_the_tenant_in_its_summary() + { + var alert = NotificationAlertBuilder.BuildConsecutiveFailuresAlert(TenantId, "Contoso", 3, "DESKTOP-0042", "timeout"); + + Assert.Equal("Alert: 3 enrollments failed in a row for Contoso", alert.Summary); + AssertTenantFacts(alert, "Contoso"); + Assert.Equal("Consecutive Failures", alert.Facts[2].Name); + } + + [Fact] + public void A_tenant_without_names_falls_back_to_its_id() + { + var name = TenantDisplayName.Of(new TenantConfiguration { TenantId = TenantId }, TenantId); + var alert = NotificationAlertBuilder.BuildConsecutiveFailuresAlert(TenantId, name, 5, null, null); + + Assert.Equal($"Alert: 5 enrollments failed in a row for {TenantId}", alert.Summary); + AssertTenantFacts(alert, TenantId); + } + + [Theory] + [InlineData("Contoso Ltd", "contoso.com", "Contoso Ltd")] + [InlineData(" ", "contoso.com", "contoso.com")] + [InlineData(null, null, TenantId)] + [InlineData(null, "", TenantId)] + public void Display_name_prefers_the_company_then_the_domain_then_the_id(string? companyName, string? domainName, string expected) + { + var config = new TenantConfiguration { TenantId = TenantId, CompanyName = companyName, DomainName = domainName! }; + + Assert.Equal(expected, TenantDisplayName.Of(config, TenantId)); + } + + [Fact] + public void Display_name_without_a_configuration_is_the_id() + => Assert.Equal(TenantId, TenantDisplayName.Of(null, TenantId)); + + private static void AssertTenantFacts(NotificationAlert alert, string expectedName) + { + Assert.Equal("Tenant", alert.Facts[0].Name); + Assert.Equal(expectedName, alert.Facts[0].Value); + Assert.Equal("Tenant ID", alert.Facts[1].Name); + Assert.Equal(TenantId, alert.Facts[1].Value); + Assert.Single(alert.Facts, f => f.Name == "Tenant ID"); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/NotificationChannelsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/NotificationChannelsTests.cs index b8fec9ff..9cdce3f1 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/NotificationChannelsTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/NotificationChannelsTests.cs @@ -256,6 +256,58 @@ public class NotificationChannelsTests Assert.Equal("Invalid webhook provider type.", error); } + [Theory] + [InlineData(40, false)] // Telegram + [InlineData(40, true)] + [InlineData(50, false)] // Push + [InlineData(50, true)] + [InlineData(60, false)] // E-mail + [InlineData(60, true)] + public void ValidateModel_LegacyWebhook_RefusesANewlySetNonWebhookProvider_ForEveryCaller(int providerType, bool isGlobalAdmin) + { + var existing = TenantConfiguration.CreateDefault("tenant-1"); + var candidate = TenantConfiguration.CreateDefault("tenant-1"); + candidate.WebhookProviderType = providerType; + candidate.WebhookUrl = "https://hooks.example/x"; + + Assert.Equal("The legacy webhook supports webhook providers only; use a notification channel for the others.", + TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin)); + } + + [Theory] + [InlineData(40)] + [InlineData(50)] + [InlineData(60)] + public void ValidateModel_LegacyWebhook_KeepsAnUnchangedStoredNonWebhookProviderSaveable(int providerType) + { + var existing = TenantConfiguration.CreateDefault("tenant-1"); + existing.WebhookProviderType = providerType; + existing.WebhookUrl = "https://hooks.example/x"; + var candidate = TenantConfiguration.CreateDefault("tenant-1"); + candidate.WebhookProviderType = providerType; + candidate.WebhookUrl = "https://hooks.example/x"; + candidate.CompanyName = "Contoso"; // an unrelated change + + Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false)); + } + + [Theory] + [InlineData(2)] + [InlineData(10)] + [InlineData(20)] + [InlineData(30)] + [InlineData(0)] + public void ValidateModel_LegacyWebhook_TakesWebhookProvidersOrNone(int providerType) + { + var existing = TenantConfiguration.CreateDefault("tenant-1"); + existing.WebhookProviderType = 50; // even switching away from a stored Push value + var candidate = TenantConfiguration.CreateDefault("tenant-1"); + candidate.WebhookProviderType = providerType; + candidate.WebhookUrl = "https://hooks.example/x"; + + Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false)); + } + [Fact] public void ValidateNotificationChannels_RejectsBadPerChannelHeaders() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/InMemoryPushDeviceRepository.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/InMemoryPushDeviceRepository.cs index 3f555136..c4d59a83 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Push/InMemoryPushDeviceRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/InMemoryPushDeviceRepository.cs @@ -95,11 +95,13 @@ internal sealed class InMemoryPushDeviceRepository : IPushDeviceRepository public Task GetOwnerAsync(string homeTenantId, string objectId) => Task.FromResult(Owners.TryGetValue((homeTenantId.ToLowerInvariant(), objectId.ToLowerInvariant()), out var o) ? o : null); - public Task StampOwnerSignInAsync(string homeTenantId, string objectId, string upn, DateTime signInUtc) + /// Replaces the whole row, like the production upsert: a null claim role clears the stored one. + public Task StampOwnerSignInAsync(string homeTenantId, string objectId, string upn, DateTime signInUtc, string? claimRole) { Owners[(homeTenantId.ToLowerInvariant(), objectId.ToLowerInvariant())] = new PushOwner { HomeTenantId = homeTenantId.ToLowerInvariant(), ObjectId = objectId.ToLowerInvariant(), Upn = upn, LastSignInUtc = signInUtc, + ClaimRole = claimRole, }; return Task.CompletedTask; } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushAlertProjectorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushAlertProjectorTests.cs index ec4a5209..3a45c5d7 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushAlertProjectorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushAlertProjectorTests.cs @@ -85,6 +85,22 @@ public class PushAlertProjectorTests Assert.DoesNotContain(Sentinel, json); } + [Fact] + public void Sla_alert_shows_the_tenant_name_and_never_carries_the_tenant_id() + { + const string tenantId = "11111111-1111-1111-1111-111111111111"; + var alert = NotificationAlertBuilder.BuildConsecutiveFailuresAlert(tenantId, "Contoso", 5, "DESKTOP-0042", null, + Constants.PortalBaseUrl + "/sla"); + + var payload = PushAlertProjector.Project(alert, Tenant, "e8", Now); + var json = Encoding.UTF8.GetString(PushAlertProjector.ToJsonBytes(payload)); + + Assert.Contains(("Tenant", "Contoso"), payload.Facts); + Assert.DoesNotContain(payload.Facts, f => f.Name == "Tenant ID"); + Assert.Equal("Alert: 5 enrollments failed in a row for Contoso", payload.Body); + Assert.DoesNotContain(tenantId, json); + } + [Fact] public void Platform_projection_never_uses_the_ops_message() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushChannelRoutingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushChannelRoutingTests.cs index 7dfb1732..ffd9b88c 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushChannelRoutingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushChannelRoutingTests.cs @@ -6,6 +6,7 @@ using AutopilotMonitor.Functions.Services.Notifications; using AutopilotMonitor.Functions.Telemetry; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Models.Notifications; using Microsoft.ApplicationInsights.DataContracts; using Microsoft.Extensions.Logging.Abstractions; @@ -15,12 +16,15 @@ namespace AutopilotMonitor.Functions.Tests.Push; /// /// Push as a channel provider: the dispatcher's Push branch (scope passed through, never the -/// webhook path), the validation rules and the GA gate, the raw-table deny-list and the -/// dependency redaction. +/// webhook path, one fan-out per send), the validation rules (no destination, open to tenant +/// admins — only Telegram and E-mail stay behind the GA gate — and one Push channel per scope), +/// the raw-table deny-list and the dependency redaction. /// public class PushChannelRoutingTests { - private static readonly NotificationScope Tenant = NotificationScope.Tenant("11111111-1111-1111-1111-111111111111"); + private const string TenantKey = "11111111-1111-1111-1111-111111111111"; + private const string OnePushChannelOnly = "only one Push channel is allowed — a single channel already reaches every paired device."; + private static readonly NotificationScope Tenant = NotificationScope.Tenant(TenantKey); private static Mock WebhookMock() { @@ -40,8 +44,13 @@ public class PushChannelRoutingTests return mock; } - private static NotificationChannel PushChannel(string id = "push") => - new() { Id = id, Name = id, ProviderType = (int)WebhookProviderType.Push, Url = null, Enabled = true }; + private static NotificationChannel PushChannel(string id = "push", bool enabled = true) => + new() { Id = id, Name = id, ProviderType = (int)WebhookProviderType.Push, Url = null, Enabled = enabled }; + + private static string Json(params NotificationChannel[] channels) => NotificationChannel.SerializeList(channels); + + private static TenantConfiguration Config(params NotificationChannel[] channels) + => new() { TenantId = TenantKey, NotificationChannelsJson = Json(channels) }; [Fact] public async Task Push_channel_reaches_the_push_sender_with_the_callers_scope_and_never_the_webhook_path() @@ -60,6 +69,25 @@ public class PushChannelRoutingTests telegram.Verify(t => t.SendAlertAsync(It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task Two_push_channels_in_one_send_fan_out_once() + { + // A pair stored before validation allowed only one: each would reach every paired device. + var webhook = WebhookMock(); + var push = new Mock(); + var dispatcher = new NotificationChannelDispatcher(webhook.Object, TelegramMock().Object, push.Object); + var alert = new NotificationAlert { Title = "t", Summary = "s" }; + var slack = new NotificationChannel { Id = "slack", Name = "slack", ProviderType = (int)WebhookProviderType.Slack, Url = "https://hooks.slack.example/x", Enabled = true }; + + await dispatcher.SendToChannelsAsync(new[] { PushChannel("phones"), slack, PushChannel("phones-2") }, alert, Tenant); + + push.Verify(p => p.SendAsync(Tenant, alert), Times.Once); + push.VerifyNoOtherCalls(); + // The other channels of the send are untouched by the guard. + webhook.Verify(w => w.SendAsync("https://hooks.slack.example/x", WebhookProviderType.Slack, alert, + It.IsAny>(), It.IsAny()), Times.Once); + } + [Fact] public async Task Push_channel_without_url_is_not_skipped_as_destination_less() { @@ -80,8 +108,9 @@ public class PushChannelRoutingTests Assert.True(PushChannel().HasDestination()); Assert.False(new NotificationChannel { Id = "s", ProviderType = (int)WebhookProviderType.Slack, Url = "" }.HasDestination()); Assert.True(new NotificationChannel { Id = "s", ProviderType = (int)WebhookProviderType.Slack, Url = "https://hooks.slack.example/x" }.HasDestination()); - Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Push)); + Assert.False(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Push)); Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Telegram)); + Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Email)); Assert.False(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Slack)); } @@ -98,28 +127,60 @@ public class PushChannelRoutingTests } [Fact] - public void Gate_lets_only_a_global_admin_add_or_flip_a_push_channel() + public void Gate_leaves_a_push_channel_to_the_tenant_admin_but_not_its_change_into_telegram() { - var json = NotificationChannel.SerializeList(new[] { PushChannel() }); + var json = Json(PushChannel()); - Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, existingJson: null, isGlobalAdmin: true)); - Assert.Contains("Push channels can only be configured by a Global Administrator", - TenantConfigValidation.ValidateTelegramChannelGate(json, existingJson: null, isGlobalAdmin: false)); - - // Unchanged GA-created channel: a tenant admin may still save unrelated fields. - Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, json, isGlobalAdmin: false)); - - var disabled = PushChannel(); - disabled.Enabled = false; - Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate( - NotificationChannel.SerializeList(new[] { disabled }), json, isGlobalAdmin: false)); - - // Renames and toggles stay with the tenant admin. + // Add, disable, rename and toggle: all the tenant admin's own. + Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, existingJson: null, isGlobalAdmin: false)); + Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(Json(PushChannel(enabled: false)), json, isGlobalAdmin: false)); var renamed = PushChannel(); renamed.Name = "Phones"; renamed.NotifyOnFailure = true; - Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate( - NotificationChannel.SerializeList(new[] { renamed }), json, isGlobalAdmin: false)); + Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(Json(renamed), json, isGlobalAdmin: false)); + + // The same channel id turned into Telegram still needs a Global Administrator. + var retyped = new NotificationChannel { Id = "push", Name = "push", ProviderType = (int)WebhookProviderType.Telegram, Url = "-100123", Enabled = true }; + Assert.Equal("Telegram channels can only be configured by a Global Administrator.", + TenantConfigValidation.ValidateTelegramChannelGate(Json(retyped), json, isGlobalAdmin: false)); + Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(Json(retyped), json, isGlobalAdmin: true)); + } + + [Fact] + public void A_tenant_admin_may_add_the_first_push_channel() + => Assert.Null(TenantConfigValidation.ValidateModel(Config(PushChannel()), Config(), isGlobalAdmin: false)); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_second_push_channel_is_refused_for_every_caller(bool isGlobalAdmin) + { + var error = TenantConfigValidation.ValidateModel( + Config(PushChannel("phones"), PushChannel("phones-2")), Config(PushChannel("phones")), isGlobalAdmin); + + Assert.Equal("Invalid notification channels: " + OnePushChannelOnly, error); + } + + [Fact] + public void A_disabled_push_channel_counts_toward_the_one() + => Assert.Equal(OnePushChannelOnly, + TenantConfigValidation.ValidateSinglePushChannel(Json(PushChannel("phones"), PushChannel("spare", enabled: false)), existingJson: null)); + + [Fact] + public void A_stored_pair_stays_saveable_until_it_is_reduced() + { + var pair = Json(PushChannel("phones"), PushChannel("phones-2")); + + // Saved before the rule: unrelated saves and the reduction pass, growing it does not. + Assert.Null(TenantConfigValidation.ValidateSinglePushChannel(pair, pair)); + Assert.Null(TenantConfigValidation.ValidateModel(Config(PushChannel("phones"), PushChannel("phones-2")), + Config(PushChannel("phones"), PushChannel("phones-2")), isGlobalAdmin: false)); + Assert.Null(TenantConfigValidation.ValidateSinglePushChannel(Json(PushChannel("phones")), pair)); + Assert.Equal(OnePushChannelOnly, TenantConfigValidation.ValidateSinglePushChannel( + Json(PushChannel("phones"), PushChannel("phones-2"), PushChannel("phones-3")), pair)); + + // Once reduced, the pair cannot come back. + Assert.Equal(OnePushChannelOnly, TenantConfigValidation.ValidateSinglePushChannel(pair, Json(PushChannel("phones")))); } [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushEligibilityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushEligibilityTests.cs index d6862363..b16969f9 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushEligibilityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushEligibilityTests.cs @@ -14,9 +14,11 @@ namespace AutopilotMonitor.Functions.Tests.Push; /// /// The real over mocked role and configuration reads: the channel -/// switch per scope (K4), the table-backed role predicate for tenant members (Admin/Operator, -/// never Viewer) and for Global Admins (GlobalAdmin with a matching identity binding, never a -/// Global Reader), and the owner-freshness window (K3). +/// switch per scope (K4), the role predicate for tenant members (Admin/Operator, never Viewer — +/// the TenantAdmins row first, else the Entra app-role claim recorded at sign-in, home tenant +/// only and only while app roles stay enabled), the live claim a pairing records, Global Admins +/// (GlobalAdmin with a matching identity binding, never a Global Reader), and the owner-freshness +/// window (K3). /// public class PushEligibilityTests { @@ -27,6 +29,7 @@ public class PushEligibilityTests private const string ObjectId = "aaaaaaaa-0000-0000-0000-000000000001"; private const string OtherObjectId = "aaaaaaaa-0000-0000-0000-000000000002"; private static readonly NotificationScope Tenant = NotificationScope.Tenant(TenantId); + private static readonly NotificationScope Home = NotificationScope.Tenant(HomeTenantId); private static readonly DateTime Now = new(2026, 10, 7, 12, 0, 0, DateTimeKind.Utc); // ── HasPushChannelAsync ────────────────────────────────────────────────── @@ -197,6 +200,128 @@ public class PushEligibilityTests h.AdminRepo.Verify(r => r.GetTenantMemberAsync(It.IsAny(), It.IsAny()), Times.Never); } + // ── IsEligibleAsync — tenant scope, Entra app-role claim ───────────────── + + [Theory] + [InlineData(Constants.TenantRoles.Admin)] + [InlineData(Constants.TenantRoles.Operator)] + public async Task Claim_only_member_with_an_alerting_role_is_eligible_in_the_home_tenant(string role) + { + var h = new Harness(); + h.Owner(Now, claimRole: role); + h.HomeConfig(appRolesEnabled: true); + + Assert.True(await h.Sut.IsEligibleAsync(Home, Upn, ObjectId, HomeTenantId)); + } + + [Fact] + public async Task Claim_only_viewer_is_not_eligible() + { + var h = new Harness(); + h.Owner(Now, claimRole: Constants.TenantRoles.Viewer); + h.HomeConfig(appRolesEnabled: true); + + Assert.False(await h.Sut.IsEligibleAsync(Home, Upn, ObjectId, HomeTenantId)); + } + + [Theory] + [InlineData(false, true)] // the tenant switched app roles off since the sign-in + [InlineData(true, false)] // no stored configuration at all + public async Task Claim_role_counts_only_while_the_tenant_keeps_app_roles_enabled(bool appRolesEnabled, bool configExists) + { + var h = new Harness(); + h.Owner(Now, claimRole: Constants.TenantRoles.Admin); + h.HomeConfig(appRolesEnabled, configExists); + + Assert.False(await h.Sut.IsEligibleAsync(Home, Upn, ObjectId, HomeTenantId)); + } + + [Fact] + public async Task Disabled_member_row_beats_the_claim_role() + { + var h = new Harness(); + h.HomeMember(Constants.TenantRoles.Admin, enabled: false); + h.Owner(Now, claimRole: Constants.TenantRoles.Admin); + h.HomeConfig(appRolesEnabled: true); + + Assert.False(await h.Sut.IsEligibleAsync(Home, Upn, ObjectId, HomeTenantId)); + } + + [Fact] + public async Task Viewer_member_row_beats_an_admin_claim_role() + { + var h = new Harness(); + h.HomeMember(Constants.TenantRoles.Viewer); + h.Owner(Now, claimRole: Constants.TenantRoles.Admin); + h.HomeConfig(appRolesEnabled: true); + + Assert.False(await h.Sut.IsEligibleAsync(Home, Upn, ObjectId, HomeTenantId)); + } + + [Fact] + public async Task Claim_role_never_reaches_a_tenant_other_than_the_home_tenant() + { + var h = new Harness(); + h.Owner(Now, claimRole: Constants.TenantRoles.Admin); + h.HomeConfig(appRolesEnabled: true); + + Assert.False(await h.Sut.IsEligibleAsync(Tenant, Upn, ObjectId, HomeTenantId)); + h.TenantConfig.Verify(t => t.TryGetConfigurationAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Claim_role_eligibility_leaves_freshness_to_its_own_check() + { + var h = new Harness(); + h.Owner(Now.AddDays(-(h.Settings.OwnerInactivityDays + 10)), claimRole: Constants.TenantRoles.Admin); + h.HomeConfig(appRolesEnabled: true); + + Assert.True(await h.Sut.IsEligibleAsync(Home, Upn, ObjectId, HomeTenantId)); + Assert.False(await h.Sut.IsOwnerFreshAsync(HomeTenantId, ObjectId, Now)); + } + + // ── ClaimRoleAsync (the live claim a pairing records) ─────────────────── + + [Theory] + [InlineData(new[] { Constants.TenantRoles.Admin }, Constants.TenantRoles.Admin)] + [InlineData(new[] { Constants.TenantRoles.Viewer, Constants.TenantRoles.Operator }, Constants.TenantRoles.Operator)] + [InlineData(new[] { Constants.TenantRoles.Viewer }, Constants.TenantRoles.Viewer)] + [InlineData(new[] { "SomethingElse" }, null)] + public async Task Live_claim_without_a_member_row_maps_to_its_highest_member_role(string[] appRoles, string? expected) + { + var h = new Harness(); + h.HomeConfig(appRolesEnabled: true); + + Assert.Equal(expected, await h.Sut.ClaimRoleAsync(HomeTenantId, Upn, appRoles)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Live_claim_is_not_recorded_where_a_member_row_decides(bool rowEnabled) + { + var h = new Harness(); + h.HomeMember(Constants.TenantRoles.Operator, enabled: rowEnabled); + h.HomeConfig(appRolesEnabled: true); + + Assert.Null(await h.Sut.ClaimRoleAsync(HomeTenantId, "Admin@Contoso.INVALID", new[] { Constants.TenantRoles.Admin })); + } + + [Fact] + public async Task Live_claim_is_not_recorded_with_app_roles_off_without_roles_or_for_an_application() + { + var off = new Harness(); + off.HomeConfig(appRolesEnabled: false); + Assert.Null(await off.Sut.ClaimRoleAsync(HomeTenantId, Upn, new[] { Constants.TenantRoles.Admin })); + + var h = new Harness(); + h.HomeConfig(appRolesEnabled: true); + Assert.Null(await h.Sut.ClaimRoleAsync(HomeTenantId, Upn, null)); + Assert.Null(await h.Sut.ClaimRoleAsync(HomeTenantId, Upn, Array.Empty())); + Assert.Null(await h.Sut.ClaimRoleAsync(HomeTenantId, + Constants.PrincipalKeys.ForApplication("22222222-2222-2222-2222-222222222222"), new[] { Constants.TenantRoles.Admin })); + } + // ── IsEligibleAsync — platform scope ───────────────────────────────────── [Fact] @@ -393,10 +518,19 @@ public class PushEligibilityTests => AdminRepo.Setup(r => r.GetIdentityBindingAsync(Upn)) .ReturnsAsync(new AdminIdentityBinding { Upn = Upn, TenantId = tenantId, ObjectId = objectId }); - public void Owner(DateTime lastSignInUtc) + public void Owner(DateTime lastSignInUtc, string? claimRole = null) => Repo.Owners[(HomeTenantId, ObjectId)] = new PushOwner { - HomeTenantId = HomeTenantId, ObjectId = ObjectId, Upn = Upn, LastSignInUtc = lastSignInUtc, + HomeTenantId = HomeTenantId, ObjectId = ObjectId, Upn = Upn, LastSignInUtc = lastSignInUtc, ClaimRole = claimRole, }; + + /// The home tenant's configuration: its app-role switch (no channel needed for eligibility). + public void HomeConfig(bool appRolesEnabled, bool exists = true) + => TenantConfig.Setup(t => t.TryGetConfigurationAsync(HomeTenantId)) + .ReturnsAsync((new TenantConfiguration { TenantId = HomeTenantId, EntraAppRolesEnabled = appRolesEnabled }, exists)); + + public void HomeMember(string role, bool enabled = true) + => AdminRepo.Setup(r => r.GetTenantMemberAsync(HomeTenantId, Upn)) + .ReturnsAsync(new TenantMember { TenantId = HomeTenantId, Upn = Upn, Role = role, IsEnabled = enabled }); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushOwnerSignInServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushOwnerSignInServiceTests.cs new file mode 100644 index 00000000..f5effe69 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushOwnerSignInServiceTests.cs @@ -0,0 +1,180 @@ +using AutopilotMonitor.Functions.Services.Push; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AutopilotMonitor.Functions.Tests.Push; + +/// +/// What a portal sign-in does to the person's push devices: the stamp carries the app-role claim +/// role (null clears it); losing that role with no TenantAdmins row taking its place revokes the +/// home-tenant devices (wipe push, then the row is gone); and a sign-in after the inactivity window +/// — or the first one ever — resumes this owner's owner_inactive pauses at once, the platform ones +/// only for a Global Admin. Fail-soft towards the sign-in. +/// +public class PushOwnerSignInServiceTests +{ + // The delivery harness's tenant, and the person its AddDevice defaults to (oid derived from the UPN). + private const string TenantId = "11111111-1111-1111-1111-111111111111"; + private const string Upn = "admin@contoso.invalid"; + private const string ObjectId = "oid-admin@contoso.invalid"; + private static readonly DateTime Now = new(2026, 10, 20, 9, 0, 0, DateTimeKind.Utc); + + [Theory] + [InlineData(Constants.TenantRoles.Admin)] + [InlineData(Constants.TenantRoles.Viewer)] + [InlineData(null)] + public async Task Sign_in_stamps_the_time_and_the_claim_role(string? claimRole) + { + var h = new Harness(); + h.Previous(Now.AddDays(-1), Constants.TenantRoles.Operator); + + await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, claimRole, isGlobalAdmin: false, Now); + + var owner = h.Push.Repo.Owners[(TenantId, ObjectId)]; + Assert.Equal(Now, owner.LastSignInUtc); + Assert.Equal(claimRole, owner.ClaimRole); + } + + [Theory] + [InlineData(null)] + [InlineData(Constants.TenantRoles.Viewer)] + public async Task Losing_the_claim_role_revokes_the_home_tenant_devices(string? claimRole) + { + var h = new Harness(); + h.Previous(Now.AddDays(-1), Constants.TenantRoles.Admin); + h.Push.AddDevice("d1", Constants.Push.DeviceStatus.Active); + h.Push.AddDevice("d2", Constants.Push.DeviceStatus.Active, upn: "other@contoso.invalid"); + h.Push.AddDevice("p1", Constants.Push.DeviceStatus.Active, scope: Constants.Push.PlatformScope); + + await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, claimRole, isGlobalAdmin: false, Now); + + Assert.False(h.Push.Repo.Devices.ContainsKey((TenantId, "d1"))); + Assert.True(h.Push.Repo.Devices.ContainsKey((TenantId, "d2"))); + Assert.True(h.Push.Repo.Devices.ContainsKey((Constants.Push.PlatformScope, "p1"))); + // The wipe push went to the revoked device first. + Assert.EndsWith("/d1", Assert.Single(h.Push.Http.Requests).RequestUri!.ToString()); + } + + [Fact] + public async Task A_member_row_that_took_the_claims_place_keeps_the_devices() + { + var h = new Harness(); + h.Previous(Now.AddDays(-1), Constants.TenantRoles.Admin); + h.Push.AddDevice("d1", Constants.Push.DeviceStatus.Active); + h.AdminRepo.Setup(r => r.GetTenantMemberAsync(TenantId, Upn)) + .ReturnsAsync(new TenantMember { TenantId = TenantId, Upn = Upn, Role = Constants.TenantRoles.Admin, IsEnabled = true }); + + // With a row the sign-in records no claim role — that is not a loss. + await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, claimRole: null, isGlobalAdmin: false, Now); + + Assert.True(h.Push.Repo.Devices.ContainsKey((TenantId, "d1"))); + Assert.Empty(h.Push.Http.Requests); + } + + [Theory] + [InlineData(Constants.TenantRoles.Admin, Constants.TenantRoles.Operator)] + [InlineData(null, null)] + [InlineData(Constants.TenantRoles.Viewer, null)] + public async Task A_sign_in_that_loses_no_alerting_claim_role_revokes_nothing(string? before, string? after) + { + var h = new Harness(); + h.Previous(Now.AddDays(-1), before); + h.Push.AddDevice("d1", Constants.Push.DeviceStatus.Active); + + await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, after, isGlobalAdmin: false, Now); + + Assert.True(h.Push.Repo.Devices.ContainsKey((TenantId, "d1"))); + h.AdminRepo.Verify(r => r.GetTenantMemberAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task A_sign_in_after_the_inactivity_window_resumes_only_this_owners_inactivity_pauses() + { + var h = new Harness(); + h.Previous(Now.AddDays(-(h.Push.Settings.OwnerInactivityDays + 5)), claimRole: null); + h.Push.AddDevice("d1", Constants.Push.DeviceStatus.Paused, statusReason: "owner_inactive"); + h.Push.AddDevice("d2", Constants.Push.DeviceStatus.Paused, statusReason: "delivery_failures"); + h.Push.AddDevice("d3", Constants.Push.DeviceStatus.Paused, upn: "other@contoso.invalid", statusReason: "owner_inactive"); + h.Push.AddDevice("p1", Constants.Push.DeviceStatus.Paused, scope: Constants.Push.PlatformScope, statusReason: "owner_inactive"); + + await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, claimRole: null, isGlobalAdmin: false, Now); + + Assert.Equal(Constants.Push.DeviceStatus.Active, h.Push.Repo.Devices[(TenantId, "d1")].Status); + Assert.Null(h.Push.Repo.Devices[(TenantId, "d1")].StatusReason); + Assert.Equal(Constants.Push.DeviceStatus.Paused, h.Push.Repo.Devices[(TenantId, "d2")].Status); + Assert.Equal(Constants.Push.DeviceStatus.Paused, h.Push.Repo.Devices[(TenantId, "d3")].Status); + // Not a Global Admin: the platform partition is not theirs to resume. + Assert.Equal(Constants.Push.DeviceStatus.Paused, h.Push.Repo.Devices[(Constants.Push.PlatformScope, "p1")].Status); + Assert.Empty(h.Push.Http.Requests); // a resume is silent + } + + [Fact] + public async Task A_global_admins_sign_in_resumes_their_platform_pauses_too() + { + var h = new Harness(); + h.Previous(Now.AddDays(-(h.Push.Settings.OwnerInactivityDays + 5)), claimRole: null); + h.Push.AddDevice("p1", Constants.Push.DeviceStatus.Paused, scope: Constants.Push.PlatformScope, statusReason: "owner_inactive"); + h.Push.AddDevice("p2", Constants.Push.DeviceStatus.Paused, upn: "ga2@contoso.invalid", scope: Constants.Push.PlatformScope, statusReason: "owner_inactive"); + + await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, claimRole: null, isGlobalAdmin: true, Now); + + Assert.Equal(Constants.Push.DeviceStatus.Active, h.Push.Repo.Devices[(Constants.Push.PlatformScope, "p1")].Status); + Assert.Equal(Constants.Push.DeviceStatus.Paused, h.Push.Repo.Devices[(Constants.Push.PlatformScope, "p2")].Status); + } + + [Fact] + public async Task The_first_sign_in_ever_resumes_too() + { + var h = new Harness(); // no PushOwners row yet + h.Push.AddDevice("d1", Constants.Push.DeviceStatus.Paused, statusReason: "owner_inactive"); + + await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, claimRole: null, isGlobalAdmin: false, Now); + + Assert.Equal(Constants.Push.DeviceStatus.Active, h.Push.Repo.Devices[(TenantId, "d1")].Status); + } + + [Fact] + public async Task A_sign_in_within_the_window_resumes_nothing() + { + var h = new Harness(); + h.Previous(Now.AddDays(-1), claimRole: null); + h.Push.AddDevice("d1", Constants.Push.DeviceStatus.Paused, statusReason: "owner_inactive"); + + await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, claimRole: null, isGlobalAdmin: true, Now); + + Assert.Equal(Constants.Push.DeviceStatus.Paused, h.Push.Repo.Devices[(TenantId, "d1")].Status); + } + + [Fact] + public async Task A_storage_failure_never_reaches_the_sign_in() + { + var push = new PushDeliveryServiceTests.Harness(); + var repo = new Mock(); + repo.Setup(r => r.GetOwnerAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException("storage unavailable")); + var sut = new PushOwnerSignInService(repo.Object, new Mock().Object, push.Settings, push.Sut, + NullLogger.Instance); + + var exception = await Record.ExceptionAsync(() => sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, null, isGlobalAdmin: false, Now)); + + Assert.Null(exception); + } + + private sealed class Harness + { + public PushDeliveryServiceTests.Harness Push { get; } = new(); + public Mock AdminRepo { get; } = new(); + public PushOwnerSignInService Sut { get; } + + public Harness() + => Sut = new PushOwnerSignInService(Push.Repo, AdminRepo.Object, Push.Settings, Push.Sut, NullLogger.Instance); + + /// The stamp the previous sign-in left. + public void Previous(DateTime lastSignInUtc, string? claimRole) + => Push.Repo.Owners[(TenantId, ObjectId)] = new PushOwner + { + HomeTenantId = TenantId, ObjectId = ObjectId, Upn = Upn, LastSignInUtc = lastSignInUtc, ClaimRole = claimRole, + }; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingFunctionsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingFunctionsTests.cs new file mode 100644 index 00000000..bd5d395e --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingFunctionsTests.cs @@ -0,0 +1,58 @@ +using System.Net; +using System.Text.Json; +using AutopilotMonitor.Functions.Functions.Push; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AutopilotMonitor.Functions.Tests.Push; + +/// +/// The 403 a refused pairing answers with: neutral, naming the role the scope needs — the member +/// list of the tenant on the tenant routes, the Global Administrator role on the platform routes. +/// +public class PushPairingFunctionsTests +{ + private const string TenantId = "11111111-1111-1111-1111-111111111111"; + + [Fact] + public async Task A_refused_tenant_pairing_names_the_member_list() + { + var sut = RefusingFunctions(); + + var response = await sut.Create(EndpointHarness.Request(TenantId).Req); + + await AssertForbiddenAsync(response, "Push notifications need an Admin or Operator role in this tenant's member list."); + } + + [Fact] + public async Task A_refused_platform_pairing_names_the_global_administrator_role() + { + var sut = RefusingFunctions(); + + var response = await sut.CreatePlatform(EndpointHarness.Request(TenantId).Req); + + await AssertForbiddenAsync(response, "Push notifications on the platform need the Global Administrator role."); + } + + private static PushPairingFunctions RefusingFunctions() + { + var pairing = new PushPairingServiceTests.Harness(); + pairing.Eligibility.Setup(e => e.IsEligibleAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + return new PushPairingFunctions(pairing.Sut, NullLogger.Instance); + } + + private static async Task AssertForbiddenAsync(HttpResponseData response, string expectedMessage) + { + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + response.Body.Position = 0; + var envelope = (await JsonSerializer.DeserializeAsync(response.Body, ApiJsonOptions.Read))!; + Assert.Equal(Constants.ApiErrorCodes.PushNotEligible, envelope.Code); + Assert.Equal(expectedMessage, envelope.Error); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingServiceTests.cs index 7988b46e..ca64c660 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingServiceTests.cs @@ -17,8 +17,9 @@ namespace AutopilotMonitor.Functions.Tests.Push; /// The pairing flow end to end against the in-memory repository and a fake push service: /// create → begin → redeem (Pending) → confirm (Active, confirmation push), the one-shot redeem, /// the five-failure burn, expiry, the device token, re-subscribe re-arming a Stale row, the -/// role/channel gates, grant ownership (a grant answers only to its creator) and the -/// one-per-host-and-hour ops event for a refused subscription endpoint. +/// role/channel gates (the channel also silences the device test, never the device list), the +/// pairing of a member known only through an Entra app role, grant ownership (a grant answers +/// only to its creator) and the one-per-host-and-hour ops event for a refused subscription endpoint. /// public class PushPairingServiceTests { @@ -304,6 +305,124 @@ public class PushPairingServiceTests Assert.Equal(PushOpError.NotEligible, (await h.Sut.CreateAsync(Tenant, Admin)).Error); } + [Fact] + public async Task A_refused_pairing_names_the_needed_role_only_on_the_platform() + { + var h = new Harness(); + h.Eligibility.Setup(e => e.IsEligibleAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var tenant = await h.Sut.CreateAsync(Tenant, Admin); + var platform = await h.Sut.CreateAsync(NotificationScope.Platform, Admin); + + // The tenant refusal carries the function's default text ("…in this tenant's member list."). + Assert.Equal(PushOpError.NotEligible, tenant.Error); + Assert.Null(tenant.Message); + Assert.Equal(PushOpError.NotEligible, platform.Error); + Assert.Equal("Push notifications on the platform need the Global Administrator role.", platform.Message); + Assert.Empty(h.Repo.Grants); + } + + [Fact] + public async Task A_member_known_only_through_an_entra_app_role_can_pair_at_once() + { + var host = new AppRoleHost(appRolesEnabled: true); + var member = new PushCaller("member@contoso.invalid", "aaaaaaaa-0000-0000-0000-000000000004", TenantId, IsScopeAdmin: true, + AppRoles: new[] { Constants.TenantRoles.Admin }); + + var created = await host.Sut.CreateAsync(Tenant, member); + + // No TenantAdmins row and no earlier sign-in stamp: the pairing recorded the live claim first. + Assert.True(created.Ok); + Assert.Equal(Constants.TenantRoles.Admin, host.Repo.Owners[(TenantId, member.ObjectId)].ClaimRole); + Assert.Single(host.Repo.Grants); + } + + [Fact] + public async Task Without_the_app_role_or_with_app_roles_off_the_pairing_is_refused() + { + var withoutRole = new AppRoleHost(appRolesEnabled: true); + var plainUser = new PushCaller("user@contoso.invalid", "aaaaaaaa-0000-0000-0000-000000000005", TenantId, IsScopeAdmin: false); + Assert.Equal(PushOpError.NotEligible, (await withoutRole.Sut.CreateAsync(Tenant, plainUser)).Error); + Assert.Null(withoutRole.Repo.Owners[(TenantId, plainUser.ObjectId)].ClaimRole); + + var appRolesOff = new AppRoleHost(appRolesEnabled: false); + var claimAdmin = plainUser with { AppRoles = new[] { Constants.TenantRoles.Admin } }; + Assert.Equal(PushOpError.NotEligible, (await appRolesOff.Sut.CreateAsync(Tenant, claimAdmin)).Error); + Assert.Null(appRolesOff.Repo.Owners[(TenantId, plainUser.ObjectId)].ClaimRole); + Assert.Empty(appRolesOff.Repo.Grants); + } + + [Fact] + public async Task A_device_test_with_the_channel_off_reports_it_and_sends_nothing() + { + var h = new Harness(); + var deviceId = await PairAsync(h, Admin); + var sentBefore = h.Http.Requests.Count; + h.Eligibility.Setup(e => e.HasPushChannelAsync(Tenant)).ReturnsAsync(false); + + var result = await h.Sut.TestAsync(Tenant, deviceId, Admin); + + Assert.True(result.Ok); + Assert.False(result.Value!.Success); + Assert.Equal("The Push channel is off — paired devices receive nothing.", result.Value.Message); + Assert.Equal(sentBefore, h.Http.Requests.Count); + } + + [Fact] + public async Task Devices_stay_listable_and_removable_with_the_channel_off() + { + var h = new Harness(); + var deviceId = await PairAsync(h, Admin); + h.Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny())).ReturnsAsync(false); + + Assert.Equal(deviceId, Assert.Single((await h.Sut.ListAsync(Tenant, Admin)).Devices).DeviceId); + Assert.Equal(PushOpError.None, await h.Sut.DeleteAsync(Tenant, deviceId, Admin)); + Assert.Empty(h.Repo.Devices); + } + + /// Create → redeem → confirm: one Active device of ; returns its id. + private static async Task PairAsync(Harness h, PushCaller owner) + { + var created = await h.Sut.CreateAsync(Tenant, owner); + await h.Sut.RedeemAsync(h.RedeemRequest(created.Value!.Code)); + Assert.True((await h.Sut.ConfirmAsync(Tenant, created.Value.PairingId, owner)).Ok); + return h.Repo.Devices.Values.Single(d => d.OwnerObjectId == owner.ObjectId).DeviceId; + } + + /// + /// The pairing service over the REAL : no TenantAdmins row for anyone, + /// a tenant configuration with an enabled Push channel and the given app-role switch. + /// + private sealed class AppRoleHost + { + public InMemoryPushDeviceRepository Repo { get; } = new(); + public PushPairingService Sut { get; } + + public AppRoleHost(bool appRolesEnabled) + { + var settings = PushSettings.ForKeys(new VapidKeyRing(VapidKey.Generate())); + var cache = new MemoryCache(new MemoryCacheOptions()); + var pushChannel = new AutopilotMonitor.Shared.Models.Notifications.NotificationChannel + { + Id = "phones", Name = "Phones", ProviderType = (int)AutopilotMonitor.Shared.Models.Notifications.WebhookProviderType.Push, Enabled = true, + }; + var tenantConfig = new Mock(null!, NullLogger.Instance, cache, null!) { CallBase = false }; + tenantConfig.Setup(t => t.TryGetConfigurationAsync(TenantId)).ReturnsAsync((new TenantConfiguration + { + TenantId = TenantId, + EntraAppRolesEnabled = appRolesEnabled, + NotificationChannelsJson = AutopilotMonitor.Shared.Models.Notifications.NotificationChannel.SerializeList(new[] { pushChannel }), + }, true)); + var adminConfig = new Mock(Mock.Of(), NullLogger.Instance, cache) { CallBase = false }; + var eligibility = new PushEligibility(new Mock().Object, Repo, tenantConfig.Object, adminConfig.Object, settings); + var opsEvents = new Mock(null!, null!, null!) { CallBase = false }; + var delivery = new PushDeliveryService(Repo, eligibility, settings, new WebPushSender(new HttpClient(new RecordingHandler())), + new Lazy(() => opsEvents.Object), cache, NullLogger.Instance); + Sut = new PushPairingService(Repo, eligibility, settings, delivery, opsEvents.Object, + Mock.Of(), tenantConfig.Object, cache, NullLogger.Instance); + } + } + [Fact] public void Codes_normalise_typed_variants_and_refuse_everything_else() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushSessionWatchServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushSessionWatchServiceTests.cs index 1a75baa3..dbbbac20 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushSessionWatchServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushSessionWatchServiceTests.cs @@ -362,7 +362,7 @@ public class PushSessionWatchServiceTests } [Fact] - public async Task Notify_without_watches_neither_writes_nor_sends() + public async Task Notify_without_watches_neither_reads_a_channel_nor_writes_nor_sends() { var repo = new Mock(); repo.Setup(r => r.GetWatchesForSessionAsync(CustomerTenantId, SessionId)).ReturnsAsync(new List()); @@ -372,9 +372,70 @@ public class PushSessionWatchServiceTests repo.Verify(r => r.GetWatchesForSessionAsync(CustomerTenantId, SessionId), Times.Once); repo.VerifyNoOtherCalls(); + h.Eligibility.Verify(e => e.HasPushChannelAsync(It.IsAny()), Times.Never); Assert.Empty(h.Http.Requests); } + [Fact] + public async Task Notify_into_a_scope_whose_channel_was_switched_off_sends_nothing_and_still_consumes_the_watch() + { + var h = new Harness(); + h.AddDevice("d1", Admin, HomeTenantId); + h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId); + h.Eligibility.Setup(e => e.HasPushChannelAsync(Home)).ReturnsAsync(false); + + await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Succeeded, "DESKTOP-4711", "1234567890"); + + Assert.Empty(h.Http.Requests); + Assert.Empty(h.Repo.Watches); + Assert.Equal(Constants.Push.DeviceStatus.Active, h.Repo.Devices[(HomeTenantId, "d1")].Status); + } + + [Fact] + public async Task Notify_pushes_only_into_the_watch_scopes_whose_channel_is_on() + { + var h = new Harness(); + var platformDevice = h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope); + h.AddDevice("h1", GlobalAdmin, HomeTenantId); + h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, $"{Constants.Push.PlatformScope};{HomeTenantId}"); + h.Eligibility.Setup(e => e.HasPushChannelAsync(Home)).ReturnsAsync(false); + + await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Failed, "DESKTOP-4711", null); + + Assert.Equal(platformDevice.Endpoint, Assert.Single(h.Http.Requests).RequestUri!.ToString()); + Assert.Empty(h.Repo.Watches); + } + + [Fact] + public async Task Notify_with_empty_owner_scopes_asks_the_session_tenants_channel() + { + var h = new Harness(); + h.AddDevice("d1", Admin, HomeTenantId); + h.AddWatch(HomeTenantId, SessionId, Admin, ownerScopes: string.Empty); // legacy row: the session tenant + h.Eligibility.Setup(e => e.HasPushChannelAsync(Home)).ReturnsAsync(false); + + await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Succeeded, "DESKTOP-4711", null); + + Assert.Empty(h.Http.Requests); + Assert.Empty(h.Repo.Watches); + h.Eligibility.Verify(e => e.HasPushChannelAsync(Home), Times.Once); + } + + [Fact] + public async Task Notify_asks_each_scopes_channel_once_for_every_watcher() + { + var h = new Harness(); + var d1 = h.AddDevice("d1", Admin, HomeTenantId); + var d2 = h.AddDevice("d2", Operator, HomeTenantId); + h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId); + h.AddWatch(HomeTenantId, SessionId, Operator, HomeTenantId); + + await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Succeeded, "DESKTOP-4711", null); + + Assert.Equal(Endpoints(d1, d2), SentTo(h)); + h.Eligibility.Verify(e => e.HasPushChannelAsync(It.IsAny()), Times.Once); + } + [Theory] [InlineData(SessionWatchOutcome.Succeeded)] [InlineData(SessionWatchOutcome.Failed)] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/TablePushRepositoryMappingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/TablePushRepositoryMappingTests.cs index 608506f9..589fcb5f 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Push/TablePushRepositoryMappingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/TablePushRepositoryMappingTests.cs @@ -8,11 +8,11 @@ using Azure.Data.Tables; namespace AutopilotMonitor.Functions.Tests.Push; /// -/// Store → Map round trip of for the three models it projects -/// (, , ; -/// is written inline, not through a Store/Map pair). Every row is a -/// credential or binds one, so a column dropped on either side is a silently unpaired phone or a -/// reusable code. The comparisons run by reflection over the model's public properties: a property +/// Store → Map round trip of for the four models it projects +/// (, , , +/// ). Every row is a credential, binds one or decides who receives +/// pushes, so a column dropped on either side is a silently unpaired phone, a reusable code or a +/// lost role. The comparisons run by reflection over the model's public properties: a property /// added later is covered without touching this file, and the fixture guard fails until the fixture /// carries a distinct non-default value for it. /// @@ -28,6 +28,7 @@ public class TablePushRepositoryMappingTests // Documented key mappings: model property → entity key, where the value does not live in a column of its own name. private static readonly Dictionary DeviceKeys = new() { ["Scope"] = "PartitionKey", ["DeviceId"] = "RowKey" }; private static readonly Dictionary GrantKeys = new() { ["CodeHash"] = "RowKey" }; + private static readonly Dictionary OwnerKeys = new() { ["HomeTenantId"] = "PartitionKey", ["ObjectId"] = "RowKey" }; private static readonly Dictionary WatchKeys = new() { ["TenantId"] = "PartitionKey" }; // ── Devices ───────────────────────────────────────────────────────────── @@ -216,6 +217,75 @@ public class TablePushRepositoryMappingTests AssertDatesStoredAsUtc(input, entity, mapped); } + // ── Owners ────────────────────────────────────────────────────────────── + + [Fact] + public void Owner_round_trip_preserves_every_property() + { + var owner = FullOwner(); + + var mapped = TablePushRepository.MapOwner(TablePushRepository.StoreOwner(owner)); + + AssertSameProperties(owner, mapped); + } + + [Fact] + public void Owner_fixture_sets_every_property_to_a_distinct_non_default_value() + => AssertFixtureIsDistinctAndNonDefault(FullOwner()); + + [Fact] + public void Owner_store_writes_every_property_as_a_column_or_key() + { + var owner = FullOwner(); + + var entity = TablePushRepository.StoreOwner(owner); + + Assert.Equal(owner.HomeTenantId, entity.PartitionKey); + Assert.Equal(owner.ObjectId, entity.RowKey); + AssertEveryPropertyIsStored(entity, OwnerKeys); + } + + [Fact] + public void Owner_store_lower_cases_keys_and_upn_but_keeps_the_role_verbatim() + { + var owner = FullOwner(); + owner.HomeTenantId = "22222222-AAAA-2222-2222-222222222222"; + owner.ObjectId = "AAAAAAAA-0000-0000-0000-000000000001"; + owner.Upn = "Owner@Contoso.invalid"; + + var mapped = TablePushRepository.MapOwner(TablePushRepository.StoreOwner(owner)); + + Assert.Equal("22222222-aaaa-2222-2222-222222222222", mapped.HomeTenantId); + Assert.Equal(OwnerObjectId, mapped.ObjectId); + Assert.Equal("owner@contoso.invalid", mapped.Upn); + Assert.Equal(Constants.TenantRoles.Operator, mapped.ClaimRole); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Owner_without_a_claim_role_maps_to_null(string? stored) + { + var owner = FullOwner(); + owner.ClaimRole = null; + var entity = TablePushRepository.StoreOwner(owner); + entity["ClaimRole"] = stored; + + Assert.Null(TablePushRepository.MapOwner(entity).ClaimRole); + Assert.Null(TablePushRepository.MapOwner(new TableEntity(OwnerHomeTenantId, OwnerObjectId)).ClaimRole); + } + + [Fact] + public void Owner_store_labels_the_sign_in_date_utc_without_moving_it() + { + var input = WithUnspecifiedKinds(FullOwner()); + + var entity = TablePushRepository.StoreOwner(input); + var mapped = TablePushRepository.MapOwner(entity); + + AssertDatesStoredAsUtc(input, entity, mapped); + } + // ── Watches ───────────────────────────────────────────────────────────── [Fact] @@ -350,6 +420,15 @@ public class TablePushRepositoryMappingTests ETag = "W/\"datetime'2026-10-07T08%3A30%3A00.0000000Z'\"", }; + private static PushOwner FullOwner() => new() + { + HomeTenantId = OwnerHomeTenantId, + ObjectId = OwnerObjectId, + Upn = "owner@contoso.invalid", + LastSignInUtc = Anchor, + ClaimRole = Constants.TenantRoles.Operator, + }; + private static PushSessionWatch FullWatch() => new() { TenantId = TenantId, diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs index 2b02a37f..05ae2994 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs @@ -1138,4 +1138,48 @@ public class SlaTenantStatusLifecycleTests Assert.Equal("consecutive_failures", send.Alert.EventType); Assert.Equal(NotificationScope.Tenant(TenantId), send.Scope); } + + // ── Tenant name in the alert texts ────────────────────────────────────── + + [Fact] + public async Task BreachAndResolvedSends_NameTheTenantByItsCompanyName() + { + var h = new Harness(); + var config = WithSlaChannel(CreateConfig(successRate: true)); + config.CompanyName = "Contoso"; + config.DomainName = "contoso.com"; + h.SetTenants(config); + h.SetTerminalSessions(TenantId, Sessions(succeeded: 8, failed: 4)); + await h.Service.EvaluateAllTenantsAsync(); + + var breach = Assert.Single(h.ChannelSends).Alert; + Assert.Contains(breach.Facts, f => f.Name == "Tenant" && f.Value == "Contoso"); + Assert.Contains(breach.Facts, f => f.Name == "Tenant ID" && f.Value == TenantId); + h.ChannelSends.Clear(); + + h.SetTerminalSessions(TenantId, Sessions(succeeded: 12, failed: 0)); + await h.Service.EvaluateAllTenantsAsync(); + + var resolved = Assert.Single(h.ChannelSends).Alert; + Assert.Equal("SLA breach resolved for Contoso: Success Rate is back within target.", resolved.Summary); + Assert.Contains(resolved.Facts, f => f.Name == "Tenant ID" && f.Value == TenantId); + } + + [Fact] + public async Task ConsecutiveFailures_InlinePath_NamesTheTenantByItsDomainWithoutACompanyName() + { + var h = new Harness(); + var sends = new List(); + var config = WithSlaChannel(CreateConfig(consecutive: true, consecThreshold: 3)); + config.DomainName = "contoso.com"; + var (svc, _) = BuildInlineService(h.StatusRepo, config, FailedPage(3), sends); + var failedSession = new SessionSummary { TenantId = TenantId, SessionId = "s3", Status = SessionStatus.Failed, DeviceName = "DEV-01", FailureReason = "timeout" }; + + await svc.EvaluateSessionCompletionAsync(TenantId, failedSession); + + var alert = Assert.Single(sends).Alert; + Assert.Equal("Alert: 3 enrollments failed in a row for contoso.com", alert.Summary); + Assert.Contains(alert.Facts, f => f.Name == "Tenant" && f.Value == "contoso.com"); + Assert.Contains(alert.Facts, f => f.Name == "Tenant ID" && f.Value == TenantId); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TablePushRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TablePushRepository.cs index 376a7fb6..3bd232bd 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TablePushRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TablePushRepository.cs @@ -199,13 +199,7 @@ public class TablePushRepository : IPushDeviceRepository try { var entity = await _owners.GetEntityAsync(homeTenantId.ToLowerInvariant(), objectId.ToLowerInvariant()).ConfigureAwait(false); - return new PushOwner - { - HomeTenantId = entity.Value.PartitionKey, - ObjectId = entity.Value.RowKey, - Upn = entity.Value.GetString("Upn") ?? string.Empty, - LastSignInUtc = entity.Value.GetDateTime("LastSignInUtc") ?? DateTime.MinValue, - }; + return MapOwner(entity.Value); } catch (RequestFailedException ex) when (ex.Status == 404) { @@ -213,12 +207,17 @@ public class TablePushRepository : IPushDeviceRepository } } - public Task StampOwnerSignInAsync(string homeTenantId, string objectId, string upn, DateTime signInUtc) - => _owners.UpsertEntityAsync(new TableEntity(homeTenantId.ToLowerInvariant(), objectId.ToLowerInvariant()) + // Replace, not Merge: the stamp is the whole row, and a claim role the person no longer holds + // must disappear — a Merge cannot clear a column. + public Task StampOwnerSignInAsync(string homeTenantId, string objectId, string upn, DateTime signInUtc, string? claimRole) + => _owners.UpsertEntityAsync(StoreOwner(new PushOwner { - ["Upn"] = upn.ToLowerInvariant(), - ["LastSignInUtc"] = DateTime.SpecifyKind(signInUtc, DateTimeKind.Utc), - }, TableUpdateMode.Merge); + HomeTenantId = homeTenantId, + ObjectId = objectId, + Upn = upn, + LastSignInUtc = signInUtc, + ClaimRole = claimRole, + }), TableUpdateMode.Replace); // ── Watches ───────────────────────────────────────────────────────────── @@ -366,6 +365,23 @@ public class TablePushRepository : IPushDeviceRepository ETag = e.ETag.ToString(), }; + internal static TableEntity StoreOwner(PushOwner o) => + new(o.HomeTenantId.ToLowerInvariant(), o.ObjectId.ToLowerInvariant()) + { + ["Upn"] = o.Upn.ToLowerInvariant(), + ["LastSignInUtc"] = DateTime.SpecifyKind(o.LastSignInUtc, DateTimeKind.Utc), + ["ClaimRole"] = o.ClaimRole, + }; + + internal static PushOwner MapOwner(TableEntity e) => new() + { + HomeTenantId = e.PartitionKey, + ObjectId = e.RowKey, + Upn = e.GetString("Upn") ?? string.Empty, + LastSignInUtc = e.GetDateTime("LastSignInUtc") ?? DateTime.MinValue, + ClaimRole = string.IsNullOrEmpty(e.GetString("ClaimRole")) ? null : e.GetString("ClaimRole"), + }; + internal static TableEntity StoreWatch(PushSessionWatch w) => new(w.TenantId.ToLowerInvariant(), WatchRowKey(w.SessionId, w.OwnerObjectId)) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PatchAdminConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PatchAdminConfigurationFunction.cs index 0e806e22..88d81d00 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PatchAdminConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PatchAdminConfigurationFunction.cs @@ -211,9 +211,10 @@ namespace AutopilotMonitor.Functions.Functions.Config draft.OpsNotificationChannelsJson = NotificationChannel.RestoreRedactedList( draft.OpsNotificationChannelsJson, storedChannels); - // Same structural gate the tenant channel list passes. No GA gate on Telegram here: - // this endpoint is GlobalAdminOnly by policy. - var channelsError = TenantConfigValidation.ValidateNotificationChannels(draft.OpsNotificationChannelsJson); + // Same structural gate and one-Push-channel rule the tenant channel list passes. No GA + // gate on Telegram here: this endpoint is GlobalAdminOnly by policy. + var channelsError = TenantConfigValidation.ValidateNotificationChannels(draft.OpsNotificationChannelsJson) + ?? TenantConfigValidation.ValidateSinglePushChannel(draft.OpsNotificationChannelsJson, storedChannels); if (channelsError != null) return $"Invalid ops notification channels: {channelsError}"; } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs index 246dea5d..539cae96 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs @@ -40,7 +40,7 @@ public class AuthFunction private readonly ISignalRNotificationService _signalRService; private readonly AdminConfigurationService _adminConfigService; private readonly IOffboardingAuditRepository _offboardingRepo; - private readonly IPushDeviceRepository? _pushRepo; + private readonly Services.Push.PushOwnerSignInService? _pushSignIn; private readonly OpsEventService? _opsEvents; public AuthFunction( @@ -60,11 +60,11 @@ public class AuthFunction ISignalRNotificationService signalRService, AdminConfigurationService adminConfigService, IOffboardingAuditRepository offboardingRepo, - IPushDeviceRepository? pushRepo = null, + Services.Push.PushOwnerSignInService? pushSignIn = null, OpsEventService? opsEvents = null) { _logger = logger; - _pushRepo = pushRepo; + _pushSignIn = pushSignIn; _opsEvents = opsEvents; _offboardingRepo = offboardingRepo; _adminConfigService = adminConfigService; @@ -224,7 +224,10 @@ public class AuthFunction } // --- Post-decision side-effects --- - await HandlePostDecisionSideEffectsAsync(decision, tenantId, upn, displayName, objectId); + // The push stamp records the app-role claim's role only where no TenantAdmins row decides. + await HandlePostDecisionSideEffectsAsync(decision, tenantId, upn, displayName, objectId, + pushClaimRole: tableState == TableMemberState.NotPresent ? memberRole?.Role : null, + isGlobalAdmin: isGlobalAdmin); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(decision.Body); @@ -688,12 +691,13 @@ public class AuthFunction } /// - /// Executes post-decision side-effects: auto-admin assignment (awaited) - /// and metrics recording (fire-and-forget). + /// Executes post-decision side-effects: auto-admin assignment (awaited), metrics recording + /// (fire-and-forget) and the push sign-in (awaited, fail-soft). /// internal async Task HandlePostDecisionSideEffectsAsync( AuthDecisionResult decision, string tenantId, string upn, - string? displayName, string? objectId) + string? displayName, string? objectId, + string? pushClaimRole = null, bool isGlobalAdmin = false) { if (decision.NeedsAutoAdmin) { @@ -706,20 +710,12 @@ public class AuthFunction "Fire-and-forget RecordUserLoginAsync failed"), TaskContinuationOptions.OnlyOnFaulted); - // Push freshness stamp (K3): one upsert per portal sign-in, keyed by the person (home - // tenant + oid), so paired devices of someone who stopped signing in pause after the - // inactivity window. Awaited — a lost stamp is an early pause; fail-soft — never a 500. - if (_pushRepo != null && !string.IsNullOrEmpty(objectId)) - { - try - { - await _pushRepo.StampOwnerSignInAsync(tenantId, objectId, upn, DateTime.UtcNow); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Push owner sign-in stamp failed for tenant {TenantId}", tenantId); - } - } + // Push sign-in (K3): one stamp per portal sign-in, keyed by the person (home tenant + oid), + // so paired devices of someone who stopped signing in pause after the inactivity window; it + // also carries the app-role claim's role, revokes when that role is gone and lifts an + // inactivity pause. Awaited — a lost stamp is an early pause; the service never throws. + if (_pushSignIn != null && !string.IsNullOrEmpty(objectId)) + await _pushSignIn.OnPortalSignInAsync(tenantId, objectId, upn, pushClaimRole, isGlobalAdmin, DateTime.UtcNow); } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushFunctionSupport.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushFunctionSupport.cs index 0f4588b3..66f8fdb6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushFunctionSupport.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushFunctionSupport.cs @@ -1,4 +1,5 @@ using System.Net; +using AutopilotMonitor.Functions.Extensions; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services.Notifications; using AutopilotMonitor.Functions.Services.Push; @@ -16,15 +17,16 @@ internal static class PushFunctionSupport { /// /// The tenant scope of a push/… route: the caller's own tenant. A Global Admin passes - /// the TenantAdminOrOperator tier by bypass, so the service re-checks the table-backed tenant - /// role (K2/K5) — a GA without a member row is told to use the platform routes. + /// the TenantAdminOrOperator tier by bypass, so pairing re-checks the member role (K2/K5) and + /// refuses a Global Admin without one in this tenant like any other caller. /// public static (NotificationScope Scope, PushCaller Caller) TenantContext(HttpRequestData req) { var ctx = req.GetRequestContext(); var scope = NotificationScope.Tenant(ctx.TargetTenantId); var caller = new PushCaller(ctx.UserPrincipalName, ctx.ObjectId, ctx.TenantId, - IsScopeAdmin: ctx.IsTenantAdmin || ctx.IsGlobalAdmin, IsGlobalAdmin: ctx.IsGlobalAdmin); + IsScopeAdmin: ctx.IsTenantAdmin || ctx.IsGlobalAdmin, IsGlobalAdmin: ctx.IsGlobalAdmin, + AppRoles: req.FunctionContext.GetUser()?.GetAppRoles()); return (scope, caller); } @@ -33,7 +35,8 @@ internal static class PushFunctionSupport { var ctx = req.GetRequestContext(); var caller = new PushCaller(ctx.UserPrincipalName, ctx.ObjectId, ctx.TenantId, - IsScopeAdmin: ctx.IsGlobalAdmin, IsGlobalAdmin: ctx.IsGlobalAdmin); + IsScopeAdmin: ctx.IsGlobalAdmin, IsGlobalAdmin: ctx.IsGlobalAdmin, + AppRoles: req.FunctionContext.GetUser()?.GetAppRoles()); return (NotificationScope.Platform, caller); } @@ -43,7 +46,7 @@ internal static class PushFunctionSupport PushOpError.ChannelRequired => req.ErrorAsync(HttpStatusCode.Conflict, Constants.ApiErrorCodes.PushChannelRequired, message ?? "A Push notification channel must be enabled in this scope first."), PushOpError.NotEligible => req.ErrorAsync(HttpStatusCode.Forbidden, Constants.ApiErrorCodes.PushNotEligible, - message ?? "Pairing needs a table-backed Admin or Operator role in this scope."), + message ?? "Push notifications need an Admin or Operator role in this tenant's member list."), PushOpError.NotFound => req.NotFoundAsync(message ?? "Not found."), PushOpError.CodeInvalid => req.ErrorAsync(HttpStatusCode.NotFound, Constants.ApiErrorCodes.PairingCodeInvalid, "This pairing code is unknown, expired or already used."), diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs index c9250e61..dd011a56 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs @@ -186,6 +186,14 @@ namespace AutopilotMonitor.Functions.Helpers && !Shared.Models.Notifications.NotificationChannel.IsSupportedProviderType(candidate.WebhookProviderType)) return "Invalid webhook provider type."; + // ...and only a webhook provider: its destination passed the webhook URL gate above, never + // the channel gates of Telegram, Push or E-mail. Only a CHANGED value is checked, so a + // stored one stays saveable. + if (candidate.WebhookProviderType != existing.WebhookProviderType + && candidate.WebhookProviderType != 0 + && !Shared.Models.Notifications.NotificationChannel.IsWebhookUrlProvider(candidate.WebhookProviderType)) + return "The legacy webhook supports webhook providers only; use a notification channel for the others."; + var headersError = ValidateWebhookCustomHeaders(candidate.WebhookCustomHeadersJson); if (headersError != null) return $"Invalid custom headers: {headersError}"; @@ -199,6 +207,10 @@ namespace AutopilotMonitor.Functions.Helpers if (telegramGateError != null) return telegramGateError; + var pushCountError = ValidateSinglePushChannel(candidate.NotificationChannelsJson, existing.NotificationChannelsJson); + if (pushCountError != null) + return $"Invalid notification channels: {pushCountError}"; + var brandingUrlError = ValidateBrandingImageUrl(candidate.EnrollmentSummaryBrandingImageUrl); if (brandingUrlError != null) return $"Invalid enrollment summary branding image URL: {brandingUrlError}"; @@ -456,9 +468,8 @@ namespace AutopilotMonitor.Functions.Helpers /// Global-Admin gate for the providers that send through PLATFORM-owned infrastructure /// (): /// Telegram through the platform bot (the token lives in PreviewConfig, not in tenant - /// config), Push through the platform's push sender and VAPID key, E-mail through the - /// platform's sender identity and provider key — GA-only until the customer release. A tenant admin must not be able to create or retarget one — hiding - /// the provider in the UI is not a control. + /// config), E-mail through the platform's sender identity and provider key. A tenant admin + /// must not be able to create or retarget one — hiding the provider in the UI is not a control. /// /// Compares against the stored config so a non-GA caller can still save unrelated changes /// while a GA-created gated channel is present: only ADDING one, changing an existing @@ -492,7 +503,6 @@ namespace AutopilotMonitor.Functions.Helpers { var provider = channel.ProviderType switch { - (int)Shared.Models.Notifications.WebhookProviderType.Push => "Push", (int)Shared.Models.Notifications.WebhookProviderType.Email => "E-mail", _ => "Telegram", }; @@ -503,6 +513,25 @@ namespace AutopilotMonitor.Functions.Helpers return null; } + /// + /// At most one Push channel per scope, enabled or not: one already reaches every paired device + /// of the scope, so a second would only deliver each alert twice. Compared with the stored list, + /// so a pair saved before this rule stays saveable until it is reduced — only a save that raises + /// the count above one is refused, for every caller (a Global Admin too). The tenant and the ops + /// channel lists both pass it. Returns an error message, or null. + /// + internal static string? ValidateSinglePushChannel(string? candidateJson, string? existingJson) + { + var count = CountPushChannels(candidateJson); + return count > 1 && count > CountPushChannels(existingJson) + ? "only one Push channel is allowed — a single channel already reaches every paired device." + : null; + } + + private static int CountPushChannels(string? json) + => Shared.Models.Notifications.NotificationChannel.ParseList(json) + .Count(c => c.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Push); + /// /// Validates a generic-webhook HMAC signing secret. Returns an error message, or null when /// valid/absent. A short key is brute-forceable as an HMAC key; the printable-ASCII rule diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantDisplayName.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantDisplayName.cs new file mode 100644 index 00000000..aa677fc6 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantDisplayName.cs @@ -0,0 +1,20 @@ +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Helpers; + +/// +/// How a message names a tenant to people: the company name, else the domain, else the tenant id. +/// One chain for the push surfaces and the SLA alerts, so a tenant reads the same on every channel. +/// +public static class TenantDisplayName +{ + public static string Of(TenantConfiguration? config, string tenantId) + { + if (config != null) + { + if (!string.IsNullOrWhiteSpace(config.CompanyName)) return config.CompanyName; + if (!string.IsNullOrWhiteSpace(config.DomainName)) return config.DomainName; + } + return tenantId; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 59358a1d..71b089ab 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -541,6 +541,7 @@ builder.Services.AddSingleton( sp => sp.GetRequiredService()); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton( diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs index 777d9a00..f5e999c5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs @@ -351,7 +351,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications /// Unknown types fall back to a neutral template rather than silently rendering as Duration. /// public static NotificationAlert BuildSlaBreachAlert( - string tenantId, double currentRate, double targetRate, + string tenantId, string tenantName, double currentRate, double targetRate, int totalSessions, int failedSessions, string breachType, string? dashboardUrl = null, string? extraContext = null) { @@ -360,10 +360,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications string summary; string period; string label; - var facts = new List - { - new() { Name = "Tenant", Value = tenantId }, - }; + var facts = TenantFacts(tenantId, tenantName); switch (breachType) { @@ -435,7 +432,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications /// Emitted once when a previously-active breach is no longer detected on the next evaluation. /// public static NotificationAlert BuildSlaResolvedAlert( - string tenantId, string breachType, double? currentValue, double? targetValue, + string tenantId, string tenantName, string breachType, double? currentValue, double? targetValue, DateTime? firstBreachAt, DateTime resolvedAt, string? dashboardUrl = null) { var label = breachType switch @@ -451,12 +448,9 @@ namespace AutopilotMonitor.Functions.Services.Notifications ? FormatDuration(resolvedAt - firstBreachAt.Value) : "–"; - var facts = new List - { - new() { Name = "Tenant", Value = tenantId }, - new() { Name = "Breach Type", Value = label }, - new() { Name = "Active For", Value = durationText }, - }; + var facts = TenantFacts(tenantId, tenantName); + facts.Add(new NotificationFact { Name = "Breach Type", Value = label }); + facts.Add(new NotificationFact { Name = "Active For", Value = durationText }); if (currentValue.HasValue) facts.Add(new NotificationFact { Name = "Current", Value = $"{currentValue.Value:F1}" }); @@ -467,7 +461,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications { EventType = "sla_resolved", Title = $"🟢 SLA Breach Resolved: {label}", - Summary = $"SLA breach resolved for tenant {tenantId}: {label} is back within target.", + Summary = $"SLA breach resolved for {tenantName}: {label} is back within target.", Severity = NotificationSeverity.Success, ThemeColor = "00B050", Facts = facts, @@ -490,23 +484,22 @@ namespace AutopilotMonitor.Functions.Services.Notifications /// Builds a notification alert for consecutive enrollment failures. /// public static NotificationAlert BuildConsecutiveFailuresAlert( - string tenantId, int consecutiveFailures, + string tenantId, string tenantName, int consecutiveFailures, string? lastDeviceName, string? lastFailureReason, string? dashboardUrl = null) { + var facts = TenantFacts(tenantId, tenantName); + facts.Add(new NotificationFact { Name = "Consecutive Failures", Value = consecutiveFailures.ToString() }); + facts.Add(new NotificationFact { Name = "Last Device", Value = lastDeviceName ?? "\u2013" }); + facts.Add(new NotificationFact { Name = "Last Failure", Value = lastFailureReason ?? "\u2013" }); + var alert = new NotificationAlert { EventType = "consecutive_failures", Title = $"\ud83d\udd34 {consecutiveFailures} Consecutive Enrollment Failures", - Summary = $"Alert: {consecutiveFailures} enrollments failed in a row for tenant {tenantId}", + Summary = $"Alert: {consecutiveFailures} enrollments failed in a row for {tenantName}", Severity = NotificationSeverity.Error, ThemeColor = "FF0000", - Facts = new List - { - new() { Name = "Tenant", Value = tenantId }, - new() { Name = "Consecutive Failures", Value = consecutiveFailures.ToString() }, - new() { Name = "Last Device", Value = lastDeviceName ?? "\u2013" }, - new() { Name = "Last Failure", Value = lastFailureReason ?? "\u2013" }, - }, + Facts = facts, }; if (!string.IsNullOrEmpty(dashboardUrl)) @@ -515,6 +508,16 @@ namespace AutopilotMonitor.Functions.Services.Notifications return alert; } + /// + /// How an SLA alert names its tenant: "Tenant" carries the display name people read, + /// "Tenant ID" the id an integration keys on. The push projection allow-lists only the first. + /// + private static List TenantFacts(string tenantId, string tenantName) => new() + { + new() { Name = "Tenant", Value = tenantName }, + new() { Name = "Tenant ID", Value = tenantId }, + }; + private static string BuildHardwareText(string? manufacturer, string? model) { var parts = new[] diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs index 9b03c322..0a80c97b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs @@ -81,6 +81,9 @@ namespace AutopilotMonitor.Functions.Services.Notifications public virtual async Task SendToChannelsAsync( IEnumerable channels, NotificationAlert alert, NotificationScope scope) { + // One Push channel already reaches every paired device of the scope: a second one in the + // same send (a pair stored before validation allowed only one) would deliver it twice. + var pushSent = false; foreach (var channel in channels) { if (channel == null || !channel.HasDestination()) @@ -88,7 +91,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications if (channel.ProviderType == (int)WebhookProviderType.Push) { - await _push.SendAsync(scope, alert); + if (!pushSent) + { + pushSent = true; + await _push.SendAsync(scope, alert); + } continue; } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushDeliveryService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushDeliveryService.cs index f9b9b08c..e881de3d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushDeliveryService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushDeliveryService.cs @@ -216,7 +216,7 @@ public class PushDeliveryService : IPushChannelSender, IPushDeviceRevoker /// /// A system push to every Active device ONE person owns in a scope, through the same role and /// freshness filter as an alert (D-327: resolved at every send) — the session-watch fan-out. - /// Returns the number of deliveries. + /// The caller checks the scope's Push channel (K4). Returns the number of deliveries. /// public async Task SendSystemToOwnerAsync(NotificationScope scope, string ownerObjectId, PushPayload payload, TimeSpan? ttl = null) { @@ -337,7 +337,7 @@ public class PushDeliveryService : IPushChannelSender, IPushDeviceRevoker Track("PushPairing", new Dictionary { ["action"] = "paused", ["reason"] = reason, ["scope"] = scope.IsPlatform ? "platform" : "tenant", ["platform"] = device.Platform }); } - /// An owner-inactivity pause lifts itself once the owner signs in again (maintenance) — no re-pairing. + /// An owner-inactivity pause lifts itself once the owner signs in again (at the sign-in, the maintenance run as fallback) — no re-pairing. public async Task ResumeAsync(PushDevice device, DateTime now) { var scope = NotificationScope.FromKey(device.Scope); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushEligibility.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushEligibility.cs index 0b55e6d4..fb2a282c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushEligibility.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushEligibility.cs @@ -1,3 +1,4 @@ +using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services.Notifications; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; @@ -7,10 +8,12 @@ namespace AutopilotMonitor.Functions.Services.Push; /// /// The ONE answer to "may this person pair and receive push in this scope", asked at pairing -/// time and again at every send (Korrektur K2). Table-backed only — at send time there is no -/// token, so a role that exists solely as an Entra app-role claim cannot be re-checked and is -/// excluded fail-closed. Tenant scope: an enabled TenantAdmins row with role Admin or Operator -/// (Viewer may not pair, F10). Platform scope: an enabled GlobalAdmins row with role GlobalAdmin +/// time and again at every send (Korrektur K2). Tenant scope: the TenantAdmins row first — enabled +/// with role Admin or Operator (Viewer may not pair, F10), a disabled row is a no. Without a row, +/// the member role the Entra app-role claim granted at the person's last portal sign-in +/// ( — a send has no token to re-read), only in the person's home +/// tenant and only while that tenant keeps app roles enabled; the sign-in that loses the claim +/// role revokes the devices. Platform scope: an enabled GlobalAdmins row with role GlobalAdmin /// (Global Reader may not, F6) whose identity binding matches the owner's home tenant and object /// id — the same corroboration GlobalAdminService performs for a live caller. Reads the /// repositories, not the per-process role caches, so a revocation takes effect at the next send. @@ -82,18 +85,48 @@ public class PushEligibility } var member = await _adminRepo.GetTenantMemberAsync(scope.Key, upn); - if (member == null || !member.IsEnabled) - return false; + if (member != null) + return member.IsEnabled && IsAlertingRole(string.IsNullOrEmpty(member.Role) ? Constants.TenantRoles.Admin : member.Role); - var role = string.IsNullOrEmpty(member.Role) ? Constants.TenantRoles.Admin : member.Role; - return role == Constants.TenantRoles.Admin || role == Constants.TenantRoles.Operator; + if (string.IsNullOrEmpty(objectId) || !string.Equals(scope.Key, homeTenantId, StringComparison.OrdinalIgnoreCase)) + return false; + var owner = await _pushRepo.GetOwnerAsync(homeTenantId, objectId); + if (owner == null || !IsAlertingRole(owner.ClaimRole)) + return false; + var (config, exists) = await _tenantConfig.TryGetConfigurationAsync(scope.Key); + return exists && config.EntraAppRolesEnabled; + } + + /// Admin and Operator receive push; Viewer never does (F10). + public static bool IsAlertingRole(string? role) + => role == Constants.TenantRoles.Admin || role == Constants.TenantRoles.Operator; + + /// + /// The member role a live caller's Entra app-role claim grants in their home tenant — what a + /// pairing records as , the same rule a portal sign-in follows: + /// only without a TenantAdmins row (a row, enabled or disabled, decides alone) and only while the + /// tenant has app roles enabled. Null otherwise. + /// + public virtual async Task ClaimRoleAsync(string homeTenantId, string upn, IReadOnlyList? appRoles) + { + if (appRoles == null || appRoles.Count == 0 || string.IsNullOrWhiteSpace(homeTenantId) + || string.IsNullOrWhiteSpace(upn) || Constants.PrincipalKeys.IsApplication(upn)) + return null; + + var home = NotificationScope.Tenant(homeTenantId).Key; + if (await _adminRepo.GetTenantMemberAsync(home, upn.ToLowerInvariant()) != null) + return null; + + var (config, exists) = await _tenantConfig.TryGetConfigurationAsync(home); + return EntraAppRoleResolver.Resolve(TableMemberState.NotPresent, null, appRoles, exists && config.EntraAppRolesEnabled)?.Role; } /// /// Freshness (K3): the owner signed in to the portal within the inactivity window. The stamp /// is written at pairing and on every portal sign-in, so an absent row means "never" — not /// fresh. A device of an owner who stopped signing in pauses; roles are app-internal and a - /// deactivated Entra account would otherwise keep receiving alerts. + /// deactivated Entra account would otherwise keep receiving alerts. The window also bounds + /// the age of a recorded claim role. /// public virtual async Task IsOwnerFreshAsync(string homeTenantId, string objectId, DateTime nowUtc) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushOwnerSignInService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushOwnerSignInService.cs new file mode 100644 index 00000000..1a703a9d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushOwnerSignInService.cs @@ -0,0 +1,83 @@ +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services.Push; + +/// +/// What a portal sign-in means for the person's paired devices. It writes the freshness stamp with +/// the member role the Entra app-role claim grants (, recorded only +/// without a TenantAdmins row); it revokes the home-tenant devices once that claim role fell below +/// Operator and no row took its place — the claim's counterpart of the TenantAdmins role-loss hook +/// (K6); and it lifts an inactivity pause at once instead of at the next maintenance run. Never +/// throws: a push failure must not fail the sign-in. +/// +public class PushOwnerSignInService +{ + private readonly IPushDeviceRepository _repo; + private readonly IAdminRepository _adminRepo; + private readonly PushSettings _settings; + private readonly PushDeliveryService _delivery; + private readonly ILogger _logger; + + public PushOwnerSignInService( + IPushDeviceRepository repo, + IAdminRepository adminRepo, + PushSettings settings, + PushDeliveryService delivery, + ILogger logger) + { + _repo = repo; + _adminRepo = adminRepo; + _settings = settings; + _delivery = delivery; + _logger = logger; + } + + /// The member role the token's app-role claim grants, only when the person has no TenantAdmins row; else null. + /// The person's platform devices resume too. + public async Task OnPortalSignInAsync(string homeTenantId, string objectId, string upn, string? claimRole, bool isGlobalAdmin, DateTime nowUtc) + { + if (string.IsNullOrWhiteSpace(homeTenantId) || string.IsNullOrWhiteSpace(objectId) || string.IsNullOrWhiteSpace(upn)) + return; + + try + { + var previous = await _repo.GetOwnerAsync(homeTenantId, objectId).ConfigureAwait(false); + await _repo.StampOwnerSignInAsync(homeTenantId, objectId, upn, nowUtc, claimRole).ConfigureAwait(false); + + var home = NotificationScope.Tenant(homeTenantId); + if (PushEligibility.IsAlertingRole(previous?.ClaimRole) && !PushEligibility.IsAlertingRole(claimRole) + && await _adminRepo.GetTenantMemberAsync(home.Key, upn.ToLowerInvariant()).ConfigureAwait(false) == null) + { + await _delivery.RevokeOwnerAsync(home, upn, "app_role_lost").ConfigureAwait(false); + } + + if (previous == null || previous.LastSignInUtc < nowUtc.AddDays(-_settings.OwnerInactivityDays)) + { + await ResumeInactivityPausesAsync(home, homeTenantId, objectId, nowUtc).ConfigureAwait(false); + if (isGlobalAdmin) + await ResumeInactivityPausesAsync(NotificationScope.Platform, homeTenantId, objectId, nowUtc).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Push sign-in handling failed for tenant {TenantId}", homeTenantId); + } + } + + private async Task ResumeInactivityPausesAsync(NotificationScope scope, string homeTenantId, string objectId, DateTime nowUtc) + { + foreach (var device in await _repo.GetDevicesAsync(scope.Key).ConfigureAwait(false)) + { + if (device.Status == Constants.Push.DeviceStatus.Paused + && device.StatusReason == "owner_inactive" + && string.Equals(device.OwnerObjectId, objectId, StringComparison.OrdinalIgnoreCase) + && string.Equals(device.OwnerHomeTenantId, homeTenantId, StringComparison.OrdinalIgnoreCase)) + { + await _delivery.ResumeAsync(device, nowUtc).ConfigureAwait(false); + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushPairingService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushPairingService.cs index cc3b5dad..f13ef458 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushPairingService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushPairingService.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services.Notifications; using AutopilotMonitor.Push; using AutopilotMonitor.Shared; @@ -14,9 +15,11 @@ namespace AutopilotMonitor.Functions.Services.Push; /// /// Who is pairing or managing devices: the authenticated caller as the middleware resolved it. /// = sees and manages every device of the scope; -/// = the caller's own devices may live in the platform partition (session watches fan out there). +/// = the caller's own devices may live in the platform partition (session watches fan out there); +/// = the token's raw Entra app-role values (pairing records the member role they grant). /// -public sealed record PushCaller(string Upn, string ObjectId, string HomeTenantId, bool IsScopeAdmin, bool IsGlobalAdmin = false); +public sealed record PushCaller(string Upn, string ObjectId, string HomeTenantId, bool IsScopeAdmin, bool IsGlobalAdmin = false, + IReadOnlyList? AppRoles = null); /// Outcome of a pairing/device operation; the function maps it to a status + error code. public enum PushOpError @@ -93,10 +96,22 @@ public class PushPairingService return PushOpResult.Fail(PushOpError.NotConfigured, "The push channel is not configured on the platform."); if (!await _eligibility.HasPushChannelAsync(scope).ConfigureAwait(false)) return PushOpResult.Fail(PushOpError.ChannelRequired, "Add and enable a Push notification channel first."); - if (!await _eligibility.IsEligibleAsync(scope, caller.Upn, caller.ObjectId, caller.HomeTenantId).ConfigureAwait(false)) - return PushOpResult.Fail(PushOpError.NotEligible, "Pairing needs an Admin or Operator role held in the member list of this tenant."); + // Pairing is a portal action from a signed-in session: it counts as a sign-in for freshness, + // and it records the live app-role claim BEFORE the role check, so a member known only + // through Entra app roles can pair without waiting for the next recorded sign-in. var now = DateTime.UtcNow; + if (!string.IsNullOrEmpty(caller.HomeTenantId) && !string.IsNullOrEmpty(caller.ObjectId)) + { + var claimRole = await _eligibility.ClaimRoleAsync(caller.HomeTenantId, caller.Upn, caller.AppRoles).ConfigureAwait(false); + await _repo.StampOwnerSignInAsync(caller.HomeTenantId.ToLowerInvariant(), caller.ObjectId.ToLowerInvariant(), + caller.Upn.ToLowerInvariant(), now, claimRole).ConfigureAwait(false); + } + + if (!await _eligibility.IsEligibleAsync(scope, caller.Upn, caller.ObjectId, caller.HomeTenantId).ConfigureAwait(false)) + return PushOpResult.Fail(PushOpError.NotEligible, + scope.IsPlatform ? "Push notifications on the platform need the Global Administrator role." : null); + var code = GenerateCode(); var grant = new PushPairingGrant { @@ -110,9 +125,6 @@ public class PushPairingService ExpiresUtc = now.AddMinutes(Constants.Push.PairingGrantMinutes), }; await _repo.AddGrantAsync(grant).ConfigureAwait(false); - - // Pairing is a portal action from a signed-in session: it counts as a sign-in for freshness. - await _repo.StampOwnerSignInAsync(grant.OwnerHomeTenantId, grant.OwnerObjectId, grant.OwnerUpn, now).ConfigureAwait(false); Track("created", scope); return PushOpResult.Success(new CreatePairingResponse @@ -445,6 +457,9 @@ public class PushPairingService var device = await _repo.GetDeviceAsync(scope.Key, deviceId).ConfigureAwait(false); if (device == null || !(caller.IsScopeAdmin || IsOwn(device, caller))) return PushOpResult.Fail(PushOpError.NotFound); + // The channel is the one switch (K4); a switched-off channel is a state the panel shows inline. + if (!await _eligibility.HasPushChannelAsync(scope).ConfigureAwait(false)) + return PushOpResult.Success(new TestWebhookNotificationResponse { Success = false, Message = "The Push channel is off — paired devices receive nothing." }); if (device.Status != Constants.Push.DeviceStatus.Active) return PushOpResult.Success(new TestWebhookNotificationResponse { Success = false, Message = $"The device is {device.Status.ToLowerInvariant()} — only active devices receive pushes." }); @@ -470,11 +485,7 @@ public class PushPairingService if (scope.IsPlatform) return "Platform operator"; var (config, exists) = await _tenantConfig.TryGetConfigurationAsync(scope.Key).ConfigureAwait(false); - if (!exists) - return scope.Key; - if (!string.IsNullOrWhiteSpace(config.CompanyName)) return config.CompanyName; - if (!string.IsNullOrWhiteSpace(config.DomainName)) return config.DomainName; - return scope.Key; + return TenantDisplayName.Of(exists ? config : null, scope.Key); } // ── Helpers ───────────────────────────────────────────────────────────── diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs index 6bb2c957..372d44f0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs @@ -28,10 +28,11 @@ public interface IPushSessionWatchNotifier /// SESSION's tenant (tenant isolation of the session), the devices it fires on are the watcher's /// OWN: their home-tenant scope and, for a Global Admin, the platform scope — so an operator can /// watch a customer's session from their own paired phone. Admin and Operator only (F14), only in -/// a scope with an enabled Push channel (K4), and the fan-out runs through the same role and -/// freshness filter as any alert. Watches expire after 48 hours (the agent's emergency break), a -/// watch on a sealed session after 30 days (the user phase starts whenever the device is handed -/// out), and die with the session's tenant. +/// a scope with an enabled Push channel (K4) — checked when the watch is set and again when it +/// fires, so switching the channel off silences a pending watch (it is consumed all the same) — +/// and the fan-out runs through the same role and freshness filter as any alert. Watches expire +/// after 48 hours (the agent's emergency break), a watch on a sealed session after 30 days (the +/// user phase starts whenever the device is handed out), and die with the session's tenant. /// public class PushSessionWatchService : IPushSessionWatchNotifier { @@ -161,6 +162,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier var now = DateTime.UtcNow; var (title, body, severity) = WatchMessage(outcome, deviceName, serialNumber); + var channelOn = new Dictionary(StringComparer.Ordinal); foreach (var watch in watches) { @@ -172,6 +174,11 @@ public class PushSessionWatchService : IPushSessionWatchNotifier foreach (var scope in ScopesOf(watch, tenantId)) { + if (!channelOn.TryGetValue(scope.Key, out var on)) + channelOn[scope.Key] = on = await _eligibility.HasPushChannelAsync(scope).ConfigureAwait(false); + if (!on) + continue; + var payload = PushAlertProjector.SystemMessage("session_watch", title, body, severity, scope, now, Constants.PortalSessionUrl(sessionId, tenantId)); await _delivery.SendSystemToOwnerAsync(scope, watch.OwnerObjectId, payload, WatchPushTtl).ConfigureAwait(false); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs index a102bbc7..17d45db1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs @@ -541,7 +541,7 @@ namespace AutopilotMonitor.Functions.Services string? extraContext = null) { var alert = NotificationAlertBuilder.BuildSlaBreachAlert( - config.TenantId, currentValue, targetValue, + config.TenantId, TenantDisplayName.Of(config, config.TenantId), currentValue, targetValue, totalSessions, failedSessions, breachType, DashboardUrl, extraContext); var slaChannels = GetSlaChannels(config); @@ -584,7 +584,7 @@ namespace AutopilotMonitor.Functions.Services double? currentValue, double? targetValue, DateTime? firstBreachAt, DateTime resolvedAt) { var alert = NotificationAlertBuilder.BuildSlaResolvedAlert( - config.TenantId, breachType, currentValue, targetValue, + config.TenantId, TenantDisplayName.Of(config, config.TenantId), breachType, currentValue, targetValue, firstBreachAt, resolvedAt, DashboardUrl); var slaChannels = GetSlaChannels(config); @@ -673,7 +673,7 @@ namespace AutopilotMonitor.Functions.Services var lastReason = failedSession.FailureReason; var alert = NotificationAlertBuilder.BuildConsecutiveFailuresAlert( - tenantId, threshold, lastDevice, lastReason, DashboardUrl); + tenantId, TenantDisplayName.Of(config, tenantId), threshold, lastDevice, lastReason, DashboardUrl); var slaChannels = GetSlaChannels(config); if (slaChannels.Count > 0) diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 464077bb..503ac7d6 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -1731,7 +1731,8 @@ namespace AutopilotMonitor.Shared // PushOwners PK = owner's HOME tenant, RK = oid. LastSignInUtc stamped once per // portal sign-in — the freshness source that retires devices of // people who stopped signing in (roles are app-internal, never - // Entra-synchronized). Never pruned on its own. + // Entra-synchronized) — with ClaimRole, the Entra app-role member + // role of a person without a TenantAdmins row. Never pruned on its own. // PushPairingGrants PK = "grant", RK = SHA-256(code). Ten-minute one-shot pairing // codes; TenantId property for the offboarding property wipe; // expired lazily on redeem and by the maintenance sweep. diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IPushDeviceRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IPushDeviceRepository.cs index 01089ebd..56b9440f 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IPushDeviceRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IPushDeviceRepository.cs @@ -35,8 +35,11 @@ namespace AutopilotMonitor.Shared.DataAccess // --- Owners (PK = home tenant, RK = oid) --- Task GetOwnerAsync(string homeTenantId, string objectId); - /// Upsert of the sign-in stamp — one write per portal sign-in. - Task StampOwnerSignInAsync(string homeTenantId, string objectId, string upn, DateTime signInUtc); + /// + /// Upsert of the whole sign-in stamp — one write per portal sign-in: the time and + /// (; null clears it). + /// + Task StampOwnerSignInAsync(string homeTenantId, string objectId, string upn, DateTime signInUtc, string? claimRole); // --- Session watches (PK = tenantId, RK = "{sessionId}_{oid}") --- Task UpsertWatchAsync(PushSessionWatch watch); @@ -118,6 +121,12 @@ namespace AutopilotMonitor.Shared.DataAccess public string ObjectId { get; set; } = string.Empty; public string Upn { get; set; } = string.Empty; public DateTime LastSignInUtc { get; set; } + /// + /// The member role (Constants.TenantRoles) the Entra app-role claim granted in the home tenant at + /// the last portal sign-in, recorded only while the person has no TenantAdmins row there; null + /// otherwise. A send has no token, so this is the role a claim-only member is checked against. + /// + public string? ClaimRole { get; set; } } /// "Notify my devices when this session ends" — one row per (session, person), stored in the SESSION's tenant. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs index 8d5eaf2a..dd7a1bf0 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs @@ -45,7 +45,8 @@ namespace AutopilotMonitor.Shared.Models.Notifications /// Provider type (int form of ): /// 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson, 30=Discord, /// 40=Telegram (Global-Admin only — see the enum member), 50=Push (paired devices, no - /// destination field; Global-Admin only until the customer release). + /// destination field; open to tenant Admins, at most one Push channel per scope), + /// 60=Email (Global-Admin only). /// public int ProviderType { get; set; } @@ -70,16 +71,28 @@ namespace AutopilotMonitor.Shared.Models.Notifications => ProviderType == (int)WebhookProviderType.Push || !string.IsNullOrWhiteSpace(Url); /// - /// Providers a tenant admin may not create or retarget: they send through PLATFORM-owned - /// infrastructure (the Telegram bot; the push sender and its VAPID key; the platform's e-mail - /// sender). Enforced server-side in TenantConfigValidation, mirrored by the gaOnly flag - /// in the web ChannelEditor. + /// Providers a tenant admin may not create or retarget: they deliver to a destination the + /// caller names (a chat, mail recipients) through PLATFORM-owned infrastructure (the Telegram + /// bot; the platform's e-mail sender and identity). Enforced server-side in + /// TenantConfigValidation, mirrored by the gaOnly flag in the web ChannelEditor. Push + /// sends through the platform too, but is open to tenant Admins: its only recipients are the + /// scope's own paired devices, and a scope holds at most one Push channel. /// public static bool IsGlobalAdminOnlyProvider(int providerType) => providerType == (int)WebhookProviderType.Telegram - || providerType == (int)WebhookProviderType.Push || providerType == (int)WebhookProviderType.Email; + /// + /// True for a provider whose destination is a caller-supplied webhook URL, rendered and posted + /// through the SSRF-guarded webhook path: every supported provider except Telegram, Push and + /// E-mail. The legacy single-webhook field accepts only these. + /// + public static bool IsWebhookUrlProvider(int providerType) + => IsSupportedProviderType(providerType) + && providerType != (int)WebhookProviderType.Telegram + && providerType != (int)WebhookProviderType.Push + && providerType != (int)WebhookProviderType.Email; + /// /// Custom HTTP request headers (JSON object: { "Header-Name": "value", ... }), applied /// only when is GenericJson. Secret — redacted for readers. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs index 0a8bb2ca..73c0caa8 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs @@ -46,9 +46,9 @@ namespace AutopilotMonitor.Shared.Models.Notifications /// end-to-end encrypted per device). Not a webhook: the channel has NO destination of its /// own — Url stays empty — because the recipients are resolved at send time from /// the paired devices of the scope's Admin/Operator members (tenant channel) or of the - /// Global Admins (platform channel). Global-Admin only while the customer release is - /// pending (same gate as Telegram, enforced in TenantConfigValidation). Value 50 stays - /// reserved for this provider even if it were ever retired. + /// Global Admins (platform channel). Open to tenant Admins; at most one Push channel per + /// scope (enforced in TenantConfigValidation), since one already reaches every paired + /// device. Value 50 stays reserved for this provider even if it were ever retired. /// Push = 50, @@ -56,7 +56,7 @@ namespace AutopilotMonitor.Shared.Models.Notifications /// E-mail through the platform's own sender (the same provider path as the welcome and /// farewell mails, noreply@autopilotmonitor.com). Not a webhook: Url carries /// the recipients — one to five addresses, ';' or ',' separated — and the message is rendered - /// by the backend. Global-Admin only (same gate as Telegram and Push, enforced in + /// by the backend. Global-Admin only (same gate as Telegram, enforced in /// TenantConfigValidation) because it sends under the platform's identity and API key. /// Email = 60, diff --git a/src/Web/autopilot-monitor-web/app/about/faq.ts b/src/Web/autopilot-monitor-web/app/about/faq.ts index 8dc5a87d..7a999d00 100644 --- a/src/Web/autopilot-monitor-web/app/about/faq.ts +++ b/src/Web/autopilot-monitor-web/app/about/faq.ts @@ -22,7 +22,7 @@ export const ABOUT_FAQ: AboutFaqItem[] = [ { question: "Can I follow a Windows Autopilot enrollment live?", answer: - "Yes. Assign the Autopilot Monitor bootstrapper script to your Autopilot device groups in Intune. During enrollment the agent captures Enrollment Status Page (ESP) phases, app downloads and installs, errors, reboots, and performance snapshots and pushes them to the portal as they happen. You see every device's progress without refreshing the page or touching the device.", + "Yes. Assign the Autopilot Monitor bootstrapper script to your Autopilot device groups in Intune. During enrollment the agent captures Enrollment Status Page (ESP) phases, app downloads and installs, errors, reboots, and performance snapshots and streams them to the portal as they happen. You see every device's progress without refreshing the page or touching the device.", }, { question: "Why did my Autopilot enrollment fail, and how do I find out without touching the device?", @@ -42,7 +42,7 @@ export const ABOUT_FAQ: AboutFaqItem[] = [ { question: "How do I get alerted when an Autopilot enrollment fails?", answer: - "Configure a notification channel for Microsoft Teams, Slack, Discord, or a generic JSON webhook and choose which triggers fire: enrollment start, success, or failure. The same channel carries SLA breach and resolution alerts, consecutive-failure alerts, and hardware rejection notices. Configuration and hardware alerts also appear as bell notifications in the portal.", + "Configure a notification channel for Microsoft Teams, Slack, Discord, a generic JSON webhook, or push notifications to the phones and browsers your admins and operators pair, and choose which triggers fire: enrollment start, success, or failure. The same channel carries SLA breach and resolution alerts, consecutive-failure alerts, and hardware rejection notices. Admins and Operators can also click Notify me when done on a running enrollment to get a push when it finishes. Configuration and hardware alerts also appear as bell notifications in the portal.", }, { question: "Is there reporting on Autopilot deployments, such as success rate, duration, and failing apps?", diff --git a/src/Web/autopilot-monitor-web/app/about/page.tsx b/src/Web/autopilot-monitor-web/app/about/page.tsx index 3f66a152..2efc9e91 100644 --- a/src/Web/autopilot-monitor-web/app/about/page.tsx +++ b/src/Web/autopilot-monitor-web/app/about/page.tsx @@ -20,9 +20,9 @@ const FEATURES = [ { title: "Real-Time Enrollment Monitoring", description: - "Track every Windows Autopilot enrollment phase as it happens. Live push updates surface device registration, ESP progress, app installs, and user phase transitions without manual refreshing.", + "Track every Windows Autopilot enrollment phase as it happens. Live updates surface device registration, ESP progress, app installs, and user phase transitions without manual refreshing.", color: "blue", - bullets: ["Live phase-by-phase tracking", "Near real-time push updates via SignalR", "Per-device event stream with timestamps"], + bullets: ["Live phase-by-phase tracking", "Near real-time updates via SignalR", "Per-device event stream with timestamps"], }, { title: "Intelligent Analyze Rules", @@ -242,7 +242,7 @@ export default function AboutPage() { "Azure Functions (.NET Isolated, Flex Consumption) — serverless, scalable API", "Azure Table Storage — high-throughput event ingestion", "Azure Blob Storage — diagnostics and log bundle storage", - "Azure SignalR Service — real-time push to the portal", + "Azure SignalR Service — live updates in the portal", ], }, { @@ -269,6 +269,7 @@ export default function AboutPage() { items: [ "Microsoft Intune — agent deployment target", "Microsoft Teams, Slack, Discord & generic JSON webhooks — start, success, failure, hardware rejection and SLA alerts", + "Push notifications — end-to-end encrypted alerts and Notify me when done on paired phones and browsers", "Intune Management Extension (IME) log — event source for log pattern detection", "WMI & Registry — extended data gather rules", "Model Context Protocol (MCP) server — AI assistants query sessions, fleet metrics and diagnostics (read-only)", diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx index 76d70a5f..13f5fb46 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx @@ -9,6 +9,7 @@ import { AUTO_ACTION_MODES, describeAutoActionWarning, type AutoActionMode } fro import { SectionCardHeader } from "@/components/SectionCardHeader"; import { SearchInput } from "@/components/SearchInput"; import { filterOpsEventCatalog } from "./opsAlertRuleSearch"; +import { hasOtherPushChannel } from "@/lib/pushPortal"; import type { ChannelHealthView } from "@/lib/channelHealth"; /** Destination sentence of a platform Push channel (ChannelEditor shows it in place of the URL field). */ @@ -639,6 +640,7 @@ export function OpsAlertRulesSection({ showEventToggles={false} pushDestinationHint={PUSH_DESTINATION_HINT_OPS} health={channelHealth?.get(channel.id) ?? null} + pushTaken={hasOtherPushChannel(channels, channel.id)} /> ))} diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx index dd3e240c..8466396f 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx @@ -58,10 +58,13 @@ export function SectionAlerts() { testChannelResult={testOpsChannelResult} channelHealth={channelHealth} /> - {/* Platform Push devices (plan push-relay K4): only once the SAVED ops channel list carries an - enabled Push channel — opsNotificationChannels mirrors the stored config, the editor above - keeps its own draft. */} - {hasEnabledPushChannel(opsNotificationChannels) && } + {/* Platform Push devices (plan push-relay K4), once the config is loaded: the SAVED ops channel + list says whether the channel is on — opsNotificationChannels mirrors the stored config, the + editor above keeps its own draft. With the channel off the panel shows only while devices + are left to remove. */} + {adminConfig != null && ( + + )} ); } diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/TipOfTheDay.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/TipOfTheDay.tsx index 36235ca1..4d7e4f16 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/TipOfTheDay.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/TipOfTheDay.tsx @@ -11,6 +11,7 @@ const tips = [ "The Software Analyzer detects installed software across enrollments \u2014 find open vulnerabilities fast.", "Use the Local Admin Analyzer to spot legitimate vs. unexpected local admin accounts.", "Connect Teams or Slack under Notifications to get real-time enrollment alerts in your channel.", + "Get a push on your phone when an enrollment finishes: add a Push channel under Settings → Notifications, pair your phone, then click Notify me when done on a running session.", "Share the Progress Portal link with your team \u2014 anyone can check enrollment status by serial number.", "Missing an event in your sessions? Use Gather Rules to collect it.", "Analyze Rules are continuously refined \u2014 analysis results may shift as rules improve. This is normal.", diff --git a/src/Web/autopilot-monitor-web/app/layout.tsx b/src/Web/autopilot-monitor-web/app/layout.tsx index ad656cc1..d5557ad3 100644 --- a/src/Web/autopilot-monitor-web/app/layout.tsx +++ b/src/Web/autopilot-monitor-web/app/layout.tsx @@ -155,14 +155,14 @@ const jsonLd = { softwareHelp: { "@type": "CreativeWork", url: DOCS_URL }, sameAs: [GITHUB_REPO], featureList: [ - "Real-time enrollment monitoring with live push updates", + "Real-time enrollment monitoring with live updates", "Analyze rules that detect enrollment failure patterns automatically", "Per-session event timeline with ESP phases, app installs, errors, and performance snapshots", "Diagnostics collection (agent, IME, and device information bundle) without touching the device", "Fleet health dashboard with success rates, failure trends, and enrollment duration", "Delegated read-only administration across customer tenants for MSPs", "Model Context Protocol (MCP) server for AI assistants", - "Notifications to Microsoft Teams, Slack, Discord, and generic JSON webhooks", + "Notifications to Microsoft Teams, Slack, Discord, generic JSON webhooks and end-to-end encrypted push notifications on paired phones and browsers", ], keywords: "Windows Autopilot, Microsoft Intune, enrollment monitoring, Autopilot troubleshooting, Enrollment Status Page, Windows deployment", diff --git a/src/Web/autopilot-monitor-web/app/privacy/page.tsx b/src/Web/autopilot-monitor-web/app/privacy/page.tsx index ce5d64c9..64ccc969 100644 --- a/src/Web/autopilot-monitor-web/app/privacy/page.tsx +++ b/src/Web/autopilot-monitor-web/app/privacy/page.tsx @@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar"; import { SiteFooter } from "../../components/SiteFooter"; import { DOCS_URL } from "@/lib/config"; -const LAST_UPDATED = "14 October 2026"; +const LAST_UPDATED = "20 October 2026"; const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`; const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`; const DOCS_DPA = `${DOCS_URL}/legal/data-privacy-agreement-dpa`; @@ -80,13 +80,15 @@ export default function PrivacyPage() {
  • Sign-in identity from Microsoft Entra ID — user principal name, display name, and tenant ID
  • Audit records of administrative actions, including who performed them and when
  • Content you choose to enter about a session — report comments and annotations (a verdict on the analysis plus notes) — stored with your tenant's data and attributed to the author
  • +
  • If you pair a device for push notifications: its name, platform, push endpoint and encryption keys, the receiver app version, delivery times, and your user principal name and object ID — kept until the device is unpaired, and removed automatically when you stop signing in to the portal. The notification history stays on the device.
  • Operational request telemetry used to run and support the service
  • A contact address and company name for your tenant, if provided
  • The contact address and company name are used only to reach and identify you about this service — a technical problem affecting your tenant, a security matter, a support request, or a change that needs an - administrator's attention. They are never used for marketing and never shared. Both are optional on the + administrator's attention — and the company name also names your tenant in the alerts your own notification + channels and paired devices receive. They are never used for marketing and never shared. Both are optional on the Community plan and required before a Pro trial. Your administrators set and change them under Settings → Tenant → Contact, and clearing a field removes it. Where an organization gave a notification address during sign-up (tenant activation), that address is copied once as the initial contact and is yours to change from then on. @@ -157,8 +159,11 @@ export default function PrivacyPage() {

    Further connections exist only because you configure them: your own Azure storage account for diagnostics, - notification channels such as Teams, Slack, Discord, or a generic webhook, and your own AI assistant if a user connects - one through the MCP integration. The platform itself makes no calls to any AI or LLM provider.{" "} + notification channels such as Teams, Slack, Discord, a generic webhook, or push notifications to devices your + admins and operators pair, and your own AI assistant if a user connects one through the MCP integration. A push notification + is encrypted end to end for the receiving device and travels through the push service of its platform (Apple, + Google, Microsoft or Mozilla), which sees only ciphertext and delivery metadata. The platform itself makes no + calls to any AI or LLM provider.{" "} Data Flows & External Services maps every outbound connection and what it carries. The data processing agreement is the authoritative document for the parties engaged and the terms of their engagement; its list of sub-processors names them. @@ -213,7 +218,7 @@ export default function PrivacyPage() {

  • Entra ID authentication for portal users with a restricted signing-algorithm allow-list and identity PII logging disabled
  • Fail-closed authorization — every API route must be registered with an access policy; an unregistered route is unreachable
  • Structural tenant isolation — storage partitioning by tenant, with the tenant identity taken from the validated token and never from a client-supplied header
  • -
  • Encryption — HTTPS with a TLS 1.2 floor in transit, Azure Storage encryption at rest with platform-managed keys
  • +
  • Encryption — HTTPS with a TLS 1.2 floor in transit, Azure Storage encryption at rest with platform-managed keys; push notifications are encrypted end to end for the paired device (RFC 8291)
  • Managed identity instead of storage keys, and secret-less OIDC deployment pipelines
  • Rate limiting per device, per portal user, and per MCP user, and an event limit per enrollment session that pauses a device's uploads when it sends far more than an enrollment needs
  • Verifiable agent binaries — Authenticode-signed scripts and binaries, third-party libraries only with their vendor's own signature, a catalog signed by glueckkanja AG that lists every file of the agent package and is checked on the device before anything runs, Sigstore build attestation, and SHA-256 verification at download, through an independent backend cross-check, and at runtime
  • diff --git a/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx index 0fcfbbb5..bb725397 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx @@ -6,6 +6,7 @@ import { MAX_NOTIFICATION_CHANNELS, NotificationChannel } from "../types"; import { ChannelEditor } from "@/components/notifications/ChannelEditor"; import { SectionCardHeader } from "@/components/SectionCardHeader"; import { DOCS_PATHS } from "@/lib/docsPaths"; +import { hasOtherPushChannel } from "@/lib/pushPortal"; import type { ChannelHealthView } from "@/lib/channelHealth"; interface NotificationsSectionProps { @@ -61,7 +62,7 @@ export default function NotificationsSection({ tone="sky" iconPath="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" title="Notifications" - subtitle="Send notifications to one or more channels — Teams, Slack, Discord, or any JSON webhook (ticketing, automation, bots). Each channel picks which events it receives, including What's new product updates." + subtitle="Send notifications to one or more channels — Teams, Slack, Discord, push to paired devices, or any JSON webhook (ticketing, automation, bots). Each channel picks which events it receives, including What's new product updates." docsPath={DOCS_PATHS.notifications} />
    @@ -84,6 +85,7 @@ export default function NotificationsSection({ testResult={testChannelResult?.channelId === channel.id ? testChannelResult : null} showTelegramProvider={showTelegramProvider} health={health?.get(channel.id) ?? null} + pushTaken={hasOtherPushChannel(channels, channel.id)} /> ))} diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx index 4f09e321..7fe19427 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx @@ -37,13 +37,13 @@ export function SectionNotifications() { testChannelResult, ); - // Push devices (plan push-relay K4): the SAVED config decides, not the draft — a Push channel - // added but not yet saved has nothing to pair against on the server. Pairing is open to + // Push devices (plan push-relay K4): the SAVED config says whether the channel is on, not the + // draft — a Push channel added but not yet saved has nothing to pair against on the server. With + // the channel off the panel shows only while devices are left to remove. Pairing is open to // Admins and Operators of the tenant (a Global Admin without a member row is refused by the // server with a hint), so the panel sits outside the read-only fieldset above. const canPairDevices = user?.isTenantAdmin === true || user?.role === "Admin" || user?.role === "Operator" || user?.isGlobalAdmin === true; - const showPushDevices = canPairDevices && hasEnabledPushChannel(config?.notificationChannelsJson); return ( <> @@ -61,7 +61,9 @@ export function SectionNotifications() { showTelegramProvider={showTelegramProvider} health={channelHealth} /> - {showPushDevices && } + {canPairDevices && config != null && ( + + )} ); } diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/tenantNavSections.ts b/src/Web/autopilot-monitor-web/app/settings/tenant/tenantNavSections.ts index 9efc0c09..bb37affa 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/tenantNavSections.ts +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/tenantNavSections.ts @@ -3,7 +3,7 @@ export const TENANT_NAV_SECTIONS = [ { id: "delegations", label: "Delegated Access", description: "Who can read your tenant, and the tenants you manage (MSP)" }, { id: "autopilot", label: "Autopilot Validation", description: "Autopilot device and corporate identifier validation" }, { id: "hardware-whitelist", label: "Hardware Whitelist", description: "Manufacturer and model whitelist configuration" }, - { id: "notifications", label: "Notifications", description: "Webhook notification configuration" }, + { id: "notifications", label: "Notifications", description: "Notification channels and paired push devices" }, { id: "sla-targets", label: "SLA Targets", description: "SLA targets and breach notification settings" }, { id: "ai-integration", label: "AI Integration", description: "Connect AI assistants and self-hosted AI clients through MCP" }, { id: "bootstrap-sessions", label: "Bootstrap Sessions", description: "Create and manage bootstrap tokens" }, diff --git a/src/Web/autopilot-monitor-web/components/__tests__/marketingLinks.guard.test.ts b/src/Web/autopilot-monitor-web/components/__tests__/marketingLinks.guard.test.ts index 6bebb8d9..652c5771 100644 --- a/src/Web/autopilot-monitor-web/components/__tests__/marketingLinks.guard.test.ts +++ b/src/Web/autopilot-monitor-web/components/__tests__/marketingLinks.guard.test.ts @@ -46,6 +46,8 @@ afterAll(() => { }); const internalHrefs = (html: string) => [...html.matchAll(/]*?\shref="(\/[^"]*)"/g)].map(m => m[1]); +const inPageTargets = (html: string) => [...html.matchAll(/]*?\shref="#([^"]+)"/g)].map(m => m[1]); +const elementIds = (html: string) => new Set([...html.matchAll(/\sid="([^"]+)"/g)].map(m => m[1])); /** A page path without its trailing slash; the root and files such as /llms.txt are exempt. */ const missesSlash = (href: string) => { @@ -60,6 +62,13 @@ describe("marketing page links", () => { expect(hrefs.length).toBeGreaterThan(0); expect(hrefs.filter(missesSlash)).toEqual([]); }); + + // A renamed section id leaves its #anchor jumping nowhere, without any error. + it(`${name}: every in-page #anchor has its target on the page`, () => { + const html = renderToStaticMarkup(createElement(Page)); + const ids = elementIds(html); + expect(inPageTargets(html).filter(target => !ids.has(target))).toEqual([]); + }); } it("treats a bare page path as missing its slash, the root and files not", () => { diff --git a/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts b/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts index 621d5ed8..9973d500 100644 --- a/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts +++ b/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts @@ -40,7 +40,7 @@ const PAGES: { name: string; Page: ComponentType; sections: string[] }[] = [ { name: "landing", Page: Landing, - sections: ["hero", "stats", "evidence_loop", "just_ask", "story", "capabilities", "comparison", "how_it_works", "testimonials", "final_cta"], + sections: ["hero", "stats", "evidence_loop", "just_ask", "story", "push", "capabilities", "comparison", "how_it_works", "testimonials", "final_cta"], }, { name: "get-started", Page: GetStartedPage, sections: ["steps", "signup"] }, { name: "ai", Page: AiPage, sections: ["hero", "structured", "questions", "rules", "architecture", "connect", "closing"] }, @@ -66,6 +66,13 @@ describe("marketing page tracking", () => { expect(ids).toEqual(expect.arrayContaining(["nav_ai", "evidence_loop_ai", "just_ask_ai", "footer_ai"])); }); + it("the landing announces push in the hero pill, which jumps to the push section, and links its docs", () => { + const html = render(Landing); + expect(trackIds(html)).toEqual(expect.arrayContaining(["hero_announcement:push", "push_docs"])); + expect(html).toMatch(/]*\shref="#push"[^>]*\sdata-track="hero_announcement:push"/); + expect(html).toMatch(/]*\sid="push"[^>]*\sdata-track-section="push"/); + }); + it("the AI page carries every question text in its static HTML", () => { const html = render(AiPage); const escape = (text: string) => text.replace(/&/g, "&").replace(/'/g, "'"); diff --git a/src/Web/autopilot-monitor-web/components/__tests__/pushWatchDemo.test.ts b/src/Web/autopilot-monitor-web/components/__tests__/pushWatchDemo.test.ts new file mode 100644 index 00000000..ba4c777e --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/__tests__/pushWatchDemo.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { FRAMES, STILL_FRAME } from "@/components/landing/PushWatchDemo"; + +/** The landing's Notify me when done demo is a frame table; these pin its pacing and its end states. */ +describe("PushWatchDemo frames", () => { + it("one loop lasts about ten seconds", () => { + const total = FRAMES.reduce((sum, frame) => sum + frame.ms, 0); + expect(total).toBeGreaterThanOrEqual(9000); + expect(total).toBeLessThanOrEqual(11000); + }); + + it("the still frame tells the whole story: watched, finished, pushed, not fading", () => { + expect(FRAMES[STILL_FRAME]).toMatchObject({ watching: true, done: true, notified: true, fade: false }); + }); + + it("every loop starts from the untouched enrollment", () => { + expect(FRAMES[0]).toMatchObject({ watching: false, done: false, notified: false, fade: false }); + }); +}); diff --git a/src/Web/autopilot-monitor-web/components/ai/icons.tsx b/src/Web/autopilot-monitor-web/components/ai/icons.tsx index 5efee209..09effbb9 100644 --- a/src/Web/autopilot-monitor-web/components/ai/icons.tsx +++ b/src/Web/autopilot-monitor-web/components/ai/icons.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; -/** Stroke icons for the /ai page (24-unit grid, currentColor). */ +/** Stroke icons for the /ai page and the landing (24-unit grid, currentColor). */ type IconProps = { className?: string }; @@ -91,6 +91,18 @@ export const CopyIcon = (p: IconProps) => ( ); +export const LockIcon = (p: IconProps) => ( + + + + +); +/** The bell of the portal's Notify me when done button (SessionWatchButton). */ +export const BellIcon = (p: IconProps) => ( + + + +); /** The check chip used in landing lists (CapabilitiesStrip). */ export function CheckChip() { diff --git a/src/Web/autopilot-monitor-web/components/landing/CapabilitiesStrip.tsx b/src/Web/autopilot-monitor-web/components/landing/CapabilitiesStrip.tsx index 34f5fddb..c29e850e 100644 --- a/src/Web/autopilot-monitor-web/components/landing/CapabilitiesStrip.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/CapabilitiesStrip.tsx @@ -3,7 +3,7 @@ import { Reveal } from "./Reveal"; const CAPABILITIES = [ { title: "Real-time event stream", - detail: "Live phase tracking with push updates, from first boot to desktop.", + detail: "Phase tracking with live updates, from first boot to desktop.", }, { title: "Analyze rules", @@ -27,7 +27,7 @@ const CAPABILITIES = [ }, { title: "Notifications", - detail: "Teams and webhook alerts the moment an enrollment fails or stalls.", + detail: "Teams, Slack, webhooks, or encrypted push to your phone — the moment an enrollment fails or stalls.", }, { title: "MCP server for AI", diff --git a/src/Web/autopilot-monitor-web/components/landing/Comparison.tsx b/src/Web/autopilot-monitor-web/components/landing/Comparison.tsx index 5ee0fbba..68bb08d3 100644 --- a/src/Web/autopilot-monitor-web/components/landing/Comparison.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/Comparison.tsx @@ -3,7 +3,7 @@ import { Reveal } from "./Reveal"; const ROWS = [ { label: "Deployment visibility", - monitor: "Real-time phase tracking with live push updates", + monitor: "Real-time phase tracking with live updates", standard: "None — black box until it finishes or fails", }, { diff --git a/src/Web/autopilot-monitor-web/components/landing/Hero.tsx b/src/Web/autopilot-monitor-web/components/landing/Hero.tsx index aab2833a..fadda2a0 100644 --- a/src/Web/autopilot-monitor-web/components/landing/Hero.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/Hero.tsx @@ -11,27 +11,23 @@ const SCENARIOS = [ "Entra ID & Hybrid join", ]; -const DEVICE_ASSOCIATION_DOCS_URL = `${DOCS_URL}/getting-started/autopilot-device-preparation#device-association`; - export function Hero() { return (
    - {/* Release-day banner: Microsoft shipped Autopilot device association on 2026-08-25; - the validator is live in the same week — the pill exists to make that visible. */} + {/* Release banner: push notifications opened to every tenant on 2026-10-20; the pill + jumps to the push section further down this page. */} New - Windows Autopilot device association is generally available - — Autopilot Monitor validates associated devices from day one. + Encrypted push notifications + {" — get pinged when an enrollment finishes"} diff --git a/src/Web/autopilot-monitor-web/components/landing/LandingPage.tsx b/src/Web/autopilot-monitor-web/components/landing/LandingPage.tsx index 931e786a..02594197 100644 --- a/src/Web/autopilot-monitor-web/components/landing/LandingPage.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/LandingPage.tsx @@ -5,6 +5,7 @@ import { StatsBand } from "./StatsBand"; import { EvidenceLoop } from "./EvidenceLoop"; import { JustAsk } from "./JustAsk"; import { Story } from "./Story"; +import { PushSection } from "./PushSection"; import { CapabilitiesStrip } from "./CapabilitiesStrip"; import { Comparison } from "./Comparison"; import { HowItWorks } from "./HowItWorks"; @@ -36,6 +37,7 @@ export function LandingPage({ statsSnapshot }: { statsSnapshot: PlatformStatsSna + diff --git a/src/Web/autopilot-monitor-web/components/landing/PhoneFrame.tsx b/src/Web/autopilot-monitor-web/components/landing/PhoneFrame.tsx new file mode 100644 index 00000000..0e97909d --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/landing/PhoneFrame.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; +import { LockIcon } from "../ai/icons"; + +/** + * A phone at its lock screen for the landing demos. Clock and day are static strings (no Date in + * render); `children` sit where notifications appear. The caller crops the frame and animates + * what goes inside. + */ +export function PhoneFrame({ children }: { children?: ReactNode }) { + return ( +
    +
    +
    +
    + +

    Tuesday

    +

    10:07

    +
    +
    {children}
    +
    +
    + ); +} diff --git a/src/Web/autopilot-monitor-web/components/landing/PushSection.tsx b/src/Web/autopilot-monitor-web/components/landing/PushSection.tsx new file mode 100644 index 00000000..63c2e19f --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/landing/PushSection.tsx @@ -0,0 +1,81 @@ +import { Reveal } from "./Reveal"; +import { PushWatchDemo } from "./PushWatchDemo"; +import { ArrowRightIcon, CheckChip } from "../ai/icons"; +import { DOCS_URL } from "@/lib/config"; +import { DOCS_PATHS } from "@/lib/docsPaths"; + +/** + * Customer-facing claims: each one holds against the push channel's code (per-device RFC 8291 + * encryption, sign-in-free receiver paired from the portal, lock-screen-only payload). + */ +const POINTS = [ + { + title: "Encrypted end to end", + text: "Every alert is encrypted for exactly one paired device. Apple, Google, Microsoft and Mozilla only pass the ciphertext along.", + }, + { + title: "Works on locked-down phones", + text: "You pair the phone once by QR code from your signed-in portal. The phone itself never signs in, so it gets your alerts even where Conditional Access keeps it out of the portal.", + }, + { + title: "Only what fits a lock screen", + text: "What happened and on which device, with the serial masked. One tap opens the details, behind your sign-in.", + }, +]; + +/** + * Push notifications: the Story ends with "no dashboard-watching required", this section hands the + * enrollment to the admin's phone. Wide: text left, the demo spanning both rows on the right. + */ +export function PushSection() { + return ( +
    + +
    + ); +} diff --git a/src/Web/autopilot-monitor-web/components/landing/PushWatchDemo.tsx b/src/Web/autopilot-monitor-web/components/landing/PushWatchDemo.tsx new file mode 100644 index 00000000..e15a20e2 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/landing/PushWatchDemo.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useInViewActive } from "@/hooks/useInViewActive"; +import { useMediaQuery } from "@/hooks/useMediaQuery"; +import { BrandMark } from "../BrandMark"; +import { BellIcon, LockIcon } from "../ai/icons"; +import { PhoneFrame } from "./PhoneFrame"; + +type Cursor = "away" | "over" | "press" | "gone"; + +interface Frame { + ms: number; + /** Apps installed, out of TOTAL_APPS. */ + apps: number; + cursor: Cursor; + watching: boolean; + done: boolean; + notified: boolean; + /** The stage fades out before the loop starts over. */ + fade: boolean; +} + +const TOTAL_APPS = 12; +const DEVICE = "CONTOSO-4711"; +// The real watch push (PushSessionWatchService): title plus device name with the masked serial. +const ALERT_TITLE = "Enrollment finished"; +const ALERT_BODY = `${DEVICE} (…8F2K) finished enrollment.`; +const CAPTION = `Animation: in the portal, a click on Notify me when done switches the running enrollment ${DEVICE} to Watching. When it completes, a locked phone shows a notification from AM Alerts: ${ALERT_TITLE}. ${ALERT_BODY} End-to-end encrypted.`; +const MAX_LOOPS = 3; + +/** One loop of about ten seconds: watch a running enrollment, let it finish, get the push. */ +export const FRAMES: readonly Frame[] = [ + { ms: 1200, apps: 9, cursor: "away", watching: false, done: false, notified: false, fade: false }, + { ms: 900, apps: 9, cursor: "over", watching: false, done: false, notified: false, fade: false }, + { ms: 250, apps: 9, cursor: "press", watching: false, done: false, notified: false, fade: false }, + { ms: 900, apps: 9, cursor: "gone", watching: true, done: false, notified: false, fade: false }, + { ms: 700, apps: 10, cursor: "gone", watching: true, done: false, notified: false, fade: false }, + { ms: 700, apps: 11, cursor: "gone", watching: true, done: false, notified: false, fade: false }, + { ms: 900, apps: 12, cursor: "gone", watching: true, done: true, notified: false, fade: false }, + { ms: 3800, apps: 12, cursor: "gone", watching: true, done: true, notified: true, fade: false }, + { ms: 500, apps: 12, cursor: "gone", watching: true, done: true, notified: true, fade: true }, +]; + +/** Reduced motion shows this frame for good, and the last loop comes to rest on it. */ +export const STILL_FRAME = 7; + +const CURSOR_CLASS: Record = { + away: "translate-x-10 translate-y-8 opacity-0 duration-700", + over: "opacity-100 duration-700", + press: "scale-90 opacity-100 duration-150", + gone: "opacity-0 duration-300", +}; + +const FADE = "transition-opacity duration-300 motion-reduce:transition-none"; +const PILL = + "col-start-1 row-start-1 inline-flex items-center gap-1.5 justify-self-start whitespace-nowrap rounded-full border px-3 py-1 text-[12px] font-medium transition duration-300 motion-reduce:transition-none"; + +/** The portal's Notify me when done button: idle, pressed, Watching, gone once the session ends. */ +function WatchPill({ frame }: { frame: Frame }) { + const idle = !frame.watching && !frame.done; + const watching = frame.watching && !frame.done; + const pressed = frame.cursor === "press" ? "scale-[0.97] ring-2 ring-[var(--lp-accent-line)]" : ""; + return ( + + + + Notify me when done + + + + Watching + + + + ); +} + +function SessionCard({ frame }: { frame: Frame }) { + const percent = Math.round((frame.apps / TOTAL_APPS) * 100); + return ( +
    +
    + + + + + + + {DEVICE} +
    +

    ThinkPad X1 Carbon · User-driven

    +
    + + + {`Installing apps ${frame.apps}/${TOTAL_APPS}`} + + + Completed + + + {`${percent}%`} +
    +
    +
    +
    +
    + +
    +
    + ); +} + +/** The push as the lock screen shows it. Mounting it plays the slide-in. */ +function LockScreenAlert() { + return ( +
    + + + +
    +

    + AM Alerts + now +

    +

    {ALERT_TITLE}

    +

    {ALERT_BODY}

    +
    +
    + ); +} + +/** + * Next to the phone, never inside the push: the real notification shows no such badge. Below sm it + * sits between card and phone; from sm it hangs left of the phone, level with the notification. + */ +function EncryptedBadge({ shown }: { shown: boolean }) { + return ( + + + end-to-end encrypted + + ); +} + +/** + * Landing demo of Notify me when done: a running enrollment gets watched, finishes, and the + * phone gets the push. A setTimeout state machine like McpTerminalDemo, at most MAX_LOOPS loops. + */ +export function PushWatchDemo() { + const stageRef = useRef(null); + const reduced = useMediaQuery("(prefers-reduced-motion: reduce)"); + // Starts once half the stage is visible; no timers while it is off screen or the tab is hidden. + const active = useInViewActive(stageRef, { startThreshold: 0.5, disabled: reduced }); + const [index, setIndex] = useState(0); + const [loop, setLoop] = useState(1); + const resting = loop >= MAX_LOOPS && index === STILL_FRAME; + + useEffect(() => { + // `active` stays true when reduced motion is switched on mid-loop, hence the extra check. + if (!active || reduced || resting) return; + const timer = window.setTimeout(() => { + const next = (index + 1) % FRAMES.length; + if (next === 0) setLoop(n => n + 1); + setIndex(next); + }, FRAMES[index].ms); + return () => window.clearTimeout(timer); + }, [active, reduced, resting, index]); + + const shown = reduced ? STILL_FRAME : index; + const frame = FRAMES[shown]; + // Frame 0 restarts the loop: everything snaps back without transitions while the stage fades in. + const restart = shown === 0 ? "[&_*]:transition-none" : ""; + + return ( +
    + +
    {CAPTION}
    +
    + ); +} diff --git a/src/Web/autopilot-monitor-web/components/landing/Story.tsx b/src/Web/autopilot-monitor-web/components/landing/Story.tsx index c5d03f97..f4c2412f 100644 --- a/src/Web/autopilot-monitor-web/components/landing/Story.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/Story.tsx @@ -112,8 +112,8 @@ export function Story() { text: ( <>

    - The moment the rule fired, it pinged your team — Teams or webhook, your choice. No - dashboard-watching required. + The moment the rule fired, it pinged your team — Teams, webhook or your phone, your choice. + No dashboard-watching required.

    And instead of asking the user to hunt for logs, one click pulls the complete diff --git a/src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx b/src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx index 12d44107..db59a485 100644 --- a/src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx +++ b/src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx @@ -26,13 +26,13 @@ const EMAIL_PROVIDER = 60; /** Default destination sentence of a Push channel (tenant scope); the ops card passes its own. */ export const PUSH_DESTINATION_HINT_TENANT = - "Delivers to the devices that Admins and Operators of this tenant pair under Notifications › Push devices. Preview: Global Administrators only."; + "Delivers to the devices that Admins and Operators of this tenant pair under Notifications › Push devices."; export const PROVIDERS: { value: number; label: string; placeholder: string; - /** Offered only to Global Admins (platform-owned transport: Telegram, Push). */ + /** Offered only to Global Admins (platform-owned transport: Telegram, e-mail). */ gaOnly?: boolean; }[] = [ { value: 2, label: "Microsoft Teams (Workflow Webhook)", placeholder: "https://prod-xx.westeurope.logic.azure.com:443/workflows/..." }, @@ -45,11 +45,10 @@ export const PROVIDERS: { // way, so saving unrelated changes cannot silently drop it. { value: TELEGRAM_PROVIDER, label: "Telegram (mobile push)", placeholder: "-1003785642894", gaOnly: true }, // Web Push to the devices paired in this scope (plan push-relay). No destination: the - // recipients are the scope's paired devices, so the URL field gives way to one sentence. - // GA-only like Telegram until the customer release (K4: one gate until then). - { value: PUSH_PROVIDER, label: "Push to paired devices", placeholder: "", gaOnly: true }, + // recipients are the scope's paired devices, so the URL field gives way to a short description. + { value: PUSH_PROVIDER, label: "Push to paired devices", placeholder: "" }, // E-mail through the platform's own sender (noreply@autopilotmonitor.com) and provider key — - // GA-only like Telegram and Push; the destination is one to five recipient addresses. + // GA-only like Telegram; the destination is one to five recipient addresses. { value: EMAIL_PROVIDER, label: "E-mail (from noreply@autopilotmonitor.com)", placeholder: "ops@example.com; second@example.com", gaOnly: true }, ]; @@ -172,6 +171,7 @@ export function ChannelEditor({ showEventToggles = true, pushDestinationHint = PUSH_DESTINATION_HINT_TENANT, health = null, + pushTaken = false, }: { channel: NotificationChannel; onChange: (next: NotificationChannel) => void; @@ -179,7 +179,7 @@ export function ChannelEditor({ onTest: () => void; testing: boolean; testResult: { success: boolean; message: string } | null; - /** Global Admin: may pick the platform-owned providers (Telegram bot, Web Push, e-mail). */ + /** Global Admin: may pick the platform-owned providers (Telegram bot, e-mail). */ showTelegramProvider?: boolean; /** Tenant channels subscribe to event kinds here; ops channels are targeted by rules instead. */ showEventToggles?: boolean; @@ -187,6 +187,8 @@ export function ChannelEditor({ pushDestinationHint?: string; /** Delivery status of the SAVED channel; null for an unsaved channel, a Push channel or a reader without access. */ health?: ChannelHealthView | null; + /** Another channel of the host's list already is Push, so Push is not offered here. */ + pushTaken?: boolean; }) { const placeholder = PROVIDERS.find((p) => p.value === channel.providerType)?.placeholder; const isTelegram = channel.providerType === TELEGRAM_PROVIDER; @@ -197,7 +199,9 @@ export function ChannelEditor({ // Keep the channel's own provider listed even when gated, so a GA-created Telegram channel // does not silently render as a different provider for a tenant admin. const providerOptions = PROVIDERS.filter( - (p) => !p.gaOnly || showTelegramProvider || p.value === channel.providerType, + (p) => + p.value === channel.providerType || + ((!p.gaOnly || showTelegramProvider) && !(p.value === PUSH_PROVIDER && pushTaken)), ); // A disabled channel sends nothing, so its last status would only be history. const status = channel.enabled ? health : null; @@ -281,10 +285,13 @@ export function ChannelEditor({

    - {/* Destination: a Push channel has none — one sentence says where it delivers. */} + {/* Destination: a Push channel has none — it says where it delivers and what a push carries. */} {isPush && (
    -

    {pushDestinationHint}

    +
    +

    {pushDestinationHint}

    +

    Paired devices never sign in, so Conditional Access does not apply to them. Pushes carry only lock-screen details; the portal holds the rest.

    +
    {isActive && ( Active diff --git a/src/Web/autopilot-monitor-web/components/push/PushDevicesPanel.tsx b/src/Web/autopilot-monitor-web/components/push/PushDevicesPanel.tsx index 1b7e589d..59ee0368 100644 --- a/src/Web/autopilot-monitor-web/components/push/PushDevicesPanel.tsx +++ b/src/Web/autopilot-monitor-web/components/push/PushDevicesPanel.tsx @@ -7,7 +7,14 @@ import { useAuth } from "@/contexts/AuthContext"; import { useNotifications } from "@/contexts/NotificationContext"; import { apiErrorText } from "@/lib/apiClient"; import { formatRelativeTime } from "@/lib/push/pushFormat"; -import { describePortalDeviceStatus, platformLabel, statusTone, type PushScope, type StatusTone } from "@/lib/pushPortal"; +import { + describePortalDeviceStatus, + platformLabel, + showPushDevicesPanel, + statusTone, + type PushScope, + type StatusTone, +} from "@/lib/pushPortal"; import { listPushDevices, removePushDevice, testPushDevice } from "@/lib/pushPortalApi"; import type { PairingDeviceDto, PushDeviceDto } from "@/lib/generated/wire-types.generated"; @@ -26,15 +33,18 @@ const TOAST_KEY = "push-devices"; interface PushDevicesPanelProps { /** `tenant` talks to push/…, `platform` to global/push/… — and picks the host's card styling. */ scope: PushScope; + /** The SAVED channel list carries an enabled Push channel (K4: the one switch for pairing and delivery). */ + channelEnabled: boolean; } /** * The paired devices of a scope (plan push-relay): everyone sees their own, scope admins every - * device. Hosts render it only when the SAVED channel list carries an enabled Push channel (K4) - * and the caller may pair (Admin/Operator of the tenant, or a Global Admin on the platform card), - * and deliberately OUTSIDE any read-only fieldset: an Operator cannot edit channels but pairs. + * device. Hosts render it for everyone who may pair (Admin/Operator of the tenant, or a Global + * Admin on the platform card), deliberately OUTSIDE any read-only fieldset: an Operator cannot + * edit channels but pairs. With the channel off the panel stays only while a device of this + * scope is left, so it can be removed; pairing and tests wait for the channel. */ -export function PushDevicesPanel({ scope }: PushDevicesPanelProps) { +export function PushDevicesPanel({ scope, channelEnabled }: PushDevicesPanelProps) { const { getAccessToken } = useAuth(); const { addNotification, notifyError } = useNotifications(); const [devices, setDevices] = useState(null); @@ -82,6 +92,8 @@ export function PushDevicesPanel({ scope }: PushDevicesPanelProps) { setPairingOpen(false); }, []); + if (!showPushDevicesPanel(channelEnabled, devices, scope)) return null; + const sendTest = async (device: PushDeviceDto) => { setTestingId(device.deviceId); try { @@ -116,12 +128,13 @@ export function PushDevicesPanel({ scope }: PushDevicesPanelProps) { ? "bg-amber-600 hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600" : "bg-sky-600 hover:bg-sky-700"; + const pairBlocked = channelRequired || !channelEnabled; const pairButton = (