Bifrost/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs
2026-11-11 12:16:18 +00:00

170 lines
6.2 KiB
C#

using System.Security.Claims;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Membership;
public sealed class MembershipSynchronizationService(
BifrostDbContext dbContext,
BifrostIdentityService bifrostIdentityService,
IOptions<BootstrapOptions> bootstrapOptions,
TimeProvider timeProvider,
AuditTrailService auditTrailService,
ILogger<MembershipSynchronizationService> logger)
{
public async Task SynchronizeAsync(ClaimsPrincipal principal, CancellationToken cancellationToken)
{
if (!TryReadGitHubIdentity(principal, out var gitHubUserId, out var gitHubLogin))
{
return;
}
var displayName = principal.FindFirstValue(BifrostClaimTypes.DisplayName);
var avatarUrl = principal.FindFirstValue(BifrostClaimTypes.AvatarUrl);
var normalizedLogin = Normalize(gitHubLogin);
var now = timeProvider.GetUtcNow();
var userAccount = await dbContext.UserAccounts
.Include(x => x.MemberProfile)
.Include(x => x.Membership!)
.ThenInclude(x => x.RoleAssignments)
.Include(x => x.Membership!)
.ThenInclude(x => x.TierSnapshots)
.SingleOrDefaultAsync(x => x.GitHubUserId == gitHubUserId, cancellationToken);
var invitation = await dbContext.MembershipInvitations
.SingleOrDefaultAsync(
x => x.NormalizedGitHubLogin == normalizedLogin && x.RevokedAtUtc == null,
cancellationToken);
if (userAccount is null)
{
userAccount = new UserAccount
{
GitHubUserId = gitHubUserId,
GitHubLogin = gitHubLogin,
NormalizedGitHubLogin = normalizedLogin,
DisplayName = displayName ?? gitHubLogin,
AvatarUrl = avatarUrl ?? string.Empty,
CreatedAtUtc = now,
LastSeenAtUtc = now,
MemberProfile = new MemberProfile
{
Nickname = displayName ?? gitHubLogin,
Headline = "New member record",
UpdatedAtUtc = now
},
Membership = new Bifrost.Web.Domain.Membership
{
Status = invitation is null ? MembershipStatus.Authenticated : MembershipStatus.PendingApproval,
CreatedAtUtc = now
}
};
dbContext.UserAccounts.Add(userAccount);
}
else
{
userAccount.GitHubLogin = gitHubLogin;
userAccount.NormalizedGitHubLogin = normalizedLogin;
userAccount.DisplayName = string.IsNullOrWhiteSpace(displayName) ? gitHubLogin : displayName;
userAccount.AvatarUrl = avatarUrl ?? userAccount.AvatarUrl;
userAccount.LastSeenAtUtc = now;
userAccount.MemberProfile ??= new MemberProfile
{
UserAccountId = userAccount.Id,
Nickname = userAccount.DisplayName,
Headline = "Imported from GitHub sign-in",
UpdatedAtUtc = now
};
userAccount.Membership ??= new Bifrost.Web.Domain.Membership
{
UserAccountId = userAccount.Id,
CreatedAtUtc = now
};
if (invitation is not null && userAccount.Membership.Status == MembershipStatus.Authenticated)
{
userAccount.Membership.Status = MembershipStatus.PendingApproval;
}
}
if (string.IsNullOrWhiteSpace(userAccount.MemberProfile!.Nickname))
{
userAccount.MemberProfile.Nickname = userAccount.DisplayName;
}
await bifrostIdentityService.EnsureIdentityAsync(
userAccount,
userAccount.GitHubLogin,
cancellationToken);
if (invitation is not null && invitation.AcceptedByUserAccountId is null)
{
invitation.AcceptedByUserAccountId = userAccount.Id;
invitation.AcceptedAtUtc = now;
}
MembershipStatus? promotedFrom = null;
if (IsBootstrapAdmin(gitHubLogin, bootstrapOptions.Value))
{
promotedFrom = userAccount.Membership!.Status;
if (!ApplicationBootstrapper.TryPromoteBootstrapAdmin(userAccount, now, logger))
{
promotedFrom = null;
}
}
else if (userAccount.Membership!.Status == MembershipStatus.Active)
{
ApplicationBootstrapper.EnsureRole(
userAccount.Membership,
MemberRole.StandardMember,
null,
now,
"Default active member role");
}
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 bool TryReadGitHubIdentity(
ClaimsPrincipal principal,
out long gitHubUserId,
out string gitHubLogin)
{
gitHubLogin = principal.FindFirstValue(BifrostClaimTypes.GitHubLogin)
?? principal.FindFirstValue(ClaimTypes.Name)
?? string.Empty;
var idClaim = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (!long.TryParse(idClaim, out gitHubUserId) || string.IsNullOrWhiteSpace(gitHubLogin))
{
gitHubUserId = 0;
return false;
}
return true;
}
private static string Normalize(string gitHubLogin) =>
gitHubLogin.Trim().ToUpperInvariant();
private static bool IsBootstrapAdmin(string gitHubLogin, BootstrapOptions options) =>
options.AdminGitHubLogins.Any(login =>
string.Equals(login, gitHubLogin, StringComparison.OrdinalIgnoreCase));
}