feat(account): add deletion lifecycle
CI and Deploy / test (pull_request) Successful in 5m18s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 19:03:54 +02:00
parent 1ec9dd037e
commit 842e793f69
28 changed files with 4418 additions and 129 deletions
@@ -0,0 +1,108 @@
using System.Security.Claims;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/account-lifecycle")]
[Authorize(AuthenticationSchemes = "local")]
public sealed class AccountLifecycleController(
JobTrackerContext db,
AccountDeletionService deletions,
TimeProvider timeProvider) : ControllerBase
{
public sealed record DeleteAccountRequest(string Confirmation);
[HttpGet("status")]
public async Task<IActionResult> Status(CancellationToken cancellationToken)
{
var ownerUserId = LocalAuthIdentity.GetRequiredUserId(User);
if (ownerUserId is null) return Unauthorized();
var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken);
if (user is null) return NotFound();
var request = await db.AccountDeletionRequests.AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderByDescending(item => item.Id).FirstOrDefaultAsync(cancellationToken);
return Ok(new
{
deletionEnabled = deletions.CanAcceptRequests,
deletionStatus = user.DeletionStatus,
requiredConfirmation = $"DELETE {user.Email}",
request = request is null ? null : ToDto(request),
});
}
[HttpPost("delete")]
[EnableRateLimiting("account-data")]
public async Task<IActionResult> Delete([FromBody] DeleteAccountRequest request, CancellationToken cancellationToken)
{
if (!deletions.CanAcceptRequests)
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Account deletion unavailable", detail: "Account deletion remains disabled until retention and restore safeguards are approved.");
var ownerUserId = LocalAuthIdentity.GetRequiredUserId(User);
if (ownerUserId is null) return Unauthorized();
var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken);
if (user is null) return NotFound();
if (!string.Equals(request.Confirmation?.Trim(), $"DELETE {user.Email}", StringComparison.Ordinal))
return BadRequest("Type the exact confirmation shown before deleting the account.");
if (!await IsRecentlyAuthenticated(ownerUserId, cancellationToken))
return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetails { Title = "Recent sign-in required", Detail = "Sign in again before deleting the account." });
if (await IsFinalAdministrator(ownerUserId, cancellationToken))
return Conflict(new ProblemDetails { Title = "Last administrator protected", Detail = "Assign the Admin role to another user before deleting the final administrator." });
var result = await deletions.RequestAsync(ownerUserId, ownerUserId, cancellationToken);
if (result is null) return StatusCode(StatusCodes.Status503ServiceUnavailable);
ExpireCookies();
return Accepted(new { requestId = result.RequestId, status = result.Status, stage = result.Stage });
}
[HttpGet("admin/requests/{id:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> AdminStatus(Guid id, CancellationToken cancellationToken)
{
var request = await db.AccountDeletionRequests.AsNoTracking().FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
return request is null ? NotFound() : Ok(ToDto(request));
}
private async Task<bool> IsRecentlyAuthenticated(string ownerUserId, CancellationToken cancellationToken)
{
var sid = User.FindFirstValue("sid");
if (string.IsNullOrWhiteSpace(sid)) return false;
var session = await db.UserSessions.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(item => item.Id == sid && item.UserId == ownerUserId, cancellationToken);
return session is { RevokedAtUtc: null } && session.CreatedAtUtc >= timeProvider.GetUtcNow().AddMinutes(-15);
}
private async Task<bool> IsFinalAdministrator(string ownerUserId, CancellationToken cancellationToken)
{
var adminRoleId = await db.Roles.Where(item => item.NormalizedName == "ADMIN").Select(item => item.Id).FirstOrDefaultAsync(cancellationToken);
if (adminRoleId is null) return false;
var isAdmin = await db.UserRoles.AnyAsync(item => item.UserId == ownerUserId && item.RoleId == adminRoleId, cancellationToken);
return isAdmin && await db.UserRoles.CountAsync(item => item.RoleId == adminRoleId, cancellationToken) <= 1;
}
private void ExpireCookies()
{
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(Request.IsHttps));
Response.Cookies.Delete(AuthSessionOptions.CsrfCookieName, AuthSessionOptions.BuildExpiredReadableCookie(Request.IsHttps));
TrustedDeviceService.ClearCookie(Response, Request.IsHttps);
}
private static object ToDto(AccountDeletionRequest request) => new
{
request.Id,
request.Status,
request.Stage,
request.AttemptCount,
request.DatabaseRowCount,
request.FileCount,
request.WarningJson,
request.LastErrorCategory,
request.LastErrorMessage,
request.RequestedAtUtc,
request.StartedAtUtc,
request.CompletedAtUtc,
};
}
@@ -1087,6 +1087,8 @@ public sealed class AuthController : ControllerBase
// decorative: skipping straight to AppSessionIssuer here would defeat the whole feature.
private async Task<IActionResult> CompleteSignInAsync(ApplicationUser user, bool rememberMe, string provider, CancellationToken cancellationToken)
{
if (user.DeletionStatus != AccountDeletionStatuses.Active)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "account_deletion_pending" });
// "Trust this device" cookie check happens BEFORE the 2FA gate: if it matches a
// non-expired row for this exact user, skip straight to a real session, same as if 2FA
// weren't required at all. Falls through to the normal gate for any other outcome
@@ -196,7 +196,7 @@ public sealed class TwoFactorController : ControllerBase
if (session is null) return Unauthorized();
var user = await _users.FindByIdAsync(session.UserId);
if (user is null || !user.TwoFactorEnabled || string.IsNullOrWhiteSpace(user.TotpSecretEncrypted))
if (user is null || user.DeletionStatus != AccountDeletionStatuses.Active || !user.TwoFactorEnabled || string.IsNullOrWhiteSpace(user.TotpSecretEncrypted))
{
return Unauthorized();
}
+13 -6
View File
@@ -19,13 +19,15 @@ public sealed class UsersController : ControllerBase
private readonly IAppEmailSender _email;
private readonly ExternalOrigin _externalOrigin;
private readonly ILogger<UsersController> _logger;
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg, ILogger<UsersController> logger, ExternalOrigin? externalOrigin = null)
private readonly AccountDeletionService? _deletions;
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg, ILogger<UsersController> logger, ExternalOrigin? externalOrigin = null, AccountDeletionService? deletions = null)
{
_users = users;
_roles = roles;
_email = email;
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
_logger = logger;
_deletions = deletions;
}
public sealed record UserDto(
@@ -163,11 +165,16 @@ public sealed class UsersController : ControllerBase
});
}
var res = await _users.DeleteAsync(u);
if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
return NoContent();
if (_deletions is null || !_deletions.CanAcceptRequests)
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Account deletion unavailable", detail: "Account deletion remains disabled until retention and restore safeguards are approved.");
if (!string.Equals(Request.Headers["X-Confirm-Account-Deletion"].ToString(), u.Email, StringComparison.OrdinalIgnoreCase))
return BadRequest("Confirm the exact account email before deletion.");
var requestedBy = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
if (string.IsNullOrWhiteSpace(requestedBy)) return Unauthorized();
var request = await _deletions.RequestAsync(u.Id, requestedBy, cancellationToken);
return request is null
? Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Account deletion unavailable")
: Accepted(new { requestId = request.RequestId, status = request.Status, stage = request.Stage });
}
[HttpPost("{id}/send-password-reset")]
+28
View File
@@ -63,6 +63,8 @@ namespace JobTrackerApi.Data
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
public DbSet<EmailSendAttempt> EmailSendAttempts => Set<EmailSendAttempt>();
public DbSet<EmailDraft> EmailDrafts => Set<EmailDraft>();
public DbSet<AccountDeletionRequest> AccountDeletionRequests => Set<AccountDeletionRequest>();
public DbSet<AccountDeletionFile> AccountDeletionFiles => Set<AccountDeletionFile>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -80,6 +82,32 @@ namespace JobTrackerApi.Data
.Property(x => x.MicrosoftObjectId)
.HasMaxLength(36);
modelBuilder.Entity<ApplicationUser>()
.Property(x => x.DeletionStatus)
.HasMaxLength(32)
.HasDefaultValue(AccountDeletionStatuses.Active);
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerUserId).HasMaxLength(255);
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerKey).HasMaxLength(64);
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.RequestedByUserId).HasMaxLength(255);
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.Status).HasMaxLength(32);
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.Stage).HasMaxLength(32);
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.LastErrorCategory).HasMaxLength(64);
modelBuilder.Entity<AccountDeletionRequest>()
.HasIndex(x => new { x.OwnerUserId, x.Status });
modelBuilder.Entity<AccountDeletionRequest>()
.HasIndex(x => new { x.Status, x.RequestedAtUtc });
modelBuilder.Entity<AccountDeletionFile>().Property(x => x.Category).HasMaxLength(64);
modelBuilder.Entity<AccountDeletionFile>().Property(x => x.Status).HasMaxLength(32);
modelBuilder.Entity<AccountDeletionFile>().Property(x => x.Sha256).HasMaxLength(64);
modelBuilder.Entity<AccountDeletionFile>()
.HasIndex(x => new { x.AccountDeletionRequestId, x.Status });
modelBuilder.Entity<AccountDeletionFile>()
.HasOne(x => x.Request)
.WithMany(x => x.Files)
.HasForeignKey(x => x.AccountDeletionRequestId)
.OnDelete(DeleteBehavior.Cascade);
// Both supported databases allow multiple NULL values in a unique index, so legacy
// rows remain unassigned while each proven tenant/object pair has exactly one owner.
modelBuilder.Entity<ApplicationUser>()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,156 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
public partial class AddAccountDeletionLifecycle : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
if (ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase))
{
migrationBuilder.Sql("""
ALTER TABLE `AspNetUsers`
ADD COLUMN `DeletionRequestedAtUtc` datetime(6) NULL,
ADD COLUMN `DeletionStatus` varchar(32) NOT NULL DEFAULT 'active';
CREATE TABLE `AccountDeletionRequests` (
`Id` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`OwnerKey` char(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`RequestedByUserId` varchar(255) NOT NULL,
`Status` varchar(32) NOT NULL,
`Stage` varchar(32) NOT NULL,
`AttemptCount` int NOT NULL,
`DatabaseRowCount` int NOT NULL,
`FileCount` int NOT NULL,
`WarningJson` longtext NULL,
`LastErrorCategory` varchar(64) NULL,
`LastErrorMessage` varchar(1024) NULL,
`RequestedAtUtc` datetime(6) NOT NULL,
`StartedAtUtc` datetime(6) NULL,
`CompletedAtUtc` datetime(6) NULL,
CONSTRAINT `PK_AccountDeletionRequests` PRIMARY KEY (`Id`)
) CHARACTER SET=utf8mb4;
CREATE TABLE `AccountDeletionFiles` (
`Id` bigint NOT NULL AUTO_INCREMENT,
`AccountDeletionRequestId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`Category` varchar(64) NOT NULL,
`OriginalPath` longtext NOT NULL,
`QuarantinePath` longtext NOT NULL,
`Status` varchar(32) NOT NULL,
`ByteSize` bigint NOT NULL,
`Sha256` char(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
CONSTRAINT `PK_AccountDeletionFiles` PRIMARY KEY (`Id`),
CONSTRAINT `FK_AccountDeletionFiles_AccountDeletionRequests_AccountDeletionRequestId`
FOREIGN KEY (`AccountDeletionRequestId`) REFERENCES `AccountDeletionRequests` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
""");
}
else
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "DeletionRequestedAtUtc",
table: "AspNetUsers",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DeletionStatus",
table: "AspNetUsers",
type: "TEXT",
maxLength: 32,
nullable: false,
defaultValue: "active");
migrationBuilder.CreateTable(
name: "AccountDeletionRequests",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
OwnerUserId = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
OwnerKey = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
RequestedByUserId = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Stage = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
AttemptCount = table.Column<int>(type: "INTEGER", nullable: false),
DatabaseRowCount = table.Column<int>(type: "INTEGER", nullable: false),
FileCount = table.Column<int>(type: "INTEGER", nullable: false),
WarningJson = table.Column<string>(type: "TEXT", nullable: true),
LastErrorCategory = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
LastErrorMessage = table.Column<string>(type: "TEXT", nullable: true),
RequestedAtUtc = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
StartedAtUtc = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
CompletedAtUtc = table.Column<DateTimeOffset>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AccountDeletionRequests", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AccountDeletionFiles",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
AccountDeletionRequestId = table.Column<Guid>(type: "TEXT", nullable: false),
Category = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
OriginalPath = table.Column<string>(type: "TEXT", nullable: false),
QuarantinePath = table.Column<string>(type: "TEXT", nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
ByteSize = table.Column<long>(type: "INTEGER", nullable: false),
Sha256 = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AccountDeletionFiles", x => x.Id);
table.ForeignKey(
name: "FK_AccountDeletionFiles_AccountDeletionRequests_AccountDeletionRequestId",
column: x => x.AccountDeletionRequestId,
principalTable: "AccountDeletionRequests",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
migrationBuilder.CreateIndex(
name: "IX_AccountDeletionFiles_AccountDeletionRequestId_Status",
table: "AccountDeletionFiles",
columns: new[] { "AccountDeletionRequestId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_AccountDeletionRequests_OwnerUserId_Status",
table: "AccountDeletionRequests",
columns: new[] { "OwnerUserId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_AccountDeletionRequests_Status_RequestedAtUtc",
table: "AccountDeletionRequests",
columns: new[] { "Status", "RequestedAtUtc" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AccountDeletionFiles");
migrationBuilder.DropTable(
name: "AccountDeletionRequests");
migrationBuilder.DropColumn(
name: "DeletionRequestedAtUtc",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "DeletionStatus",
table: "AspNetUsers");
}
}
}
@@ -17,6 +17,116 @@ namespace JobTrackerApi.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.14");
modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionFile", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<Guid>("AccountDeletionRequestId")
.HasColumnType("TEXT");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("QuarantinePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountDeletionRequestId", "Status");
b.ToTable("AccountDeletionFiles");
});
modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("AttemptCount")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("CompletedAtUtc")
.HasColumnType("TEXT");
b.Property<int>("DatabaseRowCount")
.HasColumnType("INTEGER");
b.Property<int>("FileCount")
.HasColumnType("INTEGER");
b.Property<string>("LastErrorCategory")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("LastErrorMessage")
.HasColumnType("TEXT");
b.Property<string>("OwnerKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("RequestedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("RequestedByUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Stage")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("StartedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("WarningJson")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerUserId", "Status");
b.HasIndex("Status", "RequestedAtUtc");
b.ToTable("AccountDeletionRequests");
});
modelBuilder.Entity("JobTrackerApi.Models.AiInteraction", b =>
{
b.Property<int>("Id")
@@ -209,6 +319,16 @@ namespace JobTrackerApi.Migrations
b.Property<int?>("CurrentCvUploadArtifactId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("DeletionRequestedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("DeletionStatus")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(32)
.HasColumnType("TEXT")
.HasDefaultValue("active");
b.Property<string>("DisplayName")
.HasColumnType("TEXT");
@@ -2332,6 +2452,17 @@ namespace JobTrackerApi.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionFile", b =>
{
b.HasOne("JobTrackerApi.Models.AccountDeletionRequest", "Request")
.WithMany("Files")
.HasForeignKey("AccountDeletionRequestId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Request");
});
modelBuilder.Entity("JobTrackerApi.Models.AiInteraction", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
@@ -2662,6 +2793,11 @@ namespace JobTrackerApi.Migrations
.IsRequired();
});
modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionRequest", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerProfile", b =>
{
b.Navigation("Certifications");
+59
View File
@@ -0,0 +1,59 @@
namespace JobTrackerApi.Models;
public static class AccountDeletionStatuses
{
public const string Active = "active";
public const string Pending = "pending";
public const string Completed = "completed";
}
public static class AccountDeletionRequestStatuses
{
public const string Pending = "pending";
public const string Processing = "processing";
public const string RetryRequired = "retry_required";
public const string Completed = "completed";
}
public static class AccountDeletionStages
{
public const string Requested = "requested";
public const string QuarantiningFiles = "quarantining_files";
public const string DeletingDatabase = "deleting_database";
public const string PurgingFiles = "purging_files";
public const string RecordingTombstone = "recording_tombstone";
public const string Completed = "completed";
}
public sealed class AccountDeletionRequest
{
public Guid Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public string OwnerKey { get; set; } = string.Empty;
public string RequestedByUserId { get; set; } = string.Empty;
public string Status { get; set; } = AccountDeletionRequestStatuses.Pending;
public string Stage { get; set; } = AccountDeletionStages.Requested;
public int AttemptCount { get; set; }
public int DatabaseRowCount { get; set; }
public int FileCount { get; set; }
public string? WarningJson { get; set; }
public string? LastErrorCategory { get; set; }
public string? LastErrorMessage { get; set; }
public DateTimeOffset RequestedAtUtc { get; set; }
public DateTimeOffset? StartedAtUtc { get; set; }
public DateTimeOffset? CompletedAtUtc { get; set; }
public List<AccountDeletionFile> Files { get; set; } = new();
}
public sealed class AccountDeletionFile
{
public long Id { get; set; }
public Guid AccountDeletionRequestId { get; set; }
public AccountDeletionRequest Request { get; set; } = null!;
public string Category { get; set; } = string.Empty;
public string OriginalPath { get; set; } = string.Empty;
public string QuarantinePath { get; set; } = string.Empty;
public string Status { get; set; } = "planned";
public long ByteSize { get; set; }
public string Sha256 { get; set; } = string.Empty;
}
+2
View File
@@ -32,4 +32,6 @@ public sealed class ApplicationUser : IdentityUser
public DateTime? StripeLastEventCreatedUtc { get; set; }
public bool AiEnabled { get; set; } = true;
public bool ExternalAiProcessingAllowed { get; set; }
public string DeletionStatus { get; set; } = AccountDeletionStatuses.Active;
public DateTimeOffset? DeletionRequestedAtUtc { get; set; }
}
+10
View File
@@ -46,7 +46,10 @@ builder.Services.AddSingleton<BackgroundTenantRunner>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<UserOperationStore>();
builder.Services.AddScoped<EmailSendAttemptStore>();
builder.Services.AddScoped<AccountOwnedFileInventory>();
builder.Services.AddScoped<AccountDataExportService>();
builder.Services.AddSingleton<AccountDeletionTombstoneStore>();
builder.Services.AddScoped<AccountDeletionService>();
builder.Services.AddScoped<AiOperationAdmission>();
builder.Services.AddScoped<StrategySnapshotService>();
builder.Services.AddSingleton<IAiOperationHandler, StrategySnapshotOperationHandler>();
@@ -170,6 +173,7 @@ builder.Services.AddHostedService<JobEnrichmentHostedService>();
builder.Services.AddHostedService<SummarizerProbeHostedService>();
builder.Services.AddHostedService<AiOperationHostedService>();
builder.Services.AddHostedService<EmailSendAttemptRecoveryHostedService>();
builder.Services.AddHostedService<AccountDeletionHostedService>();
builder.Services.AddHttpClient("jobimport")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
@@ -552,6 +556,12 @@ app.Use(async (ctx, next) =>
await app.InitializeJobTrackerAsync();
await using (var deletionReplayScope = app.Services.CreateAsyncScope())
{
var staged = await deletionReplayScope.ServiceProvider.GetRequiredService<AccountDeletionService>().StageRestoredAccountsAsync(CancellationToken.None);
if (staged > 0) app.Logger.LogWarning("Staged {Count} restored deleted accounts for mandatory tombstone replay.", staged);
}
await using (var attachmentScope = app.Services.CreateAsyncScope())
{
var result = await attachmentScope.ServiceProvider.GetRequiredService<IAttachmentStorage>()
@@ -14,7 +14,7 @@ public sealed record AccountDataExportArtifact(string StoragePath, string Downlo
public sealed class AccountDataExportService(
JobTrackerContext db,
AppPaths paths,
IAttachmentStorage attachmentStorage,
AccountOwnedFileInventory fileInventory,
TimeProvider timeProvider)
{
private const string SchemaVersion = "jobtracker.user-export.v1";
@@ -32,8 +32,7 @@ public sealed class AccountDataExportService(
var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken)
?? throw new InvalidOperationException("The account no longer exists.");
var generatedAt = timeProvider.GetUtcNow();
var ownerKey = AppPaths.GetOwnerStorageKey(ownerUserId);
var outputRoot = Path.Combine(paths.DataRoot, "AccountExports", ownerKey);
var outputRoot = paths.GetOwnerAccountExportsRoot(ownerUserId);
Directory.CreateDirectory(outputRoot);
var outputPath = Path.Combine(outputRoot, $"{Guid.NewGuid():N}.zip");
var warnings = new List<string>();
@@ -255,18 +254,15 @@ public sealed class AccountDataExportService(
TrustedDevices = trustedDevices,
}, sessions.Count + trustedDevices.Count + recoveryCodeCount);
foreach (var attachment in attachments)
var ownedFiles = await fileInventory.BuildAsync(ownerUserId, cancellationToken);
warnings.AddRange(ownedFiles.Warnings);
foreach (var ownedFile in ownedFiles.Files)
{
await AddOwnedFileAsync(archive, entries, warnings, attachment.FilePath, $"files/attachments/{attachment.Id}/{SafeSegment(attachment.FileName)}", "attachment", attachmentStorage.IsManagedPath, cancellationToken);
if (ownedFile.InlineBytes is not null)
await AddBytesAsync(archive, entries, ownedFile.ExportPath, ownedFile.InlineBytes, ownedFile.Category, 1, cancellationToken);
else
await AddFileAsync(archive, entries, ownedFile.SourcePath!, ownedFile.ExportPath, ownedFile.Category, cancellationToken);
}
foreach (var artifact in artifacts)
{
await AddOwnedFileAsync(archive, entries, warnings, artifact.StoragePath, $"files/cv-artifacts/{artifact.Id}/{SafeSegment(artifact.OriginalFileName)}", "cv-artifact", path => IsManagedPath(paths.CvArtifactsRoot, path), cancellationToken);
}
await AddAvatarAsync(archive, entries, warnings, paths, ownerUserId, user.AvatarImageDataUrl, cancellationToken);
await AddDirectoryAsync(archive, entries, warnings, paths.GetOwnerCvExportsRoot(ownerUserId), "files/generated-cv", "generated-cv", cancellationToken);
await AddDirectoryAsync(archive, entries, warnings, paths.GetOwnerDailyExportsRoot(null, ownerUserId), "files/daily-exports", "daily-export", cancellationToken);
const string readme = """
Jobjakt readable account export
@@ -317,77 +313,6 @@ public sealed class AccountDataExportService(
}
}
private static async Task AddOwnedFileAsync(
ZipArchive archive,
ICollection<ManifestEntry> entries,
ICollection<string> warnings,
string sourcePath,
string entryName,
string category,
Func<string, bool> isManaged,
CancellationToken cancellationToken)
{
if (!isManaged(sourcePath))
{
warnings.Add($"Excluded unsafe {category} path for {entryName}.");
return;
}
if (!File.Exists(sourcePath))
{
warnings.Add($"Owned {category} file was unavailable: {entryName}.");
return;
}
await AddFileAsync(archive, entries, sourcePath, entryName, category, cancellationToken);
}
private static async Task AddDirectoryAsync(ZipArchive archive, ICollection<ManifestEntry> entries, ICollection<string> warnings, string root, string entryRoot, string category, CancellationToken cancellationToken)
{
if (!Directory.Exists(root)) return;
foreach (var path in Directory.EnumerateFiles(root, "*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
}))
{
var relative = Path.GetRelativePath(root, path);
if (relative.StartsWith("..", StringComparison.Ordinal))
{
warnings.Add($"Excluded unsafe {category} path.");
continue;
}
var entryName = $"{entryRoot}/{string.Join('/', relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Select(SafeSegment))}";
await AddFileAsync(archive, entries, path, entryName, category, cancellationToken);
}
}
private static async Task AddAvatarAsync(ZipArchive archive, ICollection<ManifestEntry> entries, ICollection<string> warnings, AppPaths paths, string ownerUserId, string? storedAvatar, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(storedAvatar)) return;
if (storedAvatar.StartsWith("file:", StringComparison.Ordinal))
{
var path = storedAvatar[5..];
var root = Path.Combine(paths.DataRoot, "Avatars", AppPaths.GetOwnerStorageKey(ownerUserId));
await AddOwnedFileAsync(archive, entries, warnings, path, $"files/avatar/{SafeSegment(Path.GetFileName(path))}", "avatar", candidate => IsManagedPath(root, candidate), cancellationToken);
return;
}
if (storedAvatar.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var comma = storedAvatar.IndexOf(',');
if (comma > 0 && storedAvatar[..comma].Contains(";base64", StringComparison.OrdinalIgnoreCase))
{
try
{
var bytes = Convert.FromBase64String(storedAvatar[(comma + 1)..]);
await AddBytesAsync(archive, entries, "files/avatar/avatar", bytes, "avatar", 1, cancellationToken);
return;
}
catch (FormatException) { }
}
}
warnings.Add("The profile avatar was stored in an unsupported format and could not be included.");
}
private static async Task AddFileAsync(ZipArchive archive, ICollection<ManifestEntry> entries, string sourcePath, string entryName, string category, CancellationToken cancellationToken)
{
await using var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
@@ -407,36 +332,5 @@ public sealed class AccountDataExportService(
entries.Add(new ManifestEntry(entry.FullName, bytes.LongLength, Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), category, itemCount));
}
private static bool IsManagedPath(string root, string path)
{
if (string.IsNullOrWhiteSpace(path)) return false;
try
{
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var fullPath = Path.GetFullPath(path);
if (!fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, comparison)) return false;
var current = Path.GetDirectoryName(fullPath);
while (!string.IsNullOrWhiteSpace(current) && !string.Equals(current, fullRoot, comparison))
{
if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) return false;
current = Path.GetDirectoryName(current);
}
return !File.Exists(fullPath) || (File.GetAttributes(fullPath) & FileAttributes.ReparsePoint) == 0;
}
catch
{
return false;
}
}
private static string SafeSegment(string? value)
{
var candidate = Path.GetFileName(value ?? string.Empty).Trim();
foreach (var invalid in Path.GetInvalidFileNameChars()) candidate = candidate.Replace(invalid, '_');
if (candidate.Length > 120) candidate = candidate[..120];
return string.IsNullOrWhiteSpace(candidate) || candidate is "." or ".." ? "file" : candidate;
}
private sealed record ManifestEntry(string Path, long Bytes, string Sha256, string Category, int ItemCount);
}
@@ -0,0 +1,391 @@
using System.Security.Cryptography;
using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace JobTrackerApi.Services;
public sealed record AccountDeletionRequestResult(Guid RequestId, string Status, string Stage);
public sealed class AccountDeletionService(
JobTrackerContext db,
AccountOwnedFileInventory fileInventory,
AccountDeletionTombstoneStore tombstones,
IConfiguration configuration,
IMemoryCache memoryCache,
TimeProvider timeProvider,
ILogger<AccountDeletionService> logger)
{
private const int MaxAttempts = 20;
public bool CanAcceptRequests => configuration.GetValue("AccountLifecycle:DeletionEnabled", false);
public async Task<AccountDeletionRequestResult?> RequestAsync(string ownerUserId, string requestedByUserId, CancellationToken cancellationToken)
{
if (!CanAcceptRequests) return null;
var request = await RequestCoreAsync(ownerUserId, requestedByUserId, cancellationToken);
return new AccountDeletionRequestResult(request.Id, request.Status, request.Stage);
}
public async Task<int> StageRestoredAccountsAsync(CancellationToken cancellationToken)
{
var ownerKeys = (await tombstones.ReadAsync(cancellationToken)).Select(item => item.OwnerKey).ToHashSet(StringComparer.Ordinal);
if (ownerKeys.Count == 0) return 0;
var users = await db.Users.AsNoTracking().Select(item => item.Id).ToListAsync(cancellationToken);
var restored = users.Where(item => ownerKeys.Contains(AppPaths.GetOwnerStorageKey(item))).ToList();
foreach (var ownerUserId in restored)
{
await RequestCoreAsync(ownerUserId, "tombstone-replay", cancellationToken);
}
return restored.Count;
}
public async Task<int> ProcessPendingAsync(CancellationToken cancellationToken)
{
var requestIds = await db.AccountDeletionRequests.AsNoTracking()
.Where(item => item.Status != AccountDeletionRequestStatuses.Completed && item.AttemptCount < MaxAttempts)
.OrderBy(item => item.Id)
.Select(item => item.Id)
.ToListAsync(cancellationToken);
var completed = 0;
foreach (var requestId in requestIds)
{
if (await ProcessAsync(requestId, cancellationToken)) completed++;
}
return completed;
}
public async Task<bool> ProcessAsync(Guid requestId, CancellationToken cancellationToken)
{
// Requests are normally processed in a fresh background scope. Clearing here also makes
// direct retries safe when the same scoped service accepted the request: ExecuteDelete
// must not leave a previously tracked ApplicationUser pending for a later SaveChanges.
db.ChangeTracker.Clear();
var request = await db.AccountDeletionRequests.Include(item => item.Files).FirstOrDefaultAsync(item => item.Id == requestId, cancellationToken);
if (request is null) return false;
if (request.Status == AccountDeletionRequestStatuses.Completed) return true;
request.Status = AccountDeletionRequestStatuses.Processing;
request.AttemptCount++;
request.StartedAtUtc ??= timeProvider.GetUtcNow();
request.LastErrorCategory = null;
request.LastErrorMessage = null;
await db.SaveChangesAsync(cancellationToken);
try
{
if (request.Stage == AccountDeletionStages.Requested)
await PrepareFilesAsync(request, cancellationToken);
if (request.Stage == AccountDeletionStages.QuarantiningFiles)
await QuarantineFilesAsync(request, cancellationToken);
if (request.Stage == AccountDeletionStages.DeletingDatabase)
await DeleteDatabaseRowsAsync(request, cancellationToken);
if (request.Stage == AccountDeletionStages.PurgingFiles)
await PurgeFilesAsync(request, cancellationToken);
if (request.Stage == AccountDeletionStages.RecordingTombstone)
await CompleteAsync(request, cancellationToken);
return request.Status == AccountDeletionRequestStatuses.Completed;
}
catch (Exception ex)
{
request.Status = AccountDeletionRequestStatuses.RetryRequired;
request.LastErrorCategory = Classify(ex);
request.LastErrorMessage = Sanitize(ex.Message);
try { await db.SaveChangesAsync(cancellationToken); }
catch (Exception saveError) { logger.LogError(saveError, "Could not persist account deletion failure for {RequestId}", request.Id); }
logger.LogWarning(ex, "Account deletion request {RequestId} stopped at {Stage}; it remains retryable", request.Id, request.Stage);
return false;
}
}
private async Task<AccountDeletionRequest> RequestCoreAsync(string ownerUserId, string requestedByUserId, CancellationToken cancellationToken)
{
var existing = await db.AccountDeletionRequests
.Where(item => item.OwnerUserId == ownerUserId && item.Status != AccountDeletionRequestStatuses.Completed)
.OrderBy(item => item.Id)
.FirstOrDefaultAsync(cancellationToken);
if (existing is not null) return existing;
var user = await db.Users.FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken)
?? throw new InvalidOperationException("The account no longer exists.");
var now = timeProvider.GetUtcNow();
var request = new AccountDeletionRequest
{
Id = Guid.NewGuid(),
OwnerUserId = ownerUserId,
OwnerKey = AppPaths.GetOwnerStorageKey(ownerUserId),
RequestedByUserId = requestedByUserId,
Status = AccountDeletionRequestStatuses.Pending,
Stage = AccountDeletionStages.Requested,
RequestedAtUtc = now,
};
user.DeletionStatus = AccountDeletionStatuses.Pending;
user.DeletionRequestedAtUtc = now;
user.SecurityStamp = Guid.NewGuid().ToString();
foreach (var variant in await db.CvVariants.IgnoreQueryFilters().Where(item => item.OwnerUserId == ownerUserId && item.IsPublic).ToListAsync(cancellationToken))
variant.IsPublic = false;
foreach (var session in await db.UserSessions.IgnoreQueryFilters().Where(item => item.UserId == ownerUserId && item.RevokedAtUtc == null).ToListAsync(cancellationToken))
session.RevokedAtUtc = now;
db.TrustedDevices.RemoveRange(await db.TrustedDevices.IgnoreQueryFilters().Where(item => item.UserId == ownerUserId).ToListAsync(cancellationToken));
var operations = await db.UserOperations.IgnoreQueryFilters().Where(item => item.OwnerUserId == ownerUserId
&& item.Status != OperationStatuses.Succeeded
&& item.Status != OperationStatuses.Failed
&& item.Status != OperationStatuses.Cancelled).ToListAsync(cancellationToken);
foreach (var operation in operations)
{
if (operation.Status == OperationStatuses.Running) operation.CancellationRequestedAtUtc = now.UtcDateTime;
else
{
operation.Status = OperationStatuses.Cancelled;
operation.CompletedAtUtc = now.UtcDateTime;
operation.LeaseToken = null;
operation.LeaseExpiresAtUtc = null;
}
}
db.AccountDeletionRequests.Add(request);
await db.SaveChangesAsync(cancellationToken);
return request;
}
private async Task PrepareFilesAsync(AccountDeletionRequest request, CancellationToken cancellationToken)
{
var inventory = await fileInventory.BuildAsync(request.OwnerUserId, cancellationToken, includeAccountExports: true);
if (inventory.Warnings.Any(item => item.StartsWith("Excluded unsafe", StringComparison.Ordinal)))
throw new InvalidOperationException("One or more owned file paths failed the managed-root safety check.");
request.WarningJson = JsonSerializer.Serialize(inventory.Warnings);
foreach (var ownedFile in inventory.Files.Where(item => item.SourcePath is not null))
{
var sourcePath = ownedFile.SourcePath!;
if (request.Files.Any(item => string.Equals(item.OriginalPath, sourcePath, PathComparison))) continue;
await using var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
var hash = Convert.ToHexString(await SHA256.HashDataAsync(source, cancellationToken)).ToLowerInvariant();
request.Files.Add(new AccountDeletionFile
{
AccountDeletionRequestId = request.Id,
Category = ownedFile.Category,
OriginalPath = sourcePath,
QuarantinePath = sourcePath + $".{request.Id:N}.account-deleting",
Status = "planned",
ByteSize = source.Length,
Sha256 = hash,
});
}
request.FileCount = request.Files.Count;
request.Stage = AccountDeletionStages.QuarantiningFiles;
await db.SaveChangesAsync(cancellationToken);
}
private async Task QuarantineFilesAsync(AccountDeletionRequest request, CancellationToken cancellationToken)
{
try
{
foreach (var file in request.Files)
{
cancellationToken.ThrowIfCancellationRequested();
if (File.Exists(file.QuarantinePath))
{
file.Status = "quarantined";
continue;
}
if (!File.Exists(file.OriginalPath))
{
file.Status = "missing";
AppendWarning(request, $"Owned {file.Category} file disappeared before quarantine.");
continue;
}
File.Move(file.OriginalPath, file.QuarantinePath, overwrite: false);
file.Status = "quarantined";
}
request.Stage = AccountDeletionStages.DeletingDatabase;
await db.SaveChangesAsync(cancellationToken);
}
catch
{
RestoreQuarantinedFiles(request);
await db.SaveChangesAsync(cancellationToken);
throw;
}
}
private async Task DeleteDatabaseRowsAsync(AccountDeletionRequest request, CancellationToken cancellationToken)
{
foreach (var file in request.Files)
{
if (File.Exists(file.OriginalPath) || (file.Status != "missing" && !File.Exists(file.QuarantinePath)))
throw new InvalidOperationException("Owned files are not fully quarantined; database deletion was not started.");
}
var transaction = db.Database.IsRelational() ? await db.Database.BeginTransactionAsync(cancellationToken) : null;
try
{
var owner = request.OwnerUserId;
var applicationIds = await db.JobApplications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).Select(item => item.Id).ToListAsync(cancellationToken);
var variantIds = await db.CvVariants.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).Select(item => item.Id).ToListAsync(cancellationToken);
var deleted = 0;
deleted += await db.EmailDrafts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.EmailSendAttempts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.ApplicationChecklistItems.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CoverLetterVersions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.InterviewPrepItems.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.InterviewPrepNotes.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.AiWorkspaceNotes.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.AiInteractions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.TailoredCvDrafts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.Correspondences.IgnoreQueryFilters().Where(item => applicationIds.Contains(item.JobApplicationId)).ExecuteDeleteAsync(cancellationToken);
deleted += await db.JobEvents.IgnoreQueryFilters().Where(item => applicationIds.Contains(item.JobApplicationId)).ExecuteDeleteAsync(cancellationToken);
deleted += await db.Attachments.IgnoreQueryFilters().Where(item => applicationIds.Contains(item.JobApplicationId)).ExecuteDeleteAsync(cancellationToken);
deleted += await db.GmailReviewDecisions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CvVariantVersions.IgnoreQueryFilters().Where(item => variantIds.Contains(item.CvVariantId)).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CvVariants.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerExperiences.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerEducations.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerSkills.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerProjects.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerCertifications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerLanguages.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerProfileVersions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerProfiles.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CvExtractionRuns.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CvUploadArtifacts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
if (await db.GmailConnections.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == owner, cancellationToken))
AppendWarning(request, "Google consent was not revoked remotely; local Gmail credentials were deleted.");
deleted += await db.GmailConnections.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.MicrosoftGraphConnections.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.ImapConnections.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.UserRuleSettings.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.UserNotifications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.UserOperations.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.JobApplications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.Jobs.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.Companies.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.TwoFactorRecoveryCodes.IgnoreQueryFilters().Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.TrustedDevices.IgnoreQueryFilters().Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.UserSessions.IgnoreQueryFilters().Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.UserClaims.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.UserLogins.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.UserTokens.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.UserRoles.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.Users.Where(item => item.Id == owner).ExecuteDeleteAsync(cancellationToken);
request.DatabaseRowCount = deleted;
request.Stage = AccountDeletionStages.PurgingFiles;
request.Status = AccountDeletionRequestStatuses.Processing;
await db.SaveChangesAsync(cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
}
catch
{
if (transaction is not null)
{
try { await transaction.RollbackAsync(CancellationToken.None); }
catch (Exception rollbackError) { logger.LogError(rollbackError, "Account deletion transaction rollback outcome is uncertain for {RequestId}", request.Id); }
}
// A commit can succeed at the database and still lose the acknowledgement. Restore
// quarantined files only when the owner row proves the database deletion rolled back.
// When the outcome cannot be read, leave files quarantined and safely replay deletion.
try
{
if (await db.Users.AsNoTracking().AnyAsync(item => item.Id == request.OwnerUserId, CancellationToken.None))
{
RestoreQuarantinedFiles(request);
request.Stage = AccountDeletionStages.DeletingDatabase;
}
}
catch (Exception verificationError)
{
logger.LogError(verificationError, "Could not verify database deletion outcome for {RequestId}; files remain quarantined", request.Id);
request.Stage = AccountDeletionStages.DeletingDatabase;
}
throw;
}
finally
{
if (transaction is not null) await transaction.DisposeAsync();
}
}
private async Task PurgeFilesAsync(AccountDeletionRequest request, CancellationToken cancellationToken)
{
foreach (var file in request.Files)
{
cancellationToken.ThrowIfCancellationRequested();
if (File.Exists(file.QuarantinePath)) File.Delete(file.QuarantinePath);
file.Status = "purged";
}
if (memoryCache is MemoryCache cache) cache.Compact(1.0);
AppendWarning(request, "The local AI sidecar cache is content-keyed and ages out under its configured TTL; production deletion remains disabled until cache purge/restart is rehearsed.");
request.Stage = AccountDeletionStages.RecordingTombstone;
await db.SaveChangesAsync(cancellationToken);
}
private async Task CompleteAsync(AccountDeletionRequest request, CancellationToken cancellationToken)
{
var completedAt = timeProvider.GetUtcNow();
await tombstones.AppendAsync(request.Id, request.OwnerKey, completedAt, cancellationToken);
request.Stage = AccountDeletionStages.Completed;
request.Status = AccountDeletionRequestStatuses.Completed;
request.CompletedAtUtc = completedAt;
await db.SaveChangesAsync(cancellationToken);
}
private static void RestoreQuarantinedFiles(AccountDeletionRequest request)
{
foreach (var file in request.Files.Where(item => File.Exists(item.QuarantinePath) && !File.Exists(item.OriginalPath)))
{
try
{
File.Move(file.QuarantinePath, file.OriginalPath, overwrite: false);
file.Status = "planned";
}
catch { }
}
}
private static void AppendWarning(AccountDeletionRequest request, string warning)
{
var warnings = string.IsNullOrWhiteSpace(request.WarningJson)
? new List<string>()
: JsonSerializer.Deserialize<List<string>>(request.WarningJson) ?? new List<string>();
if (!warnings.Contains(warning, StringComparer.Ordinal)) warnings.Add(warning);
request.WarningJson = JsonSerializer.Serialize(warnings);
}
private static string Classify(Exception exception) => exception switch
{
IOException => "file_io",
UnauthorizedAccessException => "file_access",
DbUpdateException => "database",
OperationCanceledException => "cancelled",
_ => "unexpected",
};
private static string Sanitize(string value)
{
var message = value.Replace('\r', ' ').Replace('\n', ' ').Trim();
return message.Length > 500 ? message[..500] : message;
}
private static StringComparison PathComparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
}
public sealed class AccountDeletionHostedService(
IServiceScopeFactory scopes,
IStartupReadiness startupReadiness,
ILogger<AccountDeletionHostedService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await startupReadiness.WaitUntilReadyAsync(stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await using var scope = scopes.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<AccountDeletionService>().ProcessPendingAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
catch (Exception ex) { logger.LogError(ex, "Account deletion reconciliation failed; durable requests remain retryable"); }
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}
@@ -0,0 +1,64 @@
using System.Text.Json;
namespace JobTrackerApi.Services;
public sealed record AccountDeletionTombstone(string SchemaVersion, Guid RequestId, string OwnerKey, DateTimeOffset CompletedAtUtc);
public sealed class AccountDeletionTombstoneStore(AppPaths paths)
{
private const string SchemaVersion = "jobtracker.account-deletion-tombstone.v1";
private readonly SemaphoreSlim _gate = new(1, 1);
private string LedgerPath => Path.Combine(paths.AccountDeletionTombstonesRoot, "tombstones.jsonl");
public async Task AppendAsync(Guid requestId, string ownerKey, DateTimeOffset completedAtUtc, CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken);
try
{
var existing = await ReadUnsafeAsync(cancellationToken);
if (existing.Any(item => item.RequestId == requestId)) return;
Directory.CreateDirectory(paths.AccountDeletionTombstonesRoot);
var line = JsonSerializer.Serialize(new AccountDeletionTombstone(SchemaVersion, requestId, ownerKey, completedAtUtc));
await File.AppendAllTextAsync(LedgerPath, line + Environment.NewLine, cancellationToken);
}
finally
{
_gate.Release();
}
}
public async Task<IReadOnlyList<AccountDeletionTombstone>> ReadAsync(CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken);
try { return await ReadUnsafeAsync(cancellationToken); }
finally { _gate.Release(); }
}
private async Task<IReadOnlyList<AccountDeletionTombstone>> ReadUnsafeAsync(CancellationToken cancellationToken)
{
if (!File.Exists(LedgerPath)) return Array.Empty<AccountDeletionTombstone>();
var result = new List<AccountDeletionTombstone>();
foreach (var line in await File.ReadAllLinesAsync(LedgerPath, cancellationToken))
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
var item = JsonSerializer.Deserialize<AccountDeletionTombstone>(line);
if (item is null
|| item.SchemaVersion != SchemaVersion
|| item.RequestId == Guid.Empty
|| item.OwnerKey.Length != 64
|| item.OwnerKey.Any(character => !Uri.IsHexDigit(character)))
throw new InvalidOperationException("The account-deletion tombstone ledger contains an invalid record.");
result.Add(item);
}
catch (JsonException)
{
// A partial/corrupt line is never ignored by replay callers: expose a sentinel so
// startup fails closed instead of declaring the tombstone set complete.
throw new InvalidOperationException("The account-deletion tombstone ledger contains an invalid record.");
}
}
return result;
}
}
@@ -0,0 +1,139 @@
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
public sealed record AccountOwnedFile(string Category, string ExportPath, string? SourcePath, byte[]? InlineBytes);
public sealed record AccountOwnedFileInventoryResult(IReadOnlyList<AccountOwnedFile> Files, IReadOnlyList<string> Warnings);
public sealed class AccountOwnedFileInventory(JobTrackerContext db, AppPaths paths, IAttachmentStorage attachmentStorage)
{
public async Task<AccountOwnedFileInventoryResult> BuildAsync(string ownerUserId, CancellationToken cancellationToken, bool includeAccountExports = false)
{
ArgumentException.ThrowIfNullOrWhiteSpace(ownerUserId);
var files = new List<AccountOwnedFile>();
var warnings = new List<string>();
var applicationIds = await db.JobApplications.IgnoreQueryFilters().AsNoTracking()
.Where(item => item.OwnerUserId == ownerUserId)
.Select(item => item.Id)
.ToListAsync(cancellationToken);
var attachments = await db.Attachments.IgnoreQueryFilters().AsNoTracking()
.Where(item => applicationIds.Contains(item.JobApplicationId))
.OrderBy(item => item.Id)
.ToListAsync(cancellationToken);
foreach (var attachment in attachments)
{
AddPath(files, warnings, attachment.FilePath, $"files/attachments/{attachment.Id}/{SafeSegment(attachment.FileName)}", "attachment", attachmentStorage.IsManagedPath);
}
var artifacts = await db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking()
.Where(item => item.OwnerUserId == ownerUserId)
.OrderBy(item => item.Id)
.ToListAsync(cancellationToken);
foreach (var artifact in artifacts)
{
AddPath(files, warnings, artifact.StoragePath, $"files/cv-artifacts/{artifact.Id}/{SafeSegment(artifact.OriginalFileName)}", "cv-artifact", candidate => IsManagedPath(paths.CvArtifactsRoot, candidate));
}
var avatar = await db.Users.AsNoTracking().Where(item => item.Id == ownerUserId).Select(item => item.AvatarImageDataUrl).FirstOrDefaultAsync(cancellationToken);
AddAvatar(files, warnings, ownerUserId, avatar);
AddDirectory(files, warnings, paths.GetOwnerCvExportsRoot(ownerUserId), "files/generated-cv", "generated-cv");
AddDirectory(files, warnings, paths.GetOwnerDailyExportsRoot(null, ownerUserId), "files/daily-exports", "daily-export");
if (includeAccountExports)
AddDirectory(files, warnings, paths.GetOwnerAccountExportsRoot(ownerUserId), "files/account-exports", "account-export");
return new AccountOwnedFileInventoryResult(files, warnings);
}
private void AddAvatar(ICollection<AccountOwnedFile> files, ICollection<string> warnings, string ownerUserId, string? storedAvatar)
{
if (string.IsNullOrWhiteSpace(storedAvatar)) return;
if (storedAvatar.StartsWith("file:", StringComparison.Ordinal))
{
var path = storedAvatar[5..];
var root = Path.Combine(paths.DataRoot, "Avatars", AppPaths.GetOwnerStorageKey(ownerUserId));
AddPath(files, warnings, path, $"files/avatar/{SafeSegment(Path.GetFileName(path))}", "avatar", candidate => IsManagedPath(root, candidate));
return;
}
if (storedAvatar.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var comma = storedAvatar.IndexOf(',');
if (comma > 0 && storedAvatar[..comma].Contains(";base64", StringComparison.OrdinalIgnoreCase))
{
try
{
files.Add(new AccountOwnedFile("avatar", "files/avatar/avatar", null, Convert.FromBase64String(storedAvatar[(comma + 1)..])));
return;
}
catch (FormatException) { }
}
}
warnings.Add("The profile avatar was stored in an unsupported format and could not be included.");
}
private static void AddPath(ICollection<AccountOwnedFile> files, ICollection<string> warnings, string sourcePath, string exportPath, string category, Func<string, bool> isManaged)
{
if (!isManaged(sourcePath))
{
warnings.Add($"Excluded unsafe {category} path for {exportPath}.");
return;
}
if (!File.Exists(sourcePath))
{
warnings.Add($"Owned {category} file was unavailable: {exportPath}.");
return;
}
files.Add(new AccountOwnedFile(category, exportPath, Path.GetFullPath(sourcePath), null));
}
private static void AddDirectory(ICollection<AccountOwnedFile> files, ICollection<string> warnings, string root, string exportRoot, string category)
{
if (!Directory.Exists(root)) return;
foreach (var path in Directory.EnumerateFiles(root, "*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
}))
{
var relative = Path.GetRelativePath(root, path);
if (relative.StartsWith("..", StringComparison.Ordinal) || !IsManagedPath(root, path))
{
warnings.Add($"Excluded unsafe {category} path.");
continue;
}
var exportPath = $"{exportRoot}/{string.Join('/', relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Select(SafeSegment))}";
files.Add(new AccountOwnedFile(category, exportPath, Path.GetFullPath(path), null));
}
}
public static bool IsManagedPath(string root, string path)
{
if (string.IsNullOrWhiteSpace(path)) return false;
try
{
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var fullPath = Path.GetFullPath(path);
if (!fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, comparison)) return false;
var current = Path.GetDirectoryName(fullPath);
while (!string.IsNullOrWhiteSpace(current) && !string.Equals(current, fullRoot, comparison))
{
if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) return false;
current = Path.GetDirectoryName(current);
}
return !File.Exists(fullPath) || (File.GetAttributes(fullPath) & FileAttributes.ReparsePoint) == 0;
}
catch
{
return false;
}
}
private static string SafeSegment(string? value)
{
var candidate = Path.GetFileName(value ?? string.Empty).Trim();
foreach (var invalid in Path.GetInvalidFileNameChars()) candidate = candidate.Replace(invalid, '_');
if (candidate.Length > 120) candidate = candidate[..120];
return string.IsNullOrWhiteSpace(candidate) || candidate is "." or ".." ? "file" : candidate;
}
}
+10
View File
@@ -12,6 +12,7 @@ namespace JobTrackerApi.Services
public string CvArtifactsRoot { get; }
public string CvExportsRoot { get; }
public string CvBenchmarksRoot { get; }
public string AccountDeletionTombstonesRoot { get; }
public AppPaths(IConfiguration cfg, IHostEnvironment env)
{
@@ -49,6 +50,12 @@ namespace JobTrackerApi.Services
Directory.CreateDirectory(cvBenchmarksRoot);
CvBenchmarksRoot = cvBenchmarksRoot;
var tombstonesRoot = (cfg["AccountLifecycle:TombstonesRoot"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(tombstonesRoot)) tombstonesRoot = Path.Combine(DataRoot, "DeletionTombstones");
if (!Path.IsPathRooted(tombstonesRoot)) tombstonesRoot = Path.Combine(env.ContentRootPath, tombstonesRoot);
Directory.CreateDirectory(tombstonesRoot);
AccountDeletionTombstonesRoot = tombstonesRoot;
}
public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName);
@@ -71,6 +78,9 @@ namespace JobTrackerApi.Services
public string GetOwnerDailyExportsRoot(string? configuredFolder, string ownerUserId) =>
Path.Combine(GetExportsRoot(configuredFolder), GetOwnerStorageKey(ownerUserId));
public string GetOwnerAccountExportsRoot(string ownerUserId) =>
Path.Combine(DataRoot, "AccountExports", GetOwnerStorageKey(ownerUserId));
}
}
@@ -1,5 +1,6 @@
using System.Security.Claims;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
@@ -19,7 +20,9 @@ public static class LocalSessionValidator
var session = await db.UserSessions.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Id == sid && x.UserId == userId, cancellationToken);
if (session is null || session.RevokedAtUtc is not null || session.ExpiresAtUtc <= now) return false;
if (requireConfirmedEmail && !await db.Users.IgnoreQueryFilters().AnyAsync(x => x.Id == userId && x.EmailConfirmed, cancellationToken)) return false;
var user = await db.Users.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
if (user is null || user.DeletionStatus != AccountDeletionStatuses.Active) return false;
if (requireConfirmedEmail && !user.EmailConfirmed) return false;
if (now - session.LastSeenAtUtc > TimeSpan.FromMinutes(5))
{
+3
View File
@@ -22,6 +22,9 @@
"ExternalProvider": "ollama",
"RoutingMode": "local_first"
},
"AccountLifecycle": {
"DeletionEnabled": false
},
"AiQueue": {
"WorkerConcurrency": 1,
"GlobalCapacity": 100,