From 030bd0eede8cd87243c83dd1cec7d042d025ec87 Mon Sep 17 00:00:00 2001
From: Heimdall
Date: Wed, 11 Nov 2026 12:16:18 +0000
Subject: [PATCH 1/7] Members: demoting an admin derives the flags from the
roles left after the removal
Co-Authored-By: Claude Sonnet 5.5
---
src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs
index e9b536b..f761a3f 100644
--- a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs
+++ b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs
@@ -177,6 +177,8 @@ public sealed class IndexModel(
}
else if (!(user.Id == Actor.UserAccount.Id && role == MemberRole.PlatformAdmin))
{
+ // Out of the collection too, so the flags below derive from the roles as they will stand after the save.
+ user.Membership.RoleAssignments.Remove(existingAssignment);
dbContext.RoleAssignments.Remove(existingAssignment);
}
--
2.47.3
From 27ca04f1d6ef1180b29ad55cbf62e408fec76a72 Mon Sep 17 00:00:00 2001
From: Heimdall
Date: Wed, 11 Nov 2026 12:16:18 +0000
Subject: [PATCH 2/7] Bootstrap admin promotion never lifts a suspension and
writes a receipt when it changes status (tests cover this and the demotion
fix)
Co-Authored-By: Claude Sonnet 5.5
---
.../MembershipSynchronizationService.cs | 31 +--
.../Shared/ApplicationBootstrapper.cs | 51 ++++-
.../Bifrost.Web.Tests/BootstrapAdminTests.cs | 176 ++++++++++++++++++
3 files changed, 233 insertions(+), 25 deletions(-)
create mode 100644 tests/Bifrost.Web.Tests/BootstrapAdminTests.cs
diff --git a/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs b/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs
index 8ee13a0..6d41d73 100644
--- a/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs
+++ b/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs
@@ -12,7 +12,9 @@ public sealed class MembershipSynchronizationService(
BifrostDbContext dbContext,
BifrostIdentityService bifrostIdentityService,
IOptions bootstrapOptions,
- TimeProvider timeProvider)
+ TimeProvider timeProvider,
+ AuditTrailService auditTrailService,
+ ILogger logger)
{
public async Task SynchronizeAsync(ClaimsPrincipal principal, CancellationToken cancellationToken)
{
@@ -109,9 +111,14 @@ public sealed class MembershipSynchronizationService(
invitation.AcceptedAtUtc = now;
}
+ MembershipStatus? promotedFrom = null;
if (IsBootstrapAdmin(gitHubLogin, bootstrapOptions.Value))
{
- PromoteToBootstrapAdmin(userAccount.Membership!, now);
+ promotedFrom = userAccount.Membership!.Status;
+ if (!ApplicationBootstrapper.TryPromoteBootstrapAdmin(userAccount, now, logger))
+ {
+ promotedFrom = null;
+ }
}
else if (userAccount.Membership!.Status == MembershipStatus.Active)
{
@@ -125,22 +132,16 @@ public sealed class MembershipSynchronizationService(
userAccount.MemberProfile!.UpdatedAtUtc = now;
+ if (promotedFrom is { } before && before != userAccount.Membership!.Status)
+ {
+ // One save: the promotion and its receipt stand or fall together.
+ await ApplicationBootstrapper.RecordPromotionAsync(auditTrailService, userAccount, before, cancellationToken);
+ return;
+ }
+
await dbContext.SaveChangesAsync(cancellationToken);
}
- private static void PromoteToBootstrapAdmin(Bifrost.Web.Domain.Membership membership, DateTimeOffset now)
- {
- membership.Status = MembershipStatus.Active;
- membership.IsPlatformAdmin = true;
- membership.CanManageProjects = true;
- membership.CanManageLedger = true;
- membership.CanModerateMotions = true;
- membership.ApprovedAtUtc ??= now;
-
- ApplicationBootstrapper.EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
- ApplicationBootstrapper.EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
- }
-
private static bool TryReadGitHubIdentity(
ClaimsPrincipal principal,
out long gitHubUserId,
diff --git a/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs b/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs
index 1a3cd0f..04879e2 100644
--- a/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs
+++ b/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs
@@ -10,6 +10,7 @@ public sealed class ApplicationBootstrapper(
BifrostDbContext dbContext,
IOptions bootstrapOptions,
TimeProvider timeProvider,
+ AuditTrailService auditTrailService,
ILogger logger)
{
public async Task RunAsync(CancellationToken cancellationToken)
@@ -40,16 +41,11 @@ public sealed class ApplicationBootstrapper(
foreach (var adminUser in adminUsers.Where(x => x.Membership is not null))
{
- var membership = adminUser.Membership!;
- membership.Status = MembershipStatus.Active;
- membership.IsPlatformAdmin = true;
- membership.CanManageProjects = true;
- membership.CanManageLedger = true;
- membership.CanModerateMotions = true;
- membership.ApprovedAtUtc ??= now;
-
- EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
- EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
+ var before = adminUser.Membership!.Status;
+ if (TryPromoteBootstrapAdmin(adminUser, now, logger) && before != adminUser.Membership.Status)
+ {
+ await RecordPromotionAsync(auditTrailService, adminUser, before, cancellationToken);
+ }
}
if (dbContext.ChangeTracker.HasChanges())
@@ -58,6 +54,41 @@ public sealed class ApplicationBootstrapper(
}
}
+ ///
+ /// The one bootstrap-admin promotion, used at startup and at sign-in. A Suspended account is left exactly as it is (an admin's
+ /// decision; only the suspension service lifts it) and the refusal is logged by account id. Returns whether it promoted.
+ ///
+ public static bool TryPromoteBootstrapAdmin(UserAccount account, DateTimeOffset now, ILogger logger)
+ {
+ var membership = account.Membership!;
+ if (membership.Status == MembershipStatus.Suspended)
+ {
+ logger.LogWarning("Bootstrap admin promotion skipped for suspended account {UserAccountId}.", account.Id);
+ return false;
+ }
+
+ membership.Status = MembershipStatus.Active;
+ membership.IsPlatformAdmin = true;
+ membership.CanManageProjects = true;
+ membership.CanManageLedger = true;
+ membership.CanModerateMotions = true;
+ membership.ApprovedAtUtc ??= now;
+
+ EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
+ EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
+ return true;
+ }
+
+ /// The receipt for a bootstrap promotion that changed membership status; saves together with the promotion.
+ public static Task RecordPromotionAsync(AuditTrailService auditTrailService, UserAccount account, MembershipStatus before, CancellationToken cancellationToken) =>
+ auditTrailService.RecordAsync(
+ null,
+ nameof(Bifrost.Web.Domain.Membership),
+ account.Membership!.Id,
+ "membership.bootstrap-promoted",
+ $"Bootstrap admin promotion of {account.DisplayName} ({before} -> {account.Membership.Status}).",
+ cancellationToken);
+
public static void EnsureRole(
Bifrost.Web.Domain.Membership membership,
MemberRole role,
diff --git a/tests/Bifrost.Web.Tests/BootstrapAdminTests.cs b/tests/Bifrost.Web.Tests/BootstrapAdminTests.cs
new file mode 100644
index 0000000..a980dd6
--- /dev/null
+++ b/tests/Bifrost.Web.Tests/BootstrapAdminTests.cs
@@ -0,0 +1,176 @@
+using System.Security.Claims;
+using Bifrost.Web.Data;
+using Bifrost.Web.Domain;
+using Bifrost.Web.Features.Membership;
+using Bifrost.Web.Features.Shared;
+using Bifrost.Web.Pages.App.Members;
+using Bifrost.Web.Tests.Support;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Bifrost.Web.Tests;
+
+/// Bootstrap promotion never lifts a suspension and leaves a receipt when it changes status; demoting an admin clears the admin flags.
+public sealed class BootstrapAdminTests
+{
+ private const long BootstrapGitHubId = 4242;
+
+ private static (Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory Factory, CapturingLoggerProvider Logs) NewFactory()
+ {
+ var logs = new CapturingLoggerProvider();
+ var factory = new TestWebApplicationFactory().WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs))));
+ return (factory, logs);
+ }
+
+ private static async Task SeedBootstrapLoginAsync(IServiceProvider services, MembershipStatus status)
+ {
+ using var scope = services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var account = new UserAccount
+ {
+ DisplayName = "Test Admin",
+ GitHubUserId = BootstrapGitHubId,
+ GitHubLogin = "test-admin",
+ NormalizedGitHubLogin = "TEST-ADMIN",
+ BifrostIdentity = "testadmin",
+ NormalizedBifrostIdentity = "testadmin",
+ MemberProfile = new MemberProfile { Nickname = "ta" },
+ Membership = new Bifrost.Web.Domain.Membership { Status = status, ApprovedAtUtc = status == MembershipStatus.Suspended ? DateTimeOffset.UtcNow : null }
+ };
+ db.UserAccounts.Add(account);
+ await db.SaveChangesAsync();
+ return account.Id;
+ }
+
+ private static async Task<(Bifrost.Web.Domain.Membership Membership, List Receipts)> ReadAsync(IServiceProvider services, Guid accountId)
+ {
+ using var scope = services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var membership = await db.Memberships.AsNoTracking().Include(x => x.RoleAssignments).SingleAsync(x => x.UserAccountId == accountId);
+ var receipts = await db.AuditEvents.AsNoTracking().Where(x => x.EntityId == membership.Id.ToString()).ToListAsync();
+ return (membership, receipts);
+ }
+
+ private static ClaimsPrincipal BootstrapPrincipal() => new(new ClaimsIdentity(
+ [
+ new Claim(ClaimTypes.NameIdentifier, BootstrapGitHubId.ToString()),
+ new Claim(ClaimTypes.Name, "test-admin")
+ ], "test"));
+
+ [Theory]
+ [InlineData("startup")]
+ [InlineData("signin")]
+ public async Task A_suspended_bootstrap_login_stays_suspended_and_unpromoted(string path)
+ {
+ var (factory, logs) = NewFactory();
+ using var _ = factory;
+ var id = await SeedBootstrapLoginAsync(factory.Services, MembershipStatus.Suspended);
+ var before = (await ReadAsync(factory.Services, id)).Receipts.Count;
+
+ await RunPathAsync(factory.Services, path);
+
+ var (membership, receipts) = await ReadAsync(factory.Services, id);
+ Assert.Equal(MembershipStatus.Suspended, membership.Status);
+ Assert.False(membership.IsPlatformAdmin);
+ Assert.Empty(membership.RoleAssignments);
+ Assert.Equal(before, receipts.Count);
+ Assert.Contains(logs.Lines, x => x.Contains("suspended account") && x.Contains(id.ToString()));
+ }
+
+ [Theory]
+ [InlineData("startup")]
+ [InlineData("signin")]
+ public async Task A_bootstrap_promotion_that_changes_status_writes_a_receipt(string path)
+ {
+ var (factory, _) = NewFactory();
+ using var _f = factory;
+ var id = await SeedBootstrapLoginAsync(factory.Services, MembershipStatus.Authenticated);
+
+ await RunPathAsync(factory.Services, path);
+
+ var (membership, receipts) = await ReadAsync(factory.Services, id);
+ Assert.Equal(MembershipStatus.Active, membership.Status);
+ Assert.True(membership.IsPlatformAdmin);
+ var receipt = Assert.Single(receipts, x => x.Action == "membership.bootstrap-promoted");
+ Assert.Contains("Authenticated -> Active", receipt.Detail);
+
+ await RunPathAsync(factory.Services, path); // already Active: no second receipt
+ Assert.Single((await ReadAsync(factory.Services, id)).Receipts, x => x.Action == "membership.bootstrap-promoted");
+ }
+
+ private static async Task RunPathAsync(IServiceProvider services, string path)
+ {
+ using var scope = services.CreateScope();
+ if (path == "startup")
+ {
+ await scope.ServiceProvider.GetRequiredService().RunAsync(CancellationToken.None);
+ }
+ else
+ {
+ await scope.ServiceProvider.GetRequiredService().SynchronizeAsync(BootstrapPrincipal(), CancellationToken.None);
+ }
+ }
+
+ private sealed class FixedActor(CurrentBifrostActor actor) : ICurrentBifrostActorAccessor
+ {
+ public Task GetAsync(CancellationToken cancellationToken) => Task.FromResult(actor);
+ }
+
+ private static async Task SeedAsync(IServiceProvider services, string name, bool admin)
+ {
+ using var scope = services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var membership = new Bifrost.Web.Domain.Membership { Status = MembershipStatus.Active, ApprovedAtUtc = DateTimeOffset.UtcNow };
+ if (admin)
+ {
+ membership.IsPlatformAdmin = membership.CanManageProjects = membership.CanManageLedger = membership.CanModerateMotions = true;
+ membership.RoleAssignments.Add(new RoleAssignment { Role = MemberRole.PlatformAdmin });
+ }
+
+ var account = new UserAccount
+ {
+ DisplayName = name,
+ BifrostIdentity = $"{name}-{Guid.NewGuid():N}"[..20],
+ NormalizedBifrostIdentity = $"{name}-{Guid.NewGuid():N}"[..20],
+ Membership = membership
+ };
+ db.UserAccounts.Add(account);
+ await db.SaveChangesAsync();
+ return account.Id;
+ }
+
+ private static async Task ToggleAsync(IServiceProvider services, Guid by, Guid target, MemberRole role)
+ {
+ using var scope = services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var actor = new CurrentBifrostActor(true, await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments).SingleAsync(x => x.Id == by));
+ var page = new IndexModel(db, new FixedActor(actor), scope.ServiceProvider.GetRequiredService(),
+ scope.ServiceProvider.GetRequiredService(), TimeProvider.System);
+ Assert.IsType(await page.OnPostToggleRoleAsync(target, role, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Demoting_an_admin_through_the_page_clears_the_admin_flags_and_promoting_sets_them()
+ {
+ var (factory, _) = NewFactory();
+ using var _f = factory;
+ var actingAdmin = await SeedAsync(factory.Services, "acting", admin: true);
+ var otherAdmin = await SeedAsync(factory.Services, "other", admin: true);
+ var member = await SeedAsync(factory.Services, "member", admin: false);
+
+ await ToggleAsync(factory.Services, actingAdmin, otherAdmin, MemberRole.PlatformAdmin);
+ var (demoted, _) = await ReadAsync(factory.Services, otherAdmin);
+ Assert.False(demoted.IsPlatformAdmin);
+ Assert.False(demoted.CanManageProjects);
+ Assert.False(demoted.CanManageLedger);
+ Assert.False(demoted.CanModerateMotions);
+ Assert.DoesNotContain(demoted.RoleAssignments, x => x.Role == MemberRole.PlatformAdmin);
+
+ await ToggleAsync(factory.Services, actingAdmin, member, MemberRole.PlatformAdmin);
+ var (promoted, _) = await ReadAsync(factory.Services, member);
+ Assert.True(promoted.IsPlatformAdmin);
+ Assert.Contains(promoted.RoleAssignments, x => x.Role == MemberRole.PlatformAdmin);
+ }
+}
--
2.47.3
From c1c057b43c5d2cc1bcf5dff56769b65f0923233b Mon Sep 17 00:00:00 2001
From: Heimdall
Date: Wed, 11 Nov 2026 12:17:36 +0000
Subject: [PATCH 3/7] Members page: a refused suspend or unsuspend shows the
admin why (fixed message via TempData, role=alert)
Co-Authored-By: Claude Sonnet 5.5
---
.../Pages/App/Members/Index.cshtml | 5 +
.../Pages/App/Members/Index.cshtml.cs | 20 ++-
.../MembersPageNoticeTests.cs | 130 ++++++++++++++++++
3 files changed, 154 insertions(+), 1 deletion(-)
create mode 100644 tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs
diff --git a/src/Bifrost.Web/Pages/App/Members/Index.cshtml b/src/Bifrost.Web/Pages/App/Members/Index.cshtml
index b4d67e5..91f04c4 100644
--- a/src/Bifrost.Web/Pages/App/Members/Index.cshtml
+++ b/src/Bifrost.Web/Pages/App/Members/Index.cshtml
@@ -14,6 +14,11 @@
because trust and contributor accountability are product concerns, not just auth concerns.
+ @if (!string.IsNullOrEmpty(Model.Notice))
+ {
+ @Model.Notice
+ }
+
@if (Model.Actor.CanManageMembership)
{