diff --git a/docs/oidc-issuer.md b/docs/oidc-issuer.md
index d6cb22d..b3c4121 100644
--- a/docs/oidc-issuer.md
+++ b/docs/oidc-issuer.md
@@ -103,6 +103,10 @@ to 15 minutes; a 400 (token refused) or 404 (unknown provider ULID) is a configu
and retried at the 15 minute cap. Nothing is revoked until MAS answers 2xx. A restart loses the backoff and nothing
else: the authorizations table is the queue, so a later-set URI also catches up on earlier demotions.
+Without `BackchannelLogoutUri` none of this runs: suspension stops new sign-ins and token refresh (the authorize, token and userinfo
+endpoints refuse a suspended account) but does not end a chat session that is already live, and the issuer logs one warning at
+startup saying so. The other lever is MAS admin deactivation of the user (ops, not built here).
+
Tradeoff: delivery latency is up to one tick, and a member demoted and re-promoted inside one tick keeps their sessions.
MAS verified at v1.28.0 (`crates/handlers/src/upstream_oauth2/backchannel_logout.rs`): the token is checked like an
diff --git a/docs/registration-gates.md b/docs/registration-gates.md
index fd039be..5564b17 100644
--- a/docs/registration-gates.md
+++ b/docs/registration-gates.md
@@ -11,7 +11,7 @@ This page scopes the three that did not exist, says where each lives, and record
| Rate limits and brake | `OidcGuardMiddleware` (`docs/oidc-issuer.md`) | Merged. Covers `/connect/*` only, not the register endpoint. |
| Reserved names | `BifrostIdentityService.IsReserved`, 28 names | Merged, at creation and at authorize. |
| Chat tier, no vote | `Authenticated` / `PendingApproval` accounts chat and open no member or governance surface | Merged. |
-| Suspension | `MembershipSuspensionService` | Merged. |
+| Suspension | `MembershipSuspensionService` | Merged. The reason is `Membership.SuspensionReason`, written with the status change and its receipt, cleared on unsuspend, projected only to admins on the members page; the receipt (`AuditEvent.Detail`) holds no reason text. Receipts written before the column existed still hold their reasons as history; the member dashboard keeps filtering suspension receipts out. An unsuspend still requires a reason, but nothing stores it. |
| CAPTCHA | `Features/Registration/RegistrationChallengeService` | Built here. |
| Consent receipt | `ConsentReceipt`, `TrainingNotice` | Built here. |
| Email verification | nowhere | Scoped below, not built. |
diff --git a/src/Bifrost.Web/Data/Migrations/20261111110000_AddMembershipSuspensionReason.cs b/src/Bifrost.Web/Data/Migrations/20261111110000_AddMembershipSuspensionReason.cs
new file mode 100644
index 0000000..4dc7719
--- /dev/null
+++ b/src/Bifrost.Web/Data/Migrations/20261111110000_AddMembershipSuspensionReason.cs
@@ -0,0 +1,25 @@
+using Bifrost.Web.Data;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Bifrost.Web.Data.Migrations
+{
+ // Hand-written like ConsentReceiptsOneLivePerVersion: no dotnet-ef on the authoring host; the snapshot was edited to match.
+ // Reasons already written to AuditEvent.Detail stay there as history; new ones live only in this admin-read column.
+ [DbContext(typeof(BifrostDbContext))]
+ [Migration("20261111110000_AddMembershipSuspensionReason")]
+ public partial class AddMembershipSuspensionReason : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(name: "SuspensionReason", table: "Memberships", type: "text", nullable: true);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(name: "SuspensionReason", table: "Memberships");
+ }
+ }
+}
diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs
index 851318c..042e2f0 100644
--- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs
+++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs
@@ -821,6 +821,9 @@ namespace Bifrost.Web.Data.Migrations
.IsRequired()
.HasColumnType("text");
+ b.Property("SuspensionReason")
+ .HasColumnType("text");
+
b.Property("UserAccountId")
.HasColumnType("uuid");
diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs
index 2e5d680..00e3f9c 100644
--- a/src/Bifrost.Web/Domain/BifrostModels.cs
+++ b/src/Bifrost.Web/Domain/BifrostModels.cs
@@ -334,6 +334,9 @@ public sealed class Membership
public MembershipStatus Status { get; set; } = MembershipStatus.Authenticated;
+ /// Why an admin suspended the account; set and cleared only by MembershipSuspensionService and projected only to admins.
+ public string? SuspensionReason { get; set; }
+
public bool IsPlatformAdmin { get; set; }
public bool CanManageProjects { get; set; }
diff --git a/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs b/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs
index cb5626c..870ca4f 100644
--- a/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs
+++ b/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs
@@ -17,7 +17,7 @@ public enum SuspensionOutcome
///
/// The one place a membership becomes Suspended or stops being. Only a platform admin may call it, every use leaves an audit
-/// receipt naming the admin, the account and the reason, and no sign-in path writes or lifts a suspension (Heimdall and GitHub
+/// receipt naming the admin and the account (the reason is kept on the membership row, readable by admins only), and no sign-in path writes or lifts a suspension (Heimdall and GitHub
/// sign-in leave a Suspended membership as it is). Unsuspending restores Active for an account that was once approved,
/// Authenticated for one that never was; a pending request is not remembered.
///
@@ -65,14 +65,16 @@ public sealed class MembershipSuspensionService(BifrostDbContext dbContext, Audi
membership.Status = suspend
? MembershipStatus.Suspended
: membership.ApprovedAtUtc is null ? MembershipStatus.Authenticated : MembershipStatus.Active;
+ // The reason is a column on the membership, for admins; the receipt names who and what changed, never why.
var clipped = reason.Trim().Length > MaxReasonLength ? reason.Trim()[..MaxReasonLength] : reason.Trim();
- // One save: the status change and its receipt stand or fall together (RecordAsync saves the shared context).
+ membership.SuspensionReason = suspend ? clipped : null;
+ // One save: the status change, the reason and the receipt stand or fall together (RecordAsync saves the shared context).
await auditTrailService.RecordAsync(
actor.UserAccount.Id,
nameof(Membership),
membership.Id,
suspend ? "membership.suspended" : "membership.unsuspended",
- $"{(suspend ? "Suspended" : "Unsuspended")} {user.DisplayName} ({before} -> {membership.Status}): {clipped}",
+ $"{(suspend ? "Suspended" : "Unsuspended")} {user.DisplayName} ({before} -> {membership.Status}).",
cancellationToken);
return SuspensionOutcome.Done;
}
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/Oidc/OidcClientSeeder.cs b/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs
index edf1dd3..c393afd 100644
--- a/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs
+++ b/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs
@@ -14,10 +14,17 @@ public sealed class OidcClientSeeder(
IServiceScopeFactory scopeFactory,
OidcIssuerOptions options,
OidcKeyMaterial material,
- IOptions serverOptions) : IHostedService
+ IOptions serverOptions,
+ ILogger logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
+ if (string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri))
+ {
+ logger.LogWarning(
+ "Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri is not set: suspending an account stops new sign-ins and token refresh but does not end its existing chat sessions.");
+ }
+
// Reading the server options now runs their configuration, so a bad issuer stops startup rather than the first request.
_ = serverOptions.Value;
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/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs
index cd0d8f2..0837f7e 100644
--- a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs
+++ b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs
@@ -92,7 +92,7 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
var recentActivity = await dbContext.AuditEvents
.AsNoTracking()
.Include(x => x.ActorUserAccount)
- .Where(x => !x.Action.StartsWith("membership.suspended") && !x.Action.StartsWith("membership.unsuspended")) // a reason is for admins, not the member dashboard
+ .Where(x => !x.Action.StartsWith("membership.suspended") && !x.Action.StartsWith("membership.unsuspended") && x.Action != "membership.bootstrap-promoted") // suspension reasons and the bootstrap-admin promotion are for admins, not the member dashboard
.OrderByDescending(x => x.OccurredAtUtc)
.Take(8)
.Select(x => new DashboardActivity(
diff --git a/src/Bifrost.Web/Pages/App/Members/Index.cshtml b/src/Bifrost.Web/Pages/App/Members/Index.cshtml
index b4d67e5..94e791d 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)
{