mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
feat(permissions): protect configured account owner (SH-329)
This commit is contained in:
parent
db9a94f335
commit
69798b3e86
17 changed files with 4293 additions and 2 deletions
60
Api.SeaHavenIndustries.Tests/AccountOwnerDataServiceTests.cs
Normal file
60
Api.SeaHavenIndustries.Tests/AccountOwnerDataServiceTests.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class AccountOwnerDataServiceTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureConfiguredOwnerAsync_MarksOnlyConfiguredUser()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Users.AddRange(
|
||||
new ApplicationUser { Id = "old-owner", UserName = "old@example.com", IsAccountOwner = true },
|
||||
new ApplicationUser { Id = "configured-owner", UserName = "configured@example.com" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new AccountOwnerDataService(context);
|
||||
(await service.EnsureConfiguredOwnerAsync(" configured-owner ", CancellationToken.None)).Should().BeTrue();
|
||||
|
||||
(await context.Users.SingleAsync(user => user.Id == "old-owner")).IsAccountOwner.Should().BeFalse();
|
||||
(await context.Users.SingleAsync(user => user.Id == "configured-owner")).IsAccountOwner.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureConfiguredOwnerAsync_WithNoConfiguration_ClearsExistingOwner()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner@example.com", IsAccountOwner = true });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new AccountOwnerDataService(context);
|
||||
(await service.EnsureConfiguredOwnerAsync(null, CancellationToken.None)).Should().BeTrue();
|
||||
(await service.EnsureConfiguredOwnerAsync(" ", CancellationToken.None)).Should().BeFalse();
|
||||
|
||||
(await context.Users.SingleAsync()).IsAccountOwner.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureConfiguredOwnerAsync_RejectsUnknownUser()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var service = new AccountOwnerDataService(context);
|
||||
|
||||
var action = () => service.EnsureConfiguredOwnerAsync("missing", CancellationToken.None);
|
||||
|
||||
await action.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("The configured account owner user was not found.");
|
||||
}
|
||||
}
|
||||
|
|
@ -90,6 +90,23 @@ public class UserServiceTests
|
|||
userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteUser_AccountOwner_ReturnsForbiddenWithoutCascade()
|
||||
{
|
||||
var user = IdentityTestHelpers.User("owner");
|
||||
var userData = new Mock<IUserDataService>();
|
||||
userData.Setup(u => u.GetForEditAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||
userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||
|
||||
var service = NewService(userData, new Mock<IEmailSender>(), out _);
|
||||
|
||||
var outcome = await service.DeleteUserAsync("owner", Principal("Admin"), CancellationToken.None);
|
||||
|
||||
outcome.Success.Should().BeFalse();
|
||||
outcome.Error.Should().Be(UserMutationErrors.Forbidden);
|
||||
userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteUser_NonAdmin_ReturnsForbiddenWithoutCascade()
|
||||
{
|
||||
|
|
@ -138,6 +155,26 @@ public class UserServiceTests
|
|||
outcome.Error.Should().Be(UserMutationErrors.UserNotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditUser_AccountOwner_ReturnsForbiddenWithoutMutation()
|
||||
{
|
||||
var user = IdentityTestHelpers.User("owner");
|
||||
var userData = new Mock<IUserDataService>();
|
||||
userData.Setup(u => u.GetForEditAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||
userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||
|
||||
var service = NewService(userData, new Mock<IEmailSender>(), out _);
|
||||
|
||||
var outcome = await service.EditUserAsync(
|
||||
new EditUserRequestDTO { Id = "owner", Email = "owner@example.com", Name = "Owner", Role = "User" },
|
||||
Principal("Admin"),
|
||||
CancellationToken.None);
|
||||
|
||||
outcome.Success.Should().BeFalse();
|
||||
outcome.Error.Should().Be(UserMutationErrors.Forbidden);
|
||||
userData.Verify(u => u.UpdateUserAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUserProfile_MapsCreatedDateToAddedDate()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -222,6 +222,13 @@ app.UseAuthorization();
|
|||
|
||||
app.MapControllers();
|
||||
|
||||
using (var ownerScope = app.Services.CreateScope())
|
||||
{
|
||||
await ownerScope.ServiceProvider
|
||||
.GetRequiredService<IAccountOwnerDesignationService>()
|
||||
.EnsureConfiguredOwnerAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
//if (app.Environment.IsDevelopment())
|
||||
//{
|
||||
// using var scope = app.Services.CreateScope();
|
||||
|
|
|
|||
|
|
@ -170,6 +170,12 @@ namespace Data.SeaHavenIndustries
|
|||
builder.Entity<ApplicationUser>()
|
||||
.HasIndex(u => u.AccountId);
|
||||
|
||||
builder.Entity<ApplicationUser>()
|
||||
.HasIndex(u => u.IsAccountOwner)
|
||||
.IsUnique()
|
||||
.HasFilter("IsAccountOwner = 1")
|
||||
.HasDatabaseName("IX_AspNetUsers_IsAccountOwner");
|
||||
|
||||
builder.Entity<VendorCompany>()
|
||||
.HasIndex(c => c.NormalizedName)
|
||||
.IsUnique();
|
||||
|
|
@ -385,6 +391,7 @@ namespace Data.SeaHavenIndustries
|
|||
public string? Initials { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public int? Type { get; set; } // 1 for users 0 for admin
|
||||
public bool IsAccountOwner { get; set; }
|
||||
/// <summary>Optional CRM account membership for server-derived media scope (SH-221).</summary>
|
||||
public int? AccountId { get; set; }
|
||||
[ForeignKey(nameof(AccountId))]
|
||||
|
|
|
|||
4018
Data.SeaHavenIndustries/Migrations/20260916211933_SH329_PrimaryAccountOwner.Designer.cs
generated
Normal file
4018
Data.SeaHavenIndustries/Migrations/20260916211933_SH329_PrimaryAccountOwner.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,40 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH329_PrimaryAccountOwner : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsAccountOwner",
|
||||
table: "AspNetUsers",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUsers_IsAccountOwner",
|
||||
table: "AspNetUsers",
|
||||
column: "IsAccountOwner",
|
||||
unique: true,
|
||||
filter: "IsAccountOwner = 1");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_AspNetUsers_IsAccountOwner",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsAccountOwner",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -220,6 +220,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("Initials")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsAccountOwner")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
|
|
@ -275,6 +278,11 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("IsAccountOwner")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_AspNetUsers_IsAccountOwner")
|
||||
.HasFilter("IsAccountOwner = 1");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation;
|
||||
|
||||
public sealed class AccountOwnerDataService : IAccountOwnerDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public AccountOwnerDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<bool> EnsureConfiguredOwnerAsync(
|
||||
string? configuredUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerId = string.IsNullOrWhiteSpace(configuredUserId)
|
||||
? null
|
||||
: configuredUserId.Trim();
|
||||
|
||||
if (ownerId is not null)
|
||||
{
|
||||
var configuredUserExists = await _context.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(user => user.Id == ownerId, cancellationToken);
|
||||
|
||||
if (!configuredUserExists)
|
||||
throw new InvalidOperationException("The configured account owner user was not found.");
|
||||
}
|
||||
|
||||
var ownerRows = await _context.Users
|
||||
.Where(user => user.IsAccountOwner || (ownerId != null && user.Id == ownerId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var changed = false;
|
||||
foreach (var user in ownerRows)
|
||||
{
|
||||
var shouldBeOwner = ownerId is not null && user.Id == ownerId;
|
||||
if (user.IsAccountOwner != shouldBeOwner)
|
||||
{
|
||||
user.IsAccountOwner = shouldBeOwner;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +93,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> IsAccountOwnerAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Users
|
||||
.AsNoTracking()
|
||||
.Where(user => user.Id == userId)
|
||||
.Select(user => user.IsAccountOwner)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedEmail = email.Trim().ToUpperInvariant();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
namespace SeaHaven.DataServices.Interfaces;
|
||||
|
||||
public interface IAccountOwnerDataService
|
||||
{
|
||||
Task<bool> EnsureConfiguredOwnerAsync(string? configuredUserId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<UserProfileData?> GetProfileAsync(string id, CancellationToken cancellationToken);
|
||||
Task<bool> UpdateProfileAsync(string id, string? name, string? email, string? contact, CancellationToken cancellationToken);
|
||||
Task<ApplicationUser?> GetForEditAsync(string id, CancellationToken cancellationToken);
|
||||
Task<bool> IsAccountOwnerAsync(string userId, CancellationToken cancellationToken);
|
||||
Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken);
|
||||
Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken);
|
||||
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
|
||||
|
|
|
|||
8
SeaHaven.Services/Configuration/AccountOwnerOptions.cs
Normal file
8
SeaHaven.Services/Configuration/AccountOwnerOptions.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace SeaHaven.Services.Configuration;
|
||||
|
||||
public sealed class AccountOwnerOptions
|
||||
{
|
||||
public const string SectionName = "TeamMembers:AccountOwner";
|
||||
|
||||
public string? UserId { get; set; }
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ namespace SeaHaven.Services.DependencyInjection
|
|||
services.Configure<ApprovalsOptions>(configuration.GetSection(ApprovalsOptions.SectionName));
|
||||
services.Configure<VendorPortalOptions>(configuration.GetSection(VendorPortalOptions.SectionName));
|
||||
services.Configure<VendorDocumentsOptions>(configuration.GetSection(VendorDocumentsOptions.SectionName));
|
||||
services.Configure<AccountOwnerOptions>(configuration.GetSection(AccountOwnerOptions.SectionName));
|
||||
|
||||
services.AddOptions<WorkOrderWebhookOptions>()
|
||||
.Bind(configuration.GetSection(WorkOrderWebhookOptions.SectionName))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation;
|
||||
|
||||
public sealed class AccountOwnerDesignationService : IAccountOwnerDesignationService
|
||||
{
|
||||
private readonly IAccountOwnerDataService _dataService;
|
||||
private readonly IOptions<AccountOwnerOptions> _options;
|
||||
|
||||
public AccountOwnerDesignationService(
|
||||
IAccountOwnerDataService dataService,
|
||||
IOptions<AccountOwnerOptions> options)
|
||||
{
|
||||
_dataService = dataService;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public Task EnsureConfiguredOwnerAsync(CancellationToken cancellationToken) =>
|
||||
_dataService.EnsureConfiguredOwnerAsync(_options.Value.UserId, cancellationToken);
|
||||
}
|
||||
|
|
@ -136,6 +136,9 @@ namespace SeaHaven.Services.Implementation
|
|||
if (exist == null)
|
||||
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
|
||||
|
||||
if (await _userDataService.IsAccountOwnerAsync(exist.Id, cancellationToken))
|
||||
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden };
|
||||
|
||||
exist.FirstName = dto.Name;
|
||||
if (string.IsNullOrWhiteSpace(dto.Email))
|
||||
throw new ArgumentException("Email is required.", nameof(dto));
|
||||
|
|
@ -174,6 +177,9 @@ namespace SeaHaven.Services.Implementation
|
|||
if (data == null)
|
||||
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
|
||||
|
||||
if (await _userDataService.IsAccountOwnerAsync(data.Id, cancellationToken))
|
||||
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden };
|
||||
|
||||
await _userDataService.DeleteUserWithCascadeAsync(data, cancellationToken);
|
||||
return new AddUserOutcomeDTO { Success = true };
|
||||
}
|
||||
|
|
@ -181,7 +187,7 @@ namespace SeaHaven.Services.Implementation
|
|||
public async Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var exist = await _userDataService.GetForEditAsync(userId, cancellationToken);
|
||||
if (exist != null)
|
||||
if (exist != null && !await _userDataService.IsAccountOwnerAsync(exist.Id, cancellationToken))
|
||||
{
|
||||
exist.IsDeleted = true;
|
||||
await _userDataService.UpdateUserAsync(exist, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
namespace SeaHaven.Services.Interfaces;
|
||||
|
||||
public interface IAccountOwnerDesignationService
|
||||
{
|
||||
Task EnsureConfiguredOwnerAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
@ -208,7 +208,7 @@ public class WorkOrderMediaConcurrencyRelationalTests
|
|||
|
||||
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
|
||||
{
|
||||
if (index.GetFilter() != null)
|
||||
if (index.GetFilter() != null && index.GetDatabaseName() != "IX_AspNetUsers_IsAccountOwner")
|
||||
index.SetFilter(null);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue