Store users: Ensure the last owner cannot be downgraded (#6654)

* Store users: Ensure the last owner cannot be downgraded

Changes the behaviour of the `AddOrUpdateStoreUser` method to throw errors for the failure cases, so that the UI and API can report the actual problem. A role change might fail if the user already has that role or if they are the last owner of the store.

* Cleanup code

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
d11n
2025-04-08 10:21:07 +02:00
committed by GitHub
parent ce83e4d96d
commit 1c921030dc
7 changed files with 93 additions and 34 deletions

View File

@@ -4082,12 +4082,14 @@ namespace BTCPayServer.Tests
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.RemoveStoreUser(user.StoreId, user.UserId)); await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.RemoveStoreUser(user.StoreId, user.UserId));
// updates // updates
await client.UpdateStoreUser(user.StoreId, employee.UserId, new StoreUserData { StoreRole = ownerRole.Id }); await client.UpdateStoreUser(user.StoreId, employee.UserId, new StoreUserData { StoreRole = managerRole.Id });
await employeeClient.GetStore(user.StoreId); await employeeClient.GetStore(user.StoreId);
await AssertAPIError("store-user-role-orphaned", async () => await client.UpdateStoreUser(user.StoreId, user.UserId, new StoreUserData { StoreRole = managerRole.Id }));
// remove // remove
await client.RemoveStoreUser(user.StoreId, employee.UserId); await client.RemoveStoreUser(user.StoreId, employee.UserId);
await AssertHttpError(403, async () => await employeeClient.GetStore(user.StoreId)); await AssertHttpError(403, async () => await employeeClient.GetStore(user.StoreId));
await AssertAPIError("store-user-role-orphaned", async () => await client.RemoveStoreUser(user.StoreId, user.UserId));
// test duplicate add // test duplicate add
await client.AddStoreUser(user.StoreId, new StoreUserData { StoreRole = ownerRole.Id, Id = employee.UserId }); await client.AddStoreUser(user.StoreId, new StoreUserData { StoreRole = ownerRole.Id, Id = employee.UserId });
@@ -4103,7 +4105,6 @@ namespace BTCPayServer.Tests
await AssertAPIError("store-user-role-orphaned", async () => await employeeClient.RemoveStoreUser(user.StoreId, employee.UserId)); await AssertAPIError("store-user-role-orphaned", async () => await employeeClient.RemoveStoreUser(user.StoreId, employee.UserId));
} }
[Fact(Timeout = TestTimeout)] [Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")] [Trait("Integration", "Integration")]

View File

@@ -3936,6 +3936,14 @@ retry:
Assert.Equal(3, options.Count); Assert.Equal(3, options.Count);
Assert.DoesNotContain(options, element => element.Text.Equals("store role", StringComparison.InvariantCultureIgnoreCase)); Assert.DoesNotContain(options, element => element.Text.Equals("store role", StringComparison.InvariantCultureIgnoreCase));
s.Driver.FindElement(By.Id("Email")).SendKeys(s.AsTestAccount().Email);
s.Driver.FindElement(By.Id("Role")).SendKeys("owner");
s.Driver.FindElement(By.Id("AddUser")).Click();
Assert.Contains("The user already has the role Owner.", s.Driver.FindElement(By.CssSelector(".validation-summary-errors")).Text);
s.Driver.FindElement(By.Id("Role")).SendKeys("manager");
s.Driver.FindElement(By.Id("AddUser")).Click();
Assert.Contains("The user is the last owner. Their role cannot be changed.", s.Driver.FindElement(By.CssSelector(".validation-summary-errors")).Text);
s.GoToStore(StoreNavPages.Roles); s.GoToStore(StoreNavPages.Roles);
s.ClickPagePrimary(); s.ClickPagePrimary();
s.Driver.FindElement(By.Id("Role")).SendKeys("Malice"); s.Driver.FindElement(By.Id("Role")).SendKeys("Malice");
@@ -4188,7 +4196,7 @@ retry:
Assert.Equal(s.Driver.WaitForElement(By.Id("EditUserEmail")).Text, employee); Assert.Equal(s.Driver.WaitForElement(By.Id("EditUserEmail")).Text, employee);
// no change, no alert message // no change, no alert message
s.Driver.FindElement(By.Id("EditContinue")).Click(); s.Driver.FindElement(By.Id("EditContinue")).Click();
s.Driver.ElementDoesNotExist(By.CssSelector("#mainContent .alert")); Assert.Contains("The user already has the role Manager.", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error).Text);
// Should not change last owner // Should not change last owner
userRows = s.Driver.FindElements(By.CssSelector("#StoreUsersList tr")); userRows = s.Driver.FindElements(By.CssSelector("#StoreUsersList tr"));
@@ -4203,7 +4211,7 @@ retry:
Assert.Equal(s.Driver.WaitForElement(By.Id("EditUserEmail")).Text, owner); Assert.Equal(s.Driver.WaitForElement(By.Id("EditUserEmail")).Text, owner);
new SelectElement(s.Driver.FindElement(By.Id("EditUserRole"))).SelectByValue("Employee"); new SelectElement(s.Driver.FindElement(By.Id("EditUserRole"))).SelectByValue("Employee");
s.Driver.FindElement(By.Id("EditContinue")).Click(); s.Driver.FindElement(By.Id("EditContinue")).Click();
Assert.Contains($"User {owner} is the last owner. Their role cannot be changed.", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error).Text); Assert.Contains("The user is the last owner. Their role cannot be changed.", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error).Text);
} }
private static void CanBrowseContent(SeleniumTester s) private static void CanBrowseContent(SeleniumTester s)

View File

@@ -1,7 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Data;
using System.Reflection.Metadata;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
@@ -15,7 +13,7 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using NicolasDorier.RateLimits; using static BTCPayServer.Services.Stores.StoreRepository;
using StoreData = BTCPayServer.Data.StoreData; using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers.Greenfield namespace BTCPayServer.Controllers.Greenfield
@@ -79,7 +77,6 @@ namespace BTCPayServer.Controllers.Greenfield
// Deprecated properties // Deprecated properties
request.StoreRole ??= request.AdditionalData.TryGetValue("role", out var role) ? role.ToString() : null; request.StoreRole ??= request.AdditionalData.TryGetValue("role", out var role) ? role.ToString() : null;
request.Id ??= request.AdditionalData.TryGetValue("userId", out var userId) ? userId.ToString() : null; request.Id ??= request.AdditionalData.TryGetValue("userId", out var userId) ? userId.ToString() : null;
/////
var user = await _userManager.FindByIdOrEmail(idOrEmail ?? request.Id); var user = await _userManager.FindByIdOrEmail(idOrEmail ?? request.Id);
if (user == null) if (user == null)
@@ -96,12 +93,24 @@ namespace BTCPayServer.Controllers.Greenfield
if (!ModelState.IsValid) if (!ModelState.IsValid)
return this.CreateValidationError(ModelState); return this.CreateValidationError(ModelState);
var result = string.IsNullOrEmpty(idOrEmail) AddOrUpdateStoreUserResult res;
? await _storeRepository.AddStoreUser(storeId, user.Id, roleId) if (string.IsNullOrEmpty(idOrEmail))
: await _storeRepository.AddOrUpdateStoreUser(storeId, user.Id, roleId); {
return result res = await _storeRepository.AddStoreUser(storeId, user.Id, roleId) ? new AddOrUpdateStoreUserResult.Success() : new AddOrUpdateStoreUserResult.DuplicateRole(roleId);
? Ok() }
: this.CreateAPIError(409, "duplicate-store-user-role", "The user is already added to the store"); else
{
res = await _storeRepository.AddOrUpdateStoreUser(storeId, user.Id, roleId);
}
return res switch
{
AddOrUpdateStoreUserResult.Success => Ok(),
AddOrUpdateStoreUserResult.DuplicateRole _ => this.CreateAPIError(409, "duplicate-store-user-role", "The user is already added to the store"),
// AddOrUpdateStoreUserResult.InvalidRole
// AddOrUpdateStoreUserResult.LastOwner
_ => this.CreateAPIError(409, "store-user-role-orphaned", "Removing this user would result in the store having no owner."),
};
} }
private async Task<IEnumerable<StoreUserData>> ToAPI(StoreData store) private async Task<IEnumerable<StoreUserData>> ToAPI(StoreData store)

View File

@@ -409,7 +409,6 @@ namespace BTCPayServer.Controllers.Greenfield
settings.FirstRun = false; settings.FirstRun = false;
await _settingsRepository.UpdateSetting(settings); await _settingsRepository.UpdateSetting(settings);
} }
await _settingsRepository.FirstAdminRegistered(policies, _options.UpdateUrl != null, _options.DisableRegistration, Logs); await _settingsRepository.FirstAdminRegistered(policies, _options.UpdateUrl != null, _options.DisableRegistration, Logs);
} }
} }

View File

@@ -14,6 +14,7 @@ using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static BTCPayServer.Services.Stores.StoreRepository;
namespace BTCPayServer.Controllers; namespace BTCPayServer.Controllers;
@@ -83,7 +84,9 @@ public partial class UIStoresController
var action = isExistingUser var action = isExistingUser
? isExistingStoreUser ? "updated" : "added" ? isExistingStoreUser ? "updated" : "added"
: "invited"; : "invited";
if (await _storeRepo.AddOrUpdateStoreUser(CurrentStore.Id, user.Id, roleId))
var res = await _storeRepo.AddOrUpdateStoreUser(CurrentStore.Id, user.Id, roleId);
if (res is AddOrUpdateStoreUserResult.Success)
{ {
TempData.SetStatusMessageModel(new StatusMessageModel TempData.SetStatusMessageModel(new StatusMessageModel
{ {
@@ -93,9 +96,11 @@ public partial class UIStoresController
}); });
return RedirectToAction(nameof(StoreUsers)); return RedirectToAction(nameof(StoreUsers));
} }
else
ModelState.AddModelError(nameof(vm.Email), $"The user could not be {action}"); {
return View(vm); ModelState.AddModelError(nameof(vm.Email), $"The user could not be {action}: {res.ToString()}");
return View(vm);
}
} }
[HttpPost("{storeId}/users/{userId}")] [HttpPost("{storeId}/users/{userId}")]
@@ -105,12 +110,16 @@ public partial class UIStoresController
var roleId = await _storeRepo.ResolveStoreRoleId(storeId, vm.Role); var roleId = await _storeRepo.ResolveStoreRoleId(storeId, vm.Role);
var storeUsers = await _storeRepo.GetStoreUsers(storeId); var storeUsers = await _storeRepo.GetStoreUsers(storeId);
var user = storeUsers.First(user => user.Id == userId); var user = storeUsers.First(user => user.Id == userId);
var isOwner = user.StoreRole.Id == StoreRoleId.Owner.Id;
var isLastOwner = isOwner && storeUsers.Count(u => u.StoreRole.Id == StoreRoleId.Owner.Id) == 1; var res = await _storeRepo.AddOrUpdateStoreUser(storeId, userId, roleId);
if (isLastOwner && roleId != StoreRoleId.Owner) if (res is AddOrUpdateStoreUserResult.Success)
TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["User {0} is the last owner. Their role cannot be changed.", user.Email].Value; {
else if (await _storeRepo.AddOrUpdateStoreUser(storeId, userId, roleId))
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The role of {0} has been changed to {1}.", user.Email, vm.Role].Value; TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The role of {0} has been changed to {1}.", user.Email, vm.Role].Value;
}
else
{
TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Changing the role of user {0} failed: {1}", user.Email, res.ToString()].Value;
}
return RedirectToAction(nameof(StoreUsers), new { storeId, userId }); return RedirectToAction(nameof(StoreUsers), new { storeId, userId });
} }

View File

@@ -7,6 +7,7 @@ using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events; using BTCPayServer.Events;
using BTCPayServer.Migrations;
using Dapper; using Dapper;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NBitcoin; using NBitcoin;
@@ -314,13 +315,34 @@ namespace BTCPayServer.Services.Stores
} }
} }
public async Task<bool> AddOrUpdateStoreUser(string storeId, string userId, StoreRoleId? roleId = null) public record AddOrUpdateStoreUserResult
{
public record Success : AddOrUpdateStoreUserResult;
public record InvalidRole : AddOrUpdateStoreUserResult
{
public override string ToString() => "The roleId doesn't exist";
}
public record LastOwner : AddOrUpdateStoreUserResult
{
public override string ToString() => "The user is the last owner. Their role cannot be changed.";
}
public record DuplicateRole(StoreRoleId RoleId) : AddOrUpdateStoreUserResult
{
public override string ToString() => $"The user already has the role {RoleId}.";
}
}
public async Task<AddOrUpdateStoreUserResult> AddOrUpdateStoreUser(string storeId, string userId, StoreRoleId? roleId = null)
{ {
ArgumentNullException.ThrowIfNull(storeId); ArgumentNullException.ThrowIfNull(storeId);
AssertStoreRoleIfNeeded(storeId, roleId); AssertStoreRoleIfNeeded(storeId, roleId);
roleId ??= await GetDefaultRole(); roleId ??= await GetDefaultRole();
var storeRole = await GetStoreRole(roleId);
if (storeRole is null)
return new AddOrUpdateStoreUserResult.InvalidRole();
await using var ctx = _ContextFactory.CreateContext(); await using var ctx = _ContextFactory.CreateContext();
var userStore = await ctx.UserStore.FindAsync(userId, storeId); var userStore = await ctx.UserStore.Include(store => store.StoreRole)
.FirstOrDefaultAsync(u => u.ApplicationUserId == userId && u.StoreDataId == storeId);
var added = false; var added = false;
if (userStore is null) if (userStore is null)
{ {
@@ -328,9 +350,15 @@ namespace BTCPayServer.Services.Stores
ctx.UserStore.Add(userStore); ctx.UserStore.Add(userStore);
added = true; added = true;
} }
// ensure the last owner doesn't get downgraded
else if (userStore.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings))
{
if (storeRole.Permissions.Contains(Policies.CanModifyStoreSettings) is false && !await EnsureRemainingOwner(ctx.UserStore, storeId, userId))
return new AddOrUpdateStoreUserResult.LastOwner();
}
if (userStore.StoreRoleId == roleId.Id) if (userStore.StoreRoleId == roleId.Id)
return false; return new AddOrUpdateStoreUserResult.DuplicateRole(roleId);
userStore.StoreRoleId = roleId.Id; userStore.StoreRoleId = roleId.Id;
try try
@@ -340,11 +368,11 @@ namespace BTCPayServer.Services.Stores
? new StoreUserEvent.Added(storeId, userId, userStore.StoreRoleId) ? new StoreUserEvent.Added(storeId, userId, userStore.StoreRoleId)
: new StoreUserEvent.Updated(storeId, userId, userStore.StoreRoleId); : new StoreUserEvent.Updated(storeId, userId, userStore.StoreRoleId);
_eventAggregator.Publish(evt); _eventAggregator.Publish(evt);
return true; return new AddOrUpdateStoreUserResult.Success();
} }
catch (DbUpdateException) catch (DbUpdateException)
{ {
return false; return new AddOrUpdateStoreUserResult.DuplicateRole(roleId);
} }
} }
@@ -357,11 +385,9 @@ namespace BTCPayServer.Services.Stores
public async Task<bool> RemoveStoreUser(string storeId, string userId) public async Task<bool> RemoveStoreUser(string storeId, string userId)
{ {
await using var ctx = _ContextFactory.CreateContext(); await using var ctx = _ContextFactory.CreateContext();
if (!await ctx.UserStore.Include(store => store.StoreRole).AnyAsync(store => if (!await EnsureRemainingOwner(ctx.UserStore, storeId, userId))
store.StoreDataId == storeId && store.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings) &&
userId != store.ApplicationUserId))
return false; return false;
var userStore = new UserStore() { StoreDataId = storeId, ApplicationUserId = userId }; var userStore = new UserStore { StoreDataId = storeId, ApplicationUserId = userId };
ctx.UserStore.Add(userStore); ctx.UserStore.Add(userStore);
ctx.Entry(userStore).State = EntityState.Deleted; ctx.Entry(userStore).State = EntityState.Deleted;
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
@@ -369,6 +395,13 @@ namespace BTCPayServer.Services.Stores
return true; return true;
} }
private async Task<bool> EnsureRemainingOwner(DbSet<UserStore> userStore, string storeId, string userId)
{
return await userStore.Include(store => store.StoreRole).AnyAsync(store =>
store.StoreDataId == storeId && store.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings) &&
store.ApplicationUserId != userId);
}
private async Task DeleteStoreIfOrphan(string storeId) private async Task DeleteStoreIfOrphan(string storeId)
{ {
await using var ctx = _ContextFactory.CreateContext(); await using var ctx = _ContextFactory.CreateContext();

View File

@@ -27,7 +27,7 @@
@if (!ViewContext.ModelState.IsValid) @if (!ViewContext.ModelState.IsValid)
{ {
<div asp-validation-summary="All"></div> <div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
} }
<form method="post" class="d-flex flex-wrap align-items-center gap-3" permission="@Policies.CanModifyStoreSettings"> <form method="post" class="d-flex flex-wrap align-items-center gap-3" permission="@Policies.CanModifyStoreSettings">