fix(security): PUT preview/notification-email requires a member role once the tenant has members; roleless writes only in the signup window

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-29 23:57:35 +02:00
parent 6c50a54249
commit ecfa59d049
5 changed files with 156 additions and 3 deletions
@@ -36,6 +36,14 @@ same table) — the only reliable address: it is typed by the user on the activa
page (`PUT /api/preview/notification-email`). The signup admin UPN may have no mailbox and
`ContactEmail` is merely seeded from the notification email.
The PUT is `AuthenticatedUserWithRole`, not a member tier, because the signup user is still
roleless until approval auto-promotes them to Admin. The function re-gates on the resolved
role (`PreviewWhitelistFunction.MayWriteNotificationEmailAsync`): a member role or platform
scope always may write; a roleless caller may write only while the tenant has **no enabled
member** at all. Once any member exists the window is closed for good — the address feeds the
welcome and farewell mails and is shown to operators as the tenant contact, so an ordinary
employee with a JWT but no product role must not be able to redirect or clear it (403).
That makes activation and address entry concurrent writers. With auto-approve, activation
usually wins (observed in production: approve at T+64 s, address saved at T+69 s → the
approval path found no address and the mail was silently skipped).
+4
View File
@@ -1,5 +1,9 @@
# Log
## 2026-08-29 (12)
* **Update**: `backend/tenant-activation-welcome-mail.md` — `PUT preview/notification-email` moved to `AuthenticatedUserWithRole`; roleless callers may write the address only while the tenant has no enabled member (signup window), afterwards a member role is required.
## 2026-08-29 (11)
* **Update**: `rules/gather-rule-phase-scoping.md` — `on_event` feedback-loop guard: `TimelineEventStream` publishes the event source and `GatherRuleExecutorHost` never dispatches `on_event` for gather-emitted events (a rule triggering on its own output type looped for the whole session); per-rule on_event execution cap (100/session); backend rejects `on_event` rules whose trigger equals their output type or `gather_result`.
@@ -0,0 +1,102 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Rules;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// PUT /api/preview/notification-email writes tenant-shared state that feeds the welcome and
/// farewell mails. The route must admit roleless callers (first-touch signup) but only while the
/// tenant has no member yet — afterwards a member role is required, so an ordinary employee with a
/// valid JWT and no product role can no longer redirect or suppress the tenant's correspondence.
/// </summary>
public class PreviewNotificationEmailWriteGateTests
{
private const string TenantId = "11111111-2222-3333-4444-555555555555";
[Fact]
public void Route_is_AuthenticatedUserWithRole_so_the_function_can_see_the_resolved_role()
{
var entry = EndpointAccessPolicyCatalog.FindPolicy("PUT", "/api/preview/notification-email");
Assert.NotNull(entry);
Assert.Equal("preview/notification-email", entry!.RouteTemplate);
Assert.Equal(EndpointPolicy.AuthenticatedUserWithRole, entry.Policy);
}
[Theory]
[InlineData(Constants.TenantRoles.Admin)]
[InlineData(Constants.TenantRoles.Operator)]
[InlineData(Constants.TenantRoles.Viewer)]
public async Task Member_role_may_write_even_when_the_tenant_has_members(string role)
{
var admins = AdminsWith(enabledMember: true);
var ctx = new RequestContext { UserRole = role };
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
admins.Verify(a => a.GetTenantAdminsAsync(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Global_admin_may_write()
{
var admins = AdminsWith(enabledMember: true);
var ctx = new RequestContext { IsGlobalAdmin = true };
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
}
[Fact]
public async Task Roleless_caller_may_write_while_the_tenant_has_no_member_yet()
{
var admins = AdminsWith(enabledMember: false);
var ctx = new RequestContext { UserRole = "Authenticated" };
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
}
[Fact]
public async Task Roleless_caller_is_rejected_once_the_tenant_has_an_enabled_member()
{
var admins = AdminsWith(enabledMember: true);
var ctx = new RequestContext { UserRole = "Authenticated" };
Assert.False(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
}
[Fact]
public async Task Disabled_members_do_not_close_the_signup_window()
{
var admins = new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
{ CallBase = false };
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminEntity>
{
new() { TenantId = TenantId, Upn = "former@contoso.com", IsEnabled = false, Role = Constants.TenantRoles.Admin },
});
var ctx = new RequestContext { UserRole = "Authenticated" };
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
}
private static Mock<TenantAdminsService> AdminsWith(bool enabledMember)
{
var admins = new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
{ CallBase = false };
var members = new List<TenantAdminEntity>();
if (enabledMember)
members.Add(new TenantAdminEntity { TenantId = TenantId, Upn = "admin@contoso.com", IsEnabled = true, Role = Constants.TenantRoles.Admin });
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(members);
return admins;
}
}
@@ -1,6 +1,7 @@
using System.Net;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Azure.Functions.Worker;
@@ -11,7 +12,8 @@ namespace AutopilotMonitor.Functions.Functions.Rules;
/// <summary>
/// CRUD endpoints for managing the tenant-activation whitelist (table: PreviewWhitelist).
/// All endpoints are Global Admin only (except notification-email which is AuthenticatedUser).
/// All endpoints are Global Admin only, except PUT notification-email: that one is
/// AuthenticatedUserWithRole and gated in-function (member role, or the tenant has no members yet).
/// </summary>
public class PreviewWhitelistFunction
{
@@ -19,6 +21,7 @@ public class PreviewWhitelistFunction
private readonly PreviewWhitelistService _previewWhitelistService;
private readonly TenantApprovalService _tenantApprovalService;
private readonly TenantConfigurationService _tenantConfigurationService;
private readonly TenantAdminsService _tenantAdminsService;
private readonly IEmailService _emailService;
public PreviewWhitelistFunction(
@@ -26,12 +29,14 @@ public class PreviewWhitelistFunction
PreviewWhitelistService previewWhitelistService,
TenantApprovalService tenantApprovalService,
TenantConfigurationService tenantConfigurationService,
TenantAdminsService tenantAdminsService,
IEmailService emailService)
{
_logger = logger;
_previewWhitelistService = previewWhitelistService;
_tenantApprovalService = tenantApprovalService;
_tenantConfigurationService = tenantConfigurationService;
_tenantAdminsService = tenantAdminsService;
_emailService = emailService;
}
@@ -156,7 +161,8 @@ public class PreviewWhitelistFunction
/// <summary>
/// PUT /api/preview/notification-email
/// Saves the caller's notification email for the activation notice.
/// AuthenticatedUser policy — preview-blocked users can call this.
/// AuthenticatedUserWithRole policy — preview-blocked users can call this, but only while the
/// tenant has no members (see <see cref="MayWriteNotificationEmailAsync"/>).
/// </summary>
[Function("SavePreviewNotificationEmail")]
[Authorize]
@@ -174,6 +180,16 @@ public class PreviewWhitelistFunction
return bad;
}
if (!await MayWriteNotificationEmailAsync(req.GetRequestContext(), tenantId, _tenantAdminsService))
{
_logger.LogWarning(
"Preview notification email write denied for roleless caller {Upn} — tenant {TenantId} already has members",
principal?.GetUserPrincipalName() ?? "unknown", tenantId);
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "A tenant member role is required to change the notification email" });
return forbidden;
}
var body = await req.ReadFromJsonAsync<SaveNotificationEmailRequest>();
var email = body?.Email?.Trim();
@@ -208,6 +224,25 @@ public class PreviewWhitelistFunction
return response;
}
/// <summary>
/// The notification email is tenant-shared state consumed by privileged flows (welcome mail,
/// offboarding farewell, operator contact). A member role (or platform scope) always may write
/// it. A roleless caller may write it ONLY while the tenant has no enabled member at all — the
/// first-touch signup window, where the activating user is still roleless because the requester
/// is auto-promoted to Admin only on approval. Once any member exists, that window is closed for
/// good: an ordinary employee with a valid JWT but no product role must not be able to redirect
/// or suppress the tenant's correspondence.
/// </summary>
internal static async Task<bool> MayWriteNotificationEmailAsync(
RequestContext ctx, string tenantId, TenantAdminsService tenantAdminsService)
{
if (ctx.IsTenantMemberOrGlobalAdmin())
return true;
var members = await tenantAdminsService.GetTenantAdminsAsync(tenantId);
return !members.Any(m => m.IsEnabled);
}
/// <summary>
/// POST /api/preview/send-welcome-email/{tenantId}
/// Sends (or resends) the activation welcome email. Global Admin only.
@@ -223,7 +223,11 @@ public static class EndpointAccessPolicyCatalog
// ProgressPortalFunction; regression history c4dabeee).
new("GET", "progress/sessions/lookup", EndpointPolicy.AuthenticatedUserWithRole, TenantScoping.QueryParam),
new("GET", "progress/sessions/{sessionId}/events", EndpointPolicy.AuthenticatedUser, TenantScoping.QueryParam),
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUser),
// AuthenticatedUserWithRole (not plain AuthenticatedUser): the activating user is still
// roleless during first-touch signup, so the tier must admit non-members — but the address is
// tenant-shared state feeding the welcome/farewell mails, so the function re-gates on the
// resolved role: roleless callers may only write while the tenant has no member yet.
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUserWithRole),
new("GET", "feedback/status", EndpointPolicy.AuthenticatedUser),
new("POST", "feedback", EndpointPolicy.AuthenticatedUser),
new("GET", "config/latest-versions", EndpointPolicy.AuthenticatedUser),