diff --git a/internal b/internal index b7ea31a8..9b29bfd1 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit b7ea31a8ab3f30add17350dd98837ef7426582c7 +Subproject commit 9b29bfd12a76df5c99be679df62cd02046905df3 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushDeliveryServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushDeliveryServiceTests.cs index 9fe50e60..66dea76f 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushDeliveryServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushDeliveryServiceTests.cs @@ -38,7 +38,10 @@ public class PushDeliveryServiceTests var active = h.AddDevice("d1", Constants.Push.DeviceStatus.Active); h.AddDevice("d2", Constants.Push.DeviceStatus.Pending); h.AddDevice("d3", Constants.Push.DeviceStatus.Paused); - h.AddDevice("d4", Constants.Push.DeviceStatus.Active, scope: Constants.Push.PlatformScope); + // Another org's Global Admin: a tenant alert never reaches a platform device unless this is the + // owner's home tenant (D-334, pinned below). + h.AddDevice("d4", Constants.Push.DeviceStatus.Active, scope: Constants.Push.PlatformScope) + .OwnerHomeTenantId = "22222222-2222-2222-2222-222222222222"; var stats = await h.Sut.SendCoreAsync(Tenant, Alert()); @@ -505,6 +508,63 @@ public class PushDeliveryServiceTests public void Dispose() { } } + [Fact] + public async Task Tenant_alert_also_reaches_platform_devices_whose_owners_home_tenant_it_is() + { + var h = new Harness(); + var own = h.AddDevice("d1", Constants.Push.DeviceStatus.Active); + var ga = h.AddDevice("ga", Constants.Push.DeviceStatus.Active, upn: "ga@contoso.invalid", scope: Constants.Push.PlatformScope); + h.AddDevice("ga-paused", Constants.Push.DeviceStatus.Paused, upn: "ga@contoso.invalid", scope: Constants.Push.PlatformScope); + // Another Global Admin whose home tenant is a different one: never reached by this tenant. + h.AddDevice("other-ga", Constants.Push.DeviceStatus.Active, upn: "other@contoso.invalid", scope: Constants.Push.PlatformScope) + .OwnerHomeTenantId = "22222222-2222-2222-2222-222222222222"; + + var stats = await h.Sut.SendCoreAsync(Tenant, Alert()); + + Assert.Equal(2, stats.Targets); + Assert.Equal(2, stats.Delivered); + Assert.Equal( + new[] { own.Endpoint, ga.Endpoint }.OrderBy(x => x), + h.Http.Requests.Select(r => r.RequestUri!.ToString()).OrderBy(x => x)); + // The tenant row passes the tenant rule, the platform row the platform rule. + h.Eligibility.Verify(e => e.IsEligibleAsync(Tenant, "admin@contoso.invalid", It.IsAny(), TenantId), Times.Once); + h.Eligibility.Verify(e => e.IsEligibleAsync(NotificationScope.Platform, "ga@contoso.invalid", It.IsAny(), TenantId), Times.Once); + h.Eligibility.Verify(e => e.IsEligibleAsync(Tenant, "ga@contoso.invalid", It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Former_global_admins_platform_device_gets_nothing_from_its_home_tenant_and_a_platform_alert_never_pulls_tenant_rows() + { + var h = new Harness(); + var tenantRow = h.AddDevice("d1", Constants.Push.DeviceStatus.Active); + var ga = h.AddDevice("ga", Constants.Push.DeviceStatus.Active, upn: "ga@contoso.invalid", scope: Constants.Push.PlatformScope); + h.Eligibility.Setup(e => e.IsEligibleAsync(NotificationScope.Platform, "ga@contoso.invalid", It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var tenantStats = await h.Sut.SendCoreAsync(Tenant, Alert()); + Assert.Equal(1, tenantStats.Targets); + Assert.Equal(tenantRow.Endpoint, Assert.Single(h.Http.Requests).RequestUri!.ToString()); + + h.Eligibility.Setup(e => e.IsEligibleAsync(NotificationScope.Platform, "ga@contoso.invalid", It.IsAny(), It.IsAny())).ReturnsAsync(true); + var platformStats = await h.Sut.SendCoreAsync(NotificationScope.Platform, Alert()); + Assert.Equal(1, platformStats.Targets); + Assert.Equal(2, h.Http.Requests.Count); + Assert.Equal(ga.Endpoint, h.Http.Requests[1].RequestUri!.ToString()); + } + + [Fact] + public async Task Same_endpoint_in_both_scopes_is_sent_to_once() + { + var h = new Harness(); + var tenantRow = h.AddDevice("d1", Constants.Push.DeviceStatus.Active, upn: "ga@contoso.invalid"); + h.AddDevice("ga", Constants.Push.DeviceStatus.Active, upn: "ga@contoso.invalid", scope: Constants.Push.PlatformScope) + .Endpoint = tenantRow.Endpoint; + + var stats = await h.Sut.SendCoreAsync(Tenant, Alert()); + + Assert.Equal(1, stats.Targets); + Assert.Single(h.Http.Requests); + } + private static NotificationAlert Alert() => new() { EventType = "enrollment_failed", diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingServiceTests.cs index c1e03e8e..a9b9ed95 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingServiceTests.cs @@ -388,6 +388,50 @@ public class PushPairingServiceTests } /// Records every request (and its body, read before the sender disposes it) and answers with a configurable status (201 by default). + [Fact] + public async Task Home_tenant_list_shows_a_global_admins_platform_device_which_the_tenant_routes_refuse() + { + var h = new Harness(); + var ga = new PushCaller("ga@contoso.invalid", "aaaaaaaa-0000-0000-0000-00000000000a", TenantId, IsScopeAdmin: true, IsGlobalAdmin: true); + h.Repo.Devices[(Constants.Push.PlatformScope, "ga-phone")] = PlatformDevice("ga-phone", ga, TenantId, h.Keys.Active.Kid); + h.Repo.Devices[(Constants.Push.PlatformScope, "other-phone")] = PlatformDevice("other-phone", + new PushCaller("other@contoso.invalid", "aaaaaaaa-0000-0000-0000-00000000000b", "22222222-2222-2222-2222-222222222222", IsScopeAdmin: true, IsGlobalAdmin: true), + "22222222-2222-2222-2222-222222222222", h.Keys.Active.Kid); + + // Scope admins see it (as somebody else's device), the owner as their own, an operator not at all. + var adminView = Assert.Single((await h.Sut.ListAsync(Tenant, Admin)).Devices); + Assert.Equal("ga-phone", adminView.DeviceId); + Assert.Equal("platform", adminView.Scope); + Assert.False(adminView.IsOwn); + Assert.True(Assert.Single((await h.Sut.ListAsync(Tenant, ga)).Devices).IsOwn); + Assert.Empty((await h.Sut.ListAsync(Tenant, Operator)).Devices); + // The platform list itself is untouched by the rule. + Assert.All((await h.Sut.ListAsync(NotificationScope.Platform, ga)).Devices, d => Assert.Equal("platform", d.Scope)); + + // Managed under the platform's device list: the tenant routes do not know the row. + Assert.Equal(PushOpError.NotFound, await h.Sut.DeleteAsync(Tenant, "ga-phone", Admin)); + Assert.False((await h.Sut.TestAsync(Tenant, "ga-phone", Admin)).Ok); + Assert.Equal(2, h.Repo.Devices.Count); + } + + private static PushDevice PlatformDevice(string id, PushCaller owner, string homeTenantId, string kid) => new() + { + Scope = Constants.Push.PlatformScope, + DeviceId = id, + OwnerUpn = owner.Upn, + OwnerObjectId = owner.ObjectId, + OwnerHomeTenantId = homeTenantId, + Endpoint = $"https://web.push.apple.com/QF/{id}", + P256dh = "p", + Auth = "a", + DeviceSecretHash = "hash", + VapidKid = kid, + Label = id, + Platform = "ios-homescreen", + Status = Constants.Push.DeviceStatus.Active, + PairedUtc = DateTime.UtcNow, + }; + internal sealed class RecordingHandler : HttpMessageHandler { public List Requests { get; } = new(); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushDeliveryService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushDeliveryService.cs index 267ddd6c..f11ec2fd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushDeliveryService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushDeliveryService.cs @@ -133,13 +133,7 @@ public class PushDeliveryService : IPushChannelSender, IPushDeviceRevoker } var now = DateTime.UtcNow; - var candidates = (await _repo.GetDevicesAsync(scope.Key).ConfigureAwait(false)) - .Where(d => d.Status == Constants.Push.DeviceStatus.Active && d.Kind == "webpush") - .ToList(); - if (candidates.Count == 0) - return stats; - - var recipients = await FilterEligibleAsync(scope, candidates, now).ConfigureAwait(false); + var recipients = await ResolveRecipientsAsync(scope, now).ConfigureAwait(false); if (recipients.Count == 0) return stats; @@ -177,6 +171,39 @@ public class PushDeliveryService : IPushChannelSender, IPushDeviceRevoker return stats; } + /// + /// The Active devices an alert in reaches, role and freshness re-checked + /// per owner (D-327). A tenant scope additionally reaches the Active platform devices whose + /// owner's home tenant it is (D-334): a Global Admin's receiver holds one pairing, and in their + /// own tenant they are an admin like any other — those rows pass the platform eligibility rule, + /// and one endpoint is sent to once whichever scope its row lives in. No other tenant ever + /// reaches a platform device. + /// + internal async Task> ResolveRecipientsAsync(NotificationScope scope, DateTime now) + { + var own = ActiveWebPush(await _repo.GetDevicesAsync(scope.Key).ConfigureAwait(false)); + var recipients = own.Count == 0 ? new List() : await FilterEligibleAsync(scope, own, now).ConfigureAwait(false); + if (scope.IsPlatform) + return recipients; + + var homeTenantRows = ActiveWebPush(await _repo.GetDevicesAsync(NotificationScope.Platform.Key).ConfigureAwait(false)) + .Where(d => string.Equals(d.OwnerHomeTenantId, scope.Key, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (homeTenantRows.Count == 0) + return recipients; + + var endpoints = new HashSet(recipients.Select(d => d.Endpoint), StringComparer.Ordinal); + foreach (var device in await FilterEligibleAsync(NotificationScope.Platform, homeTenantRows, now).ConfigureAwait(false)) + { + if (endpoints.Add(device.Endpoint)) + recipients.Add(device); + } + return recipients; + } + + private static List ActiveWebPush(List devices) + => devices.Where(d => d.Status == Constants.Push.DeviceStatus.Active && d.Kind == "webpush").ToList(); + /// One push to one device, outside the flood window: test, paired, revoked, paused, session watch. public async Task SendSystemAsync(PushDevice device, PushPayload payload, TimeSpan? ttl = null) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushPairingService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushPairingService.cs index 34b827f9..cc3b5dad 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushPairingService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushPairingService.cs @@ -398,6 +398,14 @@ public class PushPairingService public async Task ListAsync(NotificationScope scope, PushCaller caller) { var devices = await _repo.GetDevicesAsync(scope.Key).ConfigureAwait(false); + // A Global Admin's platform device is listed in their home tenant as well: it receives this + // tenant's alerts without a second pairing (D-334), so it belongs where the recipients show. + // The tenant routes never act on it (DELETE/test look the id up in the tenant partition only). + if (!scope.IsPlatform) + { + devices.AddRange((await _repo.GetDevicesAsync(NotificationScope.Platform.Key).ConfigureAwait(false)) + .Where(d => string.Equals(d.OwnerHomeTenantId, scope.Key, StringComparison.OrdinalIgnoreCase))); + } var items = devices .Where(d => d.Status != Constants.Push.DeviceStatus.Pending) .Where(d => caller.IsScopeAdmin || IsOwn(d, caller)) @@ -415,6 +423,7 @@ public class PushPairingService LastOpenedUtc = d.LastOpenedUtc, OwnerUpn = d.OwnerUpn, IsOwn = IsOwn(d, caller), + Scope = d.Scope == Constants.Push.PlatformScope ? "platform" : "tenant", }) .ToList(); return new PushDeviceListResponse { Devices = items }; diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 81b42abf..7e07d249 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -3331,6 +3331,8 @@ export interface PushDeviceDto { ownerUpn: string; /** True for the caller's own devices. */ isOwn: boolean; + /** "tenant": paired in this scope. "platform": a Global Administrator's platform device, listed in their home tenant because it receives this tenant's alerts without a second pairing (D-334); the tenant routes do not act on it — it is managed under the platform's device list. */ + scope: string; } /** GET push/devices · GET global/push/devices — own devices (everyone) plus every device of the scope (admins). */ diff --git a/src/Shared/AutopilotMonitor.Shared/Models/PushApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/PushApiModels.cs index a6789340..2d7e692a 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/PushApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/PushApiModels.cs @@ -133,6 +133,12 @@ namespace AutopilotMonitor.Shared.Models public string OwnerUpn { get; set; } = default!; /// True for the caller's own devices. public bool IsOwn { get; set; } + /// + /// "tenant": paired in this scope. "platform": a Global Administrator's platform device, listed + /// in their home tenant because it receives this tenant's alerts without a second pairing + /// (D-334); the tenant routes do not act on it — it is managed under the platform's device list. + /// + public string Scope { get; set; } = "tenant"; } /// GET/PUT/DELETE sessions/{sessionId}/watch — "notify my devices when this session ends". diff --git a/src/Web/autopilot-monitor-web/components/push/PushDevicesPanel.tsx b/src/Web/autopilot-monitor-web/components/push/PushDevicesPanel.tsx index 4cbe165f..7d401c8d 100644 --- a/src/Web/autopilot-monitor-web/components/push/PushDevicesPanel.tsx +++ b/src/Web/autopilot-monitor-web/components/push/PushDevicesPanel.tsx @@ -167,10 +167,21 @@ export function PushDevicesPanel({ scope }: PushDevicesPanelProps) { const confirming = confirmRemoveId === device.deviceId; const busy = removingId === device.deviceId || testingId === device.deviceId; const canTest = device.status !== "Pending"; + // A Global Administrator's platform device shown in their home tenant (D-334): it receives + // this tenant's alerts without a second pairing and is managed on the platform side. + const viaPlatform = device.scope === "platform"; return (
  • {device.label || "Device"} {platformLabel(device.platform)} + {viaPlatform && ( + + Platform + + )} - - {confirming ? ( - <> - {`Remove ${device.label || "this device"}?`} - - - - ) : ( - <> - - - - )} - + {viaPlatform ? ( + Managed under Platform › Push devices + ) : ( + + {confirming ? ( + <> + {`Remove ${device.label || "this device"}?`} + + + + ) : ( + <> + + + + )} + + )}
  • ); })} diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index f784d6b6..1bf86f6d 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -15564,6 +15564,14 @@ "name": "boolean" }, "doc": "True for the caller's own devices." + }, + { + "name": "scope", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "\"tenant\": paired in this scope. \"platform\": a Global Administrator's platform device, listed in their home tenant because it receives this tenant's alerts without a second pairing (D-334); the tenant routes do not act on it — it is managed under the platform's device list." } ] }, diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index 5cb0e161..0e541652 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -3331,6 +3331,8 @@ export interface PushDeviceDto { ownerUpn: string; /** True for the caller's own devices. */ isOwn: boolean; + /** "tenant": paired in this scope. "platform": a Global Administrator's platform device, listed in their home tenant because it receives this tenant's alerts without a second pairing (D-334); the tenant routes do not act on it — it is managed under the platform's device list. */ + scope: string; } /** GET push/devices · GET global/push/devices — own devices (everyone) plus every device of the scope (admins). */