Store Custom Roles (#4940)

This commit is contained in:
Andrew Camilleri 2023-05-26 16:49:32 +02:00 committed by GitHub
parent 6b7fb55658
commit 783e4ccb35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 1798 additions and 316 deletions

View File

@ -6,7 +6,8 @@ using Microsoft.Extensions.Logging;
namespace BTCPayServer.Abstractions.TagHelpers;
[HtmlTargetElement(Attributes = nameof(Permission))]
[HtmlTargetElement(Attributes = "[permission]")]
[HtmlTargetElement(Attributes = "[not-permission]" )]
public class PermissionTagHelper : TagHelper
{
private readonly IAuthorizationService _authorizationService;
@ -21,16 +22,19 @@ public class PermissionTagHelper : TagHelper
}
public string Permission { get; set; }
public string NotPermission { get; set; }
public string PermissionResource { get; set; }
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
if (string.IsNullOrEmpty(Permission))
if (string.IsNullOrEmpty(Permission) && string.IsNullOrEmpty(NotPermission))
return;
if (_httpContextAccessor.HttpContext is null)
return;
var key = $"{Permission}_{PermissionResource}";
var expectedResult = !string.IsNullOrEmpty(Permission);
var key = $"{Permission??NotPermission}_{PermissionResource}";
if (!_httpContextAccessor.HttpContext.Items.TryGetValue(key, out var o) ||
o is not AuthorizationResult res)
{
@ -39,7 +43,7 @@ public class PermissionTagHelper : TagHelper
Permission);
_httpContextAccessor.HttpContext.Items.Add(key, res);
}
if (!res.Succeeded)
if (expectedResult != res.Succeeded)
{
output.SuppressOutput();
}

View File

@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
@ -11,5 +12,11 @@ namespace BTCPayServer.Client
var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/server/info"), token);
return await HandleResponse<ServerInfoData>(response);
}
public virtual async Task<List<RoleData>> GetServerRoles(CancellationToken token = default)
{
using var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/server/roles"), token);
return await HandleResponse<List<RoleData>>(response);
}
}
}

View File

@ -9,6 +9,13 @@ namespace BTCPayServer.Client
{
public partial class BTCPayServerClient
{
public virtual async Task<List<RoleData>> GetStoreRoles(string storeId,
CancellationToken token = default)
{
using var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/roles"), token);
return await HandleResponse<List<RoleData>>(response);
}
public virtual async Task<IEnumerable<StoreUserData>> GetStoreUsers(string storeId,
CancellationToken token = default)
{

View File

@ -1,3 +1,5 @@
using System.Collections.Generic;
namespace BTCPayServer.Client.Models
{
public class StoreData : StoreBaseData
@ -17,4 +19,12 @@ namespace BTCPayServer.Client.Models
public string Role { get; set; }
}
public class RoleData
{
public string Id { get; set; }
public List<string> Permissions { get; set; }
public string Role { get; set; }
public bool IsServerRole { get; set; }
}
}

View File

@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Linq.Expressions;
namespace BTCPayServer.Client
{
@ -134,7 +136,7 @@ namespace BTCPayServer.Client
{
static Permission()
{
Init();
PolicyMap = Init();
}
public static Permission Create(string policy, string scope = null)
@ -235,11 +237,13 @@ namespace BTCPayServer.Client
return subPolicies.Contains(subpolicy) || subPolicies.Any(s => ContainsPolicy(s, subpolicy));
}
private static Dictionary<string, HashSet<string>> PolicyMap = new();
public static ReadOnlyDictionary<string, HashSet<string>> PolicyMap { get; private set; }
private static void Init()
private static ReadOnlyDictionary<string, HashSet<string>> Init()
{
PolicyHasChild(Policies.CanModifyStoreSettings,
var policyMap = new Dictionary<string, HashSet<string>>();
PolicyHasChild(policyMap, Policies.CanModifyStoreSettings,
Policies.CanManageCustodianAccounts,
Policies.CanManagePullPayments,
Policies.CanModifyInvoices,
@ -248,25 +252,42 @@ namespace BTCPayServer.Client
Policies.CanModifyPaymentRequests,
Policies.CanUseLightningNodeInStore);
PolicyHasChild(Policies.CanManageUsers, Policies.CanCreateUser);
PolicyHasChild(Policies.CanManagePullPayments, Policies.CanCreatePullPayments);
PolicyHasChild(Policies.CanCreatePullPayments, Policies.CanCreateNonApprovedPullPayments);
PolicyHasChild(Policies.CanModifyPaymentRequests, Policies.CanViewPaymentRequests);
PolicyHasChild(Policies.CanModifyProfile, Policies.CanViewProfile);
PolicyHasChild(Policies.CanUseLightningNodeInStore, Policies.CanViewLightningInvoiceInStore, Policies.CanCreateLightningInvoiceInStore);
PolicyHasChild(Policies.CanManageNotificationsForUser, Policies.CanViewNotificationsForUser);
PolicyHasChild(Policies.CanModifyServerSettings,
PolicyHasChild(policyMap,Policies.CanManageUsers, Policies.CanCreateUser);
PolicyHasChild(policyMap,Policies.CanManagePullPayments, Policies.CanCreatePullPayments);
PolicyHasChild(policyMap,Policies.CanCreatePullPayments, Policies.CanCreateNonApprovedPullPayments);
PolicyHasChild(policyMap,Policies.CanModifyPaymentRequests, Policies.CanViewPaymentRequests);
PolicyHasChild(policyMap,Policies.CanModifyProfile, Policies.CanViewProfile);
PolicyHasChild(policyMap,Policies.CanUseLightningNodeInStore, Policies.CanViewLightningInvoiceInStore, Policies.CanCreateLightningInvoiceInStore);
PolicyHasChild(policyMap,Policies.CanManageNotificationsForUser, Policies.CanViewNotificationsForUser);
PolicyHasChild(policyMap,Policies.CanModifyServerSettings,
Policies.CanUseInternalLightningNode,
Policies.CanManageUsers);
PolicyHasChild(Policies.CanUseInternalLightningNode, Policies.CanCreateLightningInvoiceInternalNode, Policies.CanViewLightningInvoiceInternalNode);
PolicyHasChild(Policies.CanManageCustodianAccounts, Policies.CanViewCustodianAccounts);
PolicyHasChild(Policies.CanModifyInvoices, Policies.CanViewInvoices, Policies.CanCreateInvoice, Policies.CanCreateLightningInvoiceInStore);
PolicyHasChild(Policies.CanViewStoreSettings, Policies.CanViewInvoices, Policies.CanViewPaymentRequests);
PolicyHasChild(policyMap, Policies.CanUseInternalLightningNode, Policies.CanCreateLightningInvoiceInternalNode, Policies.CanViewLightningInvoiceInternalNode);
PolicyHasChild(policyMap, Policies.CanManageCustodianAccounts, Policies.CanViewCustodianAccounts);
PolicyHasChild(policyMap, Policies.CanModifyInvoices, Policies.CanViewInvoices, Policies.CanCreateInvoice, Policies.CanCreateLightningInvoiceInStore);
PolicyHasChild(policyMap, Policies.CanViewStoreSettings, Policies.CanViewInvoices, Policies.CanViewPaymentRequests);
var missingPolicies = Policies.AllPolicies.ToHashSet();
//recurse through the tree to see which policies are not included in the tree
foreach (var policy in policyMap)
{
missingPolicies.Remove(policy.Key);
foreach (var subPolicy in policy.Value)
{
missingPolicies.Remove(subPolicy);
}
}
foreach (var missingPolicy in missingPolicies)
{
policyMap.Add(missingPolicy, new HashSet<string>());
}
return new ReadOnlyDictionary<string, HashSet<string>>(policyMap);
}
private static void PolicyHasChild(string policy, params string[] subPolicies)
private static void PolicyHasChild(Dictionary<string, HashSet<string>>policyMap, string policy, params string[] subPolicies)
{
if (PolicyMap.TryGetValue(policy, out var existingSubPolicies))
if (policyMap.TryGetValue(policy, out var existingSubPolicies))
{
foreach (string subPolicy in subPolicies)
{
@ -275,7 +296,7 @@ namespace BTCPayServer.Client
}
else
{
PolicyMap.Add(policy, subPolicies.ToHashSet());
policyMap.Add(policy, subPolicies.ToHashSet());
}
}

View File

@ -64,6 +64,7 @@ namespace BTCPayServer.Data
public DbSet<U2FDevice> U2FDevices { get; set; }
public DbSet<Fido2Credential> Fido2Credentials { get; set; }
public DbSet<UserStore> UserStore { get; set; }
public DbSet<StoreRole> StoreRoles { get; set; }
[Obsolete]
public DbSet<WalletData> Wallets { get; set; }
public DbSet<WalletObjectData> WalletObjects { get; set; }
@ -129,6 +130,7 @@ namespace BTCPayServer.Data
PayoutProcessorData.OnModelCreating(builder, Database);
WebhookData.OnModelCreating(builder, Database);
FormData.OnModelCreating(builder, Database);
StoreRole.OnModelCreating(builder, Database);
if (Database.IsSqlite() && !_designTime)

View File

@ -1,13 +1,10 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using PayoutProcessorData = BTCPayServer.Data.PayoutProcessorData;
namespace BTCPayServer.Data
{
@ -37,8 +34,6 @@ namespace BTCPayServer.Data
public byte[] StoreCertificate { get; set; }
[NotMapped] public string Role { get; set; }
public string StoreBlob { get; set; }
[Obsolete("Use GetDefaultPaymentId instead")]
@ -52,6 +47,7 @@ namespace BTCPayServer.Data
public IEnumerable<CustodianAccountData> CustodianAccounts { get; set; }
public IEnumerable<StoreSettingData> Settings { get; set; }
public IEnumerable<FormData> Forms { get; set; }
public IEnumerable<StoreRole> StoreRoles { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{

View File

@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
namespace BTCPayServer.Data;
public class StoreRole
{
public string Id { get; set; }
public string StoreDataId { get; set; }
public string Role { get; set; }
public List<string> Permissions { get; set; }
public List<UserStore> Users { get; set; }
public StoreData StoreData { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
builder.Entity<StoreRole>(entity =>
{
entity.HasOne(e => e.StoreData)
.WithMany(s => s.StoreRoles)
.HasForeignKey(e => e.StoreDataId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
entity.HasIndex(entity => new {entity.StoreDataId, entity.Role}).IsUnique();
});
if (!databaseFacade.IsNpgsql())
{
builder.Entity<StoreRole>()
.Property(o => o.Permissions)
.HasConversion(
v => JsonConvert.SerializeObject(v),
v => JsonConvert.DeserializeObject<List<string>>(v)?? new List<string>(),
new ValueComparer<List<string>>(
(c1, c2) => c1 ==c2 || c1 != null && c2 != null && c1.SequenceEqual(c2),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => c.ToList()));
}
}
}

View File

@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Data
@ -9,7 +10,10 @@ namespace BTCPayServer.Data
public string StoreDataId { get; set; }
public StoreData StoreData { get; set; }
public string Role { get; set; }
[Column("Role")]
public string StoreRoleId { get; set; }
public StoreRole StoreRole { get; set; }
internal static void OnModelCreating(ModelBuilder builder)
@ -32,6 +36,10 @@ namespace BTCPayServer.Data
.HasOne(pt => pt.StoreData)
.WithMany(t => t.UserStores)
.HasForeignKey(pt => pt.StoreDataId);
builder.Entity<UserStore>().HasOne(e => e.StoreRole)
.WithMany(role => role.Users)
.HasForeignKey(e => e.StoreRoleId);
}
}
}

View File

@ -0,0 +1,106 @@
using System;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NBitcoin;
using Newtonsoft.Json;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20230504125505_StoreRoles")]
public partial class StoreRoles : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
var permissionsType = migrationBuilder.IsNpgsql() ? "TEXT[]" : "TEXT";
migrationBuilder.CreateTable(
name: "StoreRoles",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
StoreDataId = table.Column<string>(type: "TEXT", nullable: true),
Role = table.Column<string>(type: "TEXT", nullable: false),
Permissions = table.Column<string>(type: permissionsType, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_StoreRoles", x => x.Id);
table.ForeignKey(
name: "FK_StoreRoles_Stores_StoreDataId",
column: x => x.StoreDataId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_StoreRoles_StoreDataId_Role",
table: "StoreRoles",
columns: new[] { "StoreDataId", "Role" },
unique: true);
object GetPermissionsData(string[] permissions)
{
if (migrationBuilder.IsNpgsql())
return permissions;
return JsonConvert.SerializeObject(permissions);
}
migrationBuilder.InsertData(
"StoreRoles",
columns: new[] { "Id", "Role", "Permissions" },
columnTypes: new[] { "TEXT", "TEXT", permissionsType },
values: new object[,]
{
{
"Owner", "Owner", GetPermissionsData(new[]
{
"btcpay.store.canmodifystoresettings",
"btcpay.store.cantradecustodianaccount",
"btcpay.store.canwithdrawfromcustodianaccount",
"btcpay.store.candeposittocustodianaccount"
})
},
{
"Guest", "Guest", GetPermissionsData(new[]
{
"btcpay.store.canviewstoresettings",
"btcpay.store.canmodifyinvoices",
"btcpay.store.canviewcustodianaccounts",
"btcpay.store.candeposittocustodianaccount"
})
}
});
if (this.SupportAddForeignKey(migrationBuilder.ActiveProvider))
{
migrationBuilder.AddForeignKey(
name: "FK_UserStore_StoreRoles_Role",
table: "UserStore",
column: "Role",
principalTable: "StoreRoles",
principalColumn: "Id");
}
}
protected override void Down(MigrationBuilder migrationBuilder)
{
if (this.SupportDropForeignKey(migrationBuilder.ActiveProvider))
{
migrationBuilder.DropForeignKey(
name: "FK_UserStore_StoreRoles_Role",
table: "UserStore");
}
migrationBuilder.DropTable(
name: "StoreRoles");
}
}
}

View File

@ -214,56 +214,6 @@ namespace BTCPayServer.Migrations
b.ToTable("CustodianAccount");
});
modelBuilder.Entity("BTCPayServer.Data.FormData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Config")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<bool>("Public")
.HasColumnType("INTEGER");
b.Property<string>("StoreId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("StoreId");
b.ToTable("Forms");
});
modelBuilder.Entity("BTCPayServer.Data.PayoutProcessorData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
b.Property<string>("PaymentMethod")
.HasColumnType("TEXT");
b.Property<string>("Processor")
.HasColumnType("TEXT");
b.Property<string>("StoreId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("StoreId");
b.ToTable("PayoutProcessors");
});
modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b =>
{
b.Property<string>("Id")
@ -292,6 +242,31 @@ namespace BTCPayServer.Migrations
b.ToTable("Fido2Credentials");
});
modelBuilder.Entity("BTCPayServer.Data.FormData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Config")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<bool>("Public")
.HasColumnType("INTEGER");
b.Property<string>("StoreId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("StoreId");
b.ToTable("Forms");
});
modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b =>
{
b.Property<string>("Id")
@ -655,6 +630,34 @@ namespace BTCPayServer.Migrations
b.ToTable("Payouts");
});
modelBuilder.Entity("BTCPayServer.Data.PayoutProcessorData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
b.Property<string>("Blob2")
.HasColumnType("TEXT");
b.Property<string>("PaymentMethod")
.HasColumnType("TEXT");
b.Property<string>("Processor")
.HasColumnType("TEXT");
b.Property<string>("StoreId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("StoreId");
b.ToTable("PayoutProcessors");
});
modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b =>
{
b.Property<string>("Id")
@ -802,6 +805,28 @@ namespace BTCPayServer.Migrations
b.ToTable("Files");
});
modelBuilder.Entity("BTCPayServer.Data.StoreRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Permissions")
.HasColumnType("TEXT");
b.Property<string>("Role")
.HasColumnType("TEXT");
b.Property<string>("StoreDataId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("StoreDataId", "Role")
.IsUnique();
b.ToTable("StoreRoles");
});
modelBuilder.Entity("BTCPayServer.Data.StoreSettingData", b =>
{
b.Property<string>("StoreId")
@ -878,13 +903,16 @@ namespace BTCPayServer.Migrations
b.Property<string>("StoreDataId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.HasColumnType("TEXT");
b.Property<string>("StoreRoleId")
.HasColumnType("TEXT")
.HasColumnName("Role");
b.HasKey("ApplicationUserId", "StoreDataId");
b.HasIndex("StoreDataId");
b.HasIndex("StoreRoleId");
b.ToTable("UserStore");
});
@ -1188,26 +1216,6 @@ namespace BTCPayServer.Migrations
b.Navigation("StoreData");
});
modelBuilder.Entity("BTCPayServer.Data.FormData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "Store")
.WithMany("Forms")
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Store");
});
modelBuilder.Entity("BTCPayServer.Data.PayoutProcessorData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "Store")
.WithMany("PayoutProcessors")
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Store");
});
modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b =>
{
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
@ -1218,6 +1226,16 @@ namespace BTCPayServer.Migrations
b.Navigation("ApplicationUser");
});
modelBuilder.Entity("BTCPayServer.Data.FormData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "Store")
.WithMany("Forms")
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Store");
});
modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
@ -1343,6 +1361,16 @@ namespace BTCPayServer.Migrations
b.Navigation("StoreData");
});
modelBuilder.Entity("BTCPayServer.Data.PayoutProcessorData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "Store")
.WithMany("PayoutProcessors")
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Store");
});
modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b =>
{
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
@ -1392,6 +1420,16 @@ namespace BTCPayServer.Migrations
b.Navigation("ApplicationUser");
});
modelBuilder.Entity("BTCPayServer.Data.StoreRole", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
.WithMany("StoreRoles")
.HasForeignKey("StoreDataId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("StoreData");
});
modelBuilder.Entity("BTCPayServer.Data.StoreSettingData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "Store")
@ -1446,9 +1484,15 @@ namespace BTCPayServer.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("BTCPayServer.Data.StoreRole", "StoreRole")
.WithMany("Users")
.HasForeignKey("StoreRoleId");
b.Navigation("ApplicationUser");
b.Navigation("StoreData");
b.Navigation("StoreRole");
});
modelBuilder.Entity("BTCPayServer.Data.WalletObjectLinkData", b =>
@ -1606,9 +1650,16 @@ namespace BTCPayServer.Migrations
b.Navigation("Settings");
b.Navigation("StoreRoles");
b.Navigation("UserStores");
});
modelBuilder.Entity("BTCPayServer.Data.StoreRole", b =>
{
b.Navigation("Users");
});
modelBuilder.Entity("BTCPayServer.Data.WalletData", b =>
{
b.Navigation("WalletTransactions");

View File

@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
@ -55,7 +56,7 @@ namespace BTCPayServer.Tests
Assert.Empty(appList2.Apps);
Assert.Equal("test", appList.Apps[0].AppName);
Assert.Equal(apps.CreatedAppId, appList.Apps[0].Id);
Assert.True(appList.Apps[0].IsOwner);
Assert.True(appList.Apps[0].Role.ToPermissionSet(appList.Apps[0].StoreId).Contains(Policies.CanModifyStoreSettings, appList.Apps[0].StoreId));
Assert.Equal(user.StoreId, appList.Apps[0].StoreId);
Assert.IsType<NotFoundResult>(apps2.DeleteApp(appList.Apps[0].Id));
Assert.IsType<ViewResult>(apps.DeleteApp(appList.Apps[0].Id));

View File

@ -21,6 +21,7 @@ using BTCPayServer.Services;
using BTCPayServer.Services.Custodian.Client.MockCustodian;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
@ -228,7 +229,7 @@ namespace BTCPayServer.Tests
await Assert.ThrowsAsync<GreenfieldAPIException>(() => newUserClient.GetInvoices(store.Id));
// if user is a guest or owner, then it should be ok
await unrestricted.AddStoreUser(store.Id, new StoreUserData() { UserId = newUser.Id, Role = "Guest" });
await unrestricted.AddStoreUser(store.Id, new StoreUserData() { UserId = newUser.Id});
await newUserClient.GetInvoices(store.Id);
}
@ -1319,7 +1320,8 @@ namespace BTCPayServer.Tests
// We strip the user's Owner right, so the key should not work
using var ctx = tester.PayTester.GetService<Data.ApplicationDbContextFactory>().CreateContext();
var storeEntity = await ctx.UserStore.SingleAsync(u => u.ApplicationUserId == user.UserId && u.StoreDataId == newStore.Id);
storeEntity.Role = "Guest";
var roleId = (await tester.PayTester.GetService<StoreRepository>().GetStoreRoles(null)).Single(r => r.Role == "Guest").Id;
storeEntity.StoreRoleId = roleId;
await ctx.SaveChangesAsync();
await AssertHttpError(403, async () => await client.UpdateStore(newStore.Id, new UpdateStoreRequest() { Name = "B" }));
@ -3365,11 +3367,16 @@ namespace BTCPayServer.Tests
var client = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings);
var roles = await client.GetServerRoles();
Assert.Equal(2,roles.Count);
#pragma warning disable CS0618
var ownerRole = roles.Single(data => data.Role == StoreRoles.Owner);
var guestRole = roles.Single(data => data.Role == StoreRoles.Guest);
#pragma warning restore CS0618
var users = await client.GetStoreUsers(user.StoreId);
var storeuser = Assert.Single(users);
Assert.Equal(user.UserId, storeuser.UserId);
Assert.Equal(StoreRoles.Owner, storeuser.Role);
Assert.Equal(ownerRole.Id, storeuser.Role);
var user2 = tester.NewAccount();
await user2.GrantAccessAsync(false);
@ -3380,7 +3387,7 @@ namespace BTCPayServer.Tests
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.RemoveStoreUser(user.StoreId, user.UserId));
await client.AddStoreUser(user.StoreId, new StoreUserData() { Role = StoreRoles.Guest, UserId = user2.UserId });
await client.AddStoreUser(user.StoreId, new StoreUserData() { Role = guestRole.Id, UserId = user2.UserId });
//test no access to api when only a guest
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.GetStoreUsers(user.StoreId));
@ -3394,10 +3401,10 @@ namespace BTCPayServer.Tests
await user2Client.GetStore(user.StoreId));
await client.AddStoreUser(user.StoreId, new StoreUserData() { Role = StoreRoles.Owner, UserId = user2.UserId });
await client.AddStoreUser(user.StoreId, new StoreUserData() { Role = ownerRole.Id, UserId = user2.UserId });
await AssertAPIError("duplicate-store-user-role", async () =>
await client.AddStoreUser(user.StoreId,
new StoreUserData() { Role = StoreRoles.Owner, UserId = user2.UserId }));
new StoreUserData() { Role = ownerRole.Id, UserId = user2.UserId }));
await user2Client.RemoveStoreUser(user.StoreId, user.UserId);

View File

@ -1,4 +1,5 @@
using System;
using System.Buffers;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
@ -1741,11 +1742,12 @@ namespace BTCPayServer.Tests
{
s.Driver.Navigate().Refresh();
Assert.Contains("transaction-label", s.Driver.PageSource);
var labels = s.Driver.FindElements(By.CssSelector("#WalletTransactionsList tr:first-child div.transaction-label"));
Assert.Equal(2, labels.Count);
Assert.Contains(labels, element => element.Text == "payout");
Assert.Contains(labels, element => element.Text == "pull-payment");
});
var labels = s.Driver.FindElements(By.CssSelector("#WalletTransactionsList tr:first-child div.transaction-label"));
Assert.Equal(2, labels.Count);
Assert.Contains(labels, element => element.Text == "payout");
Assert.Contains(labels, element => element.Text == "pull-payment");
s.GoToStore(s.StoreId, StoreNavPages.Payouts);
s.Driver.FindElement(By.Id($"{PayoutState.InProgress}-view")).Click();
@ -2468,6 +2470,145 @@ retry:
Assert.StartsWith(s.ServerUri.ToString(), s.Driver.Url);
});
}
[Fact]
[Trait("Selenium", "Selenium")]
public async Task CanUseRoleManager()
{
using var s = CreateSeleniumTester(newDb: true);
await s.StartAsync();
var user = s.RegisterNewUser(true);
s.GoToServer(ServerNavPages.Roles);
var existingServerRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
Assert.Equal(3, existingServerRoles.Count);
IWebElement ownerRow = null;
IWebElement guestRow = null;
foreach (var roleItem in existingServerRoles)
{
if (roleItem.Text.Contains("owner", StringComparison.InvariantCultureIgnoreCase))
{
ownerRow = roleItem;
}
else if (roleItem.Text.Contains("guest", StringComparison.InvariantCultureIgnoreCase))
{
guestRow = roleItem;
}
}
Assert.NotNull(ownerRow);
Assert.NotNull(guestRow);
var ownerBadges = ownerRow.FindElements(By.CssSelector(".badge"));
Assert.Contains(ownerBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
Assert.Contains(ownerBadges, element => element.Text.Equals("Server-wide", StringComparison.InvariantCultureIgnoreCase));
var guestBadges = guestRow.FindElements(By.CssSelector(".badge"));
Assert.DoesNotContain(guestBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
Assert.Contains(guestBadges, element => element.Text.Equals("Server-wide", StringComparison.InvariantCultureIgnoreCase));
guestRow.FindElement(By.Id("SetDefault")).Click();
s.FindAlertMessage();
existingServerRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
foreach (var roleItem in existingServerRoles)
{
if (roleItem.Text.Contains("owner", StringComparison.InvariantCultureIgnoreCase))
{
ownerRow = roleItem;
}
else if (roleItem.Text.Contains("guest", StringComparison.InvariantCultureIgnoreCase))
{
guestRow = roleItem;
}
}
guestBadges = guestRow.FindElements(By.CssSelector(".badge"));
Assert.Contains(guestBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
ownerBadges = ownerRow.FindElements(By.CssSelector(".badge"));
Assert.DoesNotContain(ownerBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
ownerRow.FindElement(By.Id("SetDefault")).Click();
s.FindAlertMessage();
s.CreateNewStore();
s.GoToStore(StoreNavPages.Roles);
var existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
Assert.Equal(3, existingStoreRoles.Count);
Assert.Equal(2, existingStoreRoles.Count(element => element.Text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
foreach (var roleItem in existingStoreRoles)
{
if (roleItem.Text.Contains("owner", StringComparison.InvariantCultureIgnoreCase))
{
ownerRow = roleItem;
break;
}
}
ownerRow.FindElement(By.LinkText("Remove")).Click();
Assert.DoesNotContain("ConfirmContinue", s.Driver.PageSource);
s.Driver.Navigate().Back();
existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
foreach (var roleItem in existingStoreRoles)
{
if (roleItem.Text.Contains("guest", StringComparison.InvariantCultureIgnoreCase))
{
guestRow = roleItem;
break;
}
}
guestRow.FindElement(By.LinkText("Remove")).Click();
s.Driver.FindElement(By.Id("ConfirmContinue")).Click();
s.FindAlertMessage();
s.GoToStore(StoreNavPages.Roles);
s.Driver.FindElement(By.Id("CreateRole")).Click();
Assert.Contains("Create role", s.Driver.PageSource);
s.Driver.FindElement(By.Id("Save")).Click();
s.Driver.FindElement(By.Id("Role")).SendKeys("store role");
s.Driver.FindElement(By.Id("Save")).Click();
s.FindAlertMessage();
existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
foreach (var roleItem in existingStoreRoles)
{
if (roleItem.Text.Contains("store role", StringComparison.InvariantCultureIgnoreCase))
{
guestRow = roleItem;
break;
}
}
guestBadges = guestRow.FindElements(By.CssSelector(".badge"));
Assert.DoesNotContain(guestBadges, element => element.Text.Equals("server-wide", StringComparison.InvariantCultureIgnoreCase));
s.GoToStore(StoreNavPages.Users);
var options = s.Driver.FindElements(By.CssSelector("#Role option"));
Assert.Equal(2, options.Count);
Assert.Contains(options, element => element.Text.Equals("store role", StringComparison.InvariantCultureIgnoreCase));
s.CreateNewStore();
s.GoToStore(StoreNavPages.Roles);
existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
Assert.Equal(2, existingStoreRoles.Count);
Assert.Equal(1, existingStoreRoles.Count(element => element.Text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
Assert.Equal(0, existingStoreRoles.Count(element => element.Text.Contains("store role", StringComparison.InvariantCultureIgnoreCase)));
s.GoToStore(StoreNavPages.Users);
options = s.Driver.FindElements(By.CssSelector("#Role option"));
Assert.Single(options);
Assert.DoesNotContain(options, element => element.Text.Equals("store role", StringComparison.InvariantCultureIgnoreCase));
s.GoToStore(StoreNavPages.Roles);
s.Driver.FindElement(By.Id("CreateRole")).Click();
s.Driver.FindElement(By.Id("Role")).SendKeys("Malice");
s.Driver.ExecuteJavaScript($"document.getElementById('Policies')['{Policies.CanModifyServerSettings}']=new Option('{Policies.CanModifyServerSettings}', '{Policies.CanModifyServerSettings}', true,true);");
s.Driver.FindElement(By.Id("Save")).Click();
s.FindAlertMessage();
Assert.Contains("Malice",s.Driver.PageSource);
Assert.DoesNotContain(Policies.CanModifyServerSettings,s.Driver.PageSource);
}
private static void CanBrowseContent(SeleniumTester s)
{

View File

@ -470,7 +470,10 @@ namespace BTCPayServer.Tests
var req = await _server.GetNextRequest(cancellation);
var bytes = await req.Request.Body.ReadBytesAsync((int)req.Request.Headers.ContentLength);
var callback = Encoding.UTF8.GetString(bytes);
_webhookEvents.Add(JsonConvert.DeserializeObject<WebhookInvoiceEvent>(callback));
lock (_webhookEvents)
{
_webhookEvents.Add(JsonConvert.DeserializeObject<WebhookInvoiceEvent>(callback));
}
req.Response.StatusCode = 200;
_server.Done();
}
@ -487,18 +490,21 @@ namespace BTCPayServer.Tests
{
int retry = 0;
retry:
foreach (var evt in WebhookEvents)
lock (WebhookEvents)
{
if (evt.Type == eventType)
foreach (var evt in WebhookEvents)
{
var typedEvt = evt.ReadAs<TEvent>();
try
{
assert(typedEvt);
return typedEvt;
}
catch (XunitException)
if (evt.Type == eventType)
{
var typedEvt = evt.ReadAs<TEvent>();
try
{
assert(typedEvt);
return typedEvt;
}
catch (XunitException)
{
}
}
}
}
@ -540,12 +546,12 @@ retry:
public async Task AddGuest(string userId)
{
var repo = this.parent.PayTester.GetService<StoreRepository>();
await repo.AddStoreUser(StoreId, userId, "Guest");
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Guest);
}
public async Task AddOwner(string userId)
{
var repo = this.parent.PayTester.GetService<StoreRepository>();
await repo.AddStoreUser(StoreId, userId, "Owner");
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Owner);
}
}
}

View File

@ -1962,7 +1962,8 @@ namespace BTCPayServer.Tests
Assert.Empty(appList2.Apps);
Assert.Equal("test", appList.Apps[0].AppName);
Assert.Equal(apps.CreatedAppId, appList.Apps[0].Id);
Assert.True(app.IsOwner);
Assert.True(app.Role.ToPermissionSet(app.StoreId).Contains(Policies.CanModifyStoreSettings, app.StoreId));
Assert.Equal(user.StoreId, appList.Apps[0].StoreId);
Assert.IsType<NotFoundResult>(apps2.DeleteApp(appList.Apps[0].Id));
Assert.IsType<ViewResult>(apps.DeleteApp(appList.Apps[0].Id));

View File

@ -72,7 +72,6 @@ namespace BTCPayServer.Components.MainNav
vm.Apps = apps.Select(a => new StoreApp
{
Id = a.Id,
IsOwner = a.IsOwner,
AppName = a.AppName,
AppType = a.AppType
}).ToList();

View File

@ -20,6 +20,5 @@ namespace BTCPayServer.Components.MainNav
public string Id { get; set; }
public string AppName { get; set; }
public string AppType { get; set; }
public bool IsOwner { get; set; }
}
}

View File

@ -1,6 +1,7 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.Contracts
@using BTCPayServer.Client
@using BTCPayServer.Services
@inject SignInManager<ApplicationUser> SignInManager
@inject BTCPayServerEnvironment Env
@ -29,13 +30,11 @@
{
<a asp-controller="UIHome" asp-action="Index" id="StoreSelectorHome" class="navbar-brand py-2">@{await LogoContent();}</a>
}
else if (Model.CurrentStoreIsOwner)
{
<a asp-controller="UIStores" asp-action="Dashboard" asp-route-storeId="@Model.CurrentStoreId" id="StoreSelectorHome" class="navbar-brand py-2">@{await LogoContent();}</a>
}
else
{
<a asp-controller="UIInvoice" asp-action="ListInvoices" asp-route-storeId="@Model.CurrentStoreId" id="StoreSelectorHome" class="navbar-brand py-2">@{await LogoContent();}</a>
<a asp-controller="UIStores" asp-action="Dashboard" permission="@Policies.CanModifyStoreSettings" asp-route-storeId="@Model.CurrentStoreId" id="StoreSelectorHome" class="navbar-brand py-2">@{await LogoContent();}</a>
<a asp-controller="UIInvoice" asp-action="ListInvoices" not-permission="@Policies.CanModifyStoreSettings" asp-route-storeId="@Model.CurrentStoreId" id="StoreSelectorHome" class="navbar-brand py-2">@{await LogoContent();}</a>
}
@if (Model.Options.Any())
{

View File

@ -43,7 +43,6 @@ namespace BTCPayServer.Components.StoreSelector
Text = store.StoreName,
Value = store.Id,
Selected = store.Id == currentStore?.Id,
IsOwner = store.Role == StoreRoles.Owner,
WalletId = walletId
};
})
@ -57,7 +56,6 @@ namespace BTCPayServer.Components.StoreSelector
Options = options,
CurrentStoreId = currentStore?.Id,
CurrentDisplayName = currentStore?.StoreName,
CurrentStoreIsOwner = currentStore?.Role == StoreRoles.Owner,
CurrentStoreLogoFileId = blob?.LogoFileId
};

View File

@ -8,7 +8,6 @@ namespace BTCPayServer.Components.StoreSelector
public string CurrentStoreId { get; set; }
public string CurrentStoreLogoFileId { get; set; }
public string CurrentDisplayName { get; set; }
public bool CurrentStoreIsOwner { get; set; }
}
public class StoreSelectorOption

View File

@ -0,0 +1,42 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers.Greenfield;
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldServerRolesController : ControllerBase
{
private readonly StoreRepository _storeRepository;
public GreenfieldServerRolesController(StoreRepository storeRepository)
{
_storeRepository = storeRepository;
}
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/server/roles")]
public async Task<IActionResult> GetServerRoles()
{
return Ok(FromModel(await _storeRepository.GetStoreRoles(null, false, false)));
}
private List<RoleData> FromModel(StoreRepository.StoreRole[] data)
{
return data.Select(r => new RoleData() {Role = r.Role, Id = r.Id, Permissions = r.Permissions, IsServerRole = true}).ToList();
}
private IActionResult StoreNotFound()
{
return this.CreateAPIError(404, "store-not-found", "The store was not found");
}
}

View File

@ -0,0 +1,48 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldStoreRolesController : ControllerBase
{
private readonly StoreRepository _storeRepository;
public GreenfieldStoreRolesController(StoreRepository storeRepository)
{
_storeRepository = storeRepository;
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/roles")]
public async Task<IActionResult> GetStoreRoles(string storeId)
{
var store = HttpContext.GetStoreData();
return store == null
? StoreNotFound()
: Ok(FromModel(await _storeRepository.GetStoreRoles(storeId, false, false)));
}
private List<RoleData> FromModel(StoreRepository.StoreRole[] data)
{
return data.Select(r => new RoleData() {Role = r.Role, Id = r.Id, Permissions = r.Permissions, IsServerRole = r.IsServerRole}).ToList();
}
private IActionResult StoreNotFound()
{
return this.CreateAPIError(404, "store-not-found", "The store was not found");
}
}
}

View File

@ -63,8 +63,19 @@ namespace BTCPayServer.Controllers.Greenfield
{
return StoreNotFound();
}
//we do not need to validate the role string as any value other than `StoreRoles.Owner` is currently treated like a guest
if (await _storeRepository.AddStoreUser(storeId, request.UserId, request.Role))
StoreRoleId roleId = null;
if (request.Role is not null)
{
roleId = await _storeRepository.ResolveStoreRoleId(storeId, request.Role);
if (roleId is null)
ModelState.AddModelError(nameof(request.Role), "The role id provided does not exist");
}
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
if (await _storeRepository.AddStoreUser(storeId, request.UserId, roleId))
{
return Ok();
}
@ -74,7 +85,7 @@ namespace BTCPayServer.Controllers.Greenfield
private IEnumerable<StoreUserData> FromModel(Data.StoreData data)
{
return data.UserStores.Select(store => new StoreUserData() { UserId = store.ApplicationUserId, Role = store.Role });
return data.UserStores.Select(store => new StoreUserData() { UserId = store.ApplicationUserId, Role = store.StoreRoleId });
}
private IActionResult StoreNotFound()
{

View File

@ -1319,5 +1319,27 @@ namespace BTCPayServer.Controllers.Greenfield
{
return GetFromActionResult<CrowdfundAppData>(await GetController<GreenfieldAppsController>().GetCrowdfundApp(appId));
}
public override async Task<PullPaymentData> RefundInvoice(string storeId, string invoiceId, RefundInvoiceRequest request, CancellationToken token = default)
{
return GetFromActionResult<PullPaymentData>(await GetController<GreenfieldInvoiceController>().RefundInvoice(storeId, invoiceId, request, token));
}
public override async Task RevokeAPIKey(string userId, string apikey, CancellationToken token = default)
{
HandleActionResult(await GetController<GreenfieldApiKeysController>().RevokeAPIKey(userId, apikey));
}
public override async Task<ApiKeyData> CreateAPIKey(string userId, CreateApiKeyRequest request, CancellationToken token = default)
{
return GetFromActionResult<ApiKeyData>(await GetController<GreenfieldApiKeysController>().CreateUserAPIKey(userId, request));
}
public override async Task<List<RoleData>> GetServerRoles(CancellationToken token = default)
{
return GetFromActionResult<List<RoleData>>(await GetController<GreenfieldServerRolesController>().GetServerRoles());
}
public override async Task<List<RoleData>> GetStoreRoles(string storeId, CancellationToken token = default)
{
return GetFromActionResult<List<RoleData>>(await GetController<GreenfieldStoreRolesController>().GetStoreRoles(storeId));
}
}
}

View File

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
@ -9,30 +8,18 @@ using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Components.StoreSelector;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.HostedServices;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Stores;
using ExchangeSharp;
using Google.Apis.Auth.OAuth2;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.Payment;
using NBitpayClient;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@ -92,13 +79,13 @@ namespace BTCPayServer.Controllers
var store = await _storeRepository.FindStore(storeId, userId);
if (store != null)
{
return RedirectToStore(store);
return RedirectToStore(userId, store);
}
}
var stores = await _storeRepository.GetStoresByUserId(userId);
return stores.Any()
? RedirectToStore(stores.First())
? RedirectToStore(userId, stores.First())
: RedirectToAction(nameof(UIUserStoresController.CreateStore), "UIUserStores");
}
@ -211,9 +198,9 @@ namespace BTCPayServer.Controllers
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
public RedirectToActionResult RedirectToStore(StoreData store)
public RedirectToActionResult RedirectToStore(string userId, StoreData store)
{
return store.HasPermission(Policies.CanModifyStoreSettings)
return store.HasPermission(userId, Policies.CanModifyStoreSettings)
? RedirectToAction("Dashboard", "UIStores", new { storeId = store.Id })
: RedirectToAction("ListInvoices", "UIInvoice", new { storeId = store.Id });
}

View File

@ -638,7 +638,7 @@ namespace BTCPayServer.Controllers
}
if (explorer is null)
return NotSupported("This feature is only available to BTC wallets");
if (this.GetCurrentStore().Role != StoreRoles.Owner)
if (!GetCurrentStore().HasPermission(GetUserId(), Policies.CanModifyStoreSettings))
return Forbid();
var derivationScheme = (this.GetCurrentStore().GetDerivationSchemeSettings(_NetworkProvider, network.CryptoCode))?.AccountDerivation;

View File

@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Amazon.S3.Transfer;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers
{
public partial class UIServerController
{
[Route("server/roles")]
public async Task<IActionResult> ListRoles(
[FromServices] StoreRepository storeRepository,
RolesViewModel model,
string sortOrder = null
)
{
model ??= new RolesViewModel();
model.DefaultRole = (await storeRepository.GetDefaultRole()).Role;
var roles = await storeRepository.GetStoreRoles(null);
if (sortOrder != null)
{
switch (sortOrder)
{
case "desc":
ViewData["NextRoleSortOrder"] = "asc";
roles = roles.OrderByDescending(user => user.Role).ToArray();
break;
case "asc":
roles = roles.OrderBy(user => user.Role).ToArray();
ViewData["NextRoleSortOrder"] = "desc";
break;
}
}
model.Roles = roles.Skip(model.Skip).Take(model.Count).ToList();
return View(model);
}
[HttpGet("server/roles/{role}")]
public async Task<IActionResult> CreateOrEditRole(
[FromServices] StoreRepository storeRepository,
string role)
{
if (role == "create")
{
ModelState.Remove(nameof(role));
return View(new UpdateRoleViewModel());
}
else
{
var roleData = await storeRepository.GetStoreRole(new StoreRoleId(role));
if (roleData == null)
return NotFound();
return View(new UpdateRoleViewModel()
{
Policies = roleData.Permissions,
Role = roleData.Role
});
}
}
[HttpPost("server/roles/{role}")]
public async Task<IActionResult> CreateOrEditRole(
[FromServices] StoreRepository storeRepository,
[FromRoute] string role, UpdateRoleViewModel viewModel)
{
string successMessage = null;
if (role == "create")
{
successMessage = "Role created";
role = viewModel.Role;
}
else
{
successMessage = "Role updated";
var storeRole = await storeRepository.GetStoreRole(new StoreRoleId(role));
if (storeRole == null)
return NotFound();
}
if (!ModelState.IsValid)
{
return View(viewModel);
}
var r = await storeRepository.AddOrUpdateStoreRole(new StoreRoleId(role), viewModel.Policies);
if (r is null)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "Role could not be updated"
});
return View(viewModel);
}
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = successMessage
});
return RedirectToAction(nameof(ListRoles));
}
[HttpGet("server/roles/{role}/delete")]
public async Task<IActionResult> DeleteRole(
[FromServices] StoreRepository storeRepository,
string role)
{
var roleData = await storeRepository.GetStoreRole(new StoreRoleId(role), true);
if (roleData == null)
return NotFound();
return View("Confirm",
roleData.IsUsed is true
? new ConfirmModel("Delete role",
$"Unable to proceed: The role <strong>{Html.Encode(roleData.Role)}</strong> is currently assigned to one or more users, it cannot be removed.")
: new ConfirmModel("Delete role",
$"The role <strong>{Html.Encode(roleData.Role)}</strong> will be permanently deleted. Are you sure?",
"Delete"));
}
[HttpPost("server/roles/{role}/delete")]
public async Task<IActionResult> DeleteRolePost(
[FromServices] StoreRepository storeRepository,
string role)
{
var roleId = new StoreRoleId(role);
var roleData = await storeRepository.GetStoreRole(roleId, true);
if (roleData == null)
return NotFound();
if (roleData.IsUsed is true)
{
return BadRequest();
}
var errorMessage = await storeRepository.RemoveStoreRole(roleId);
if (errorMessage is null)
{
TempData[WellKnownTempData.SuccessMessage] = "Role deleted";
}
else
{
TempData[WellKnownTempData.ErrorMessage] = errorMessage;
}
return RedirectToAction(nameof(ListRoles));
}
[HttpGet("server/roles/{role}/default")]
public async Task<IActionResult> SetDefaultRole(
[FromServices] StoreRepository storeRepository,
string role)
{
await storeRepository.SetDefaultRole(role);
TempData[WellKnownTempData.SuccessMessage] = "Role set default";
return RedirectToAction(nameof(ListRoles));
}
}
}
public class UpdateRoleViewModel
{
[Required]
[Display(Name = "Role")]
public string Role { get; set; }
[Display(Name = "Policies")] public List<string> Policies { get; set; } = new();
}

View File

@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Amazon.S3.Transfer;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers
{
public partial class UIStoresController
{
[Route("{storeId}/roles")]
public async Task<IActionResult> ListRoles(
string storeId,
[FromServices] StoreRepository storeRepository,
RolesViewModel model,
string sortOrder = null
)
{
model ??= new RolesViewModel();
model.DefaultRole = (await storeRepository.GetDefaultRole()).Role;
var roles = await storeRepository.GetStoreRoles(storeId, false, false);
if (sortOrder != null)
{
switch (sortOrder)
{
case "desc":
ViewData["NextRoleSortOrder"] = "asc";
roles = roles.OrderByDescending(user => user.Role).ToArray();
break;
case "asc":
roles = roles.OrderBy(user => user.Role).ToArray();
ViewData["NextRoleSortOrder"] = "desc";
break;
}
}
model.Roles = roles.Skip(model.Skip).Take(model.Count).ToList();
return View(model);
}
[HttpGet("{storeId}/roles/{role}")]
public async Task<IActionResult> CreateOrEditRole(
string storeId,
[FromServices] StoreRepository storeRepository,
string role)
{
if (role == "create")
{
ModelState.Remove(nameof(role));
return View(new UpdateRoleViewModel());
}
else
{
var roleData = await storeRepository.GetStoreRole(new StoreRoleId(storeId, role));
if (roleData == null)
return NotFound();
return View(new UpdateRoleViewModel()
{
Policies = roleData.Permissions,
Role = roleData.Role
});
}
}
[HttpPost("{storeId}/roles/{role}")]
public async Task<IActionResult> CreateOrEditRole(
string storeId,
[FromServices] StoreRepository storeRepository,
[FromRoute] string role, UpdateRoleViewModel viewModel)
{
string successMessage = null;
StoreRoleId roleId;
if (role == "create")
{
successMessage = "Role created";
role = viewModel.Role;
roleId = new StoreRoleId(storeId, role);
}
else
{
successMessage = "Role updated";
roleId = new StoreRoleId(storeId, role);
var storeRole = await storeRepository.GetStoreRole(roleId);
if (storeRole == null)
return NotFound();
}
if (!ModelState.IsValid)
{
return View(viewModel);
}
var r = await storeRepository.AddOrUpdateStoreRole(roleId, viewModel.Policies);
if (r is null)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "Role could not be updated"
});
return View(viewModel);
}
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = successMessage
});
return RedirectToAction(nameof(ListRoles), new { storeId });
}
[HttpGet("{storeId}/roles/{role}/delete")]
public async Task<IActionResult> DeleteRole(
string storeId,
[FromServices] StoreRepository storeRepository,
string role)
{
var roleData = await storeRepository.GetStoreRole(new StoreRoleId(storeId, role), true);;
if (roleData == null)
return NotFound();
return View("Confirm",
roleData.IsUsed is true
? new ConfirmModel("Delete role",
$"Unable to proceed: The role <strong>{Html.Encode(roleData.Role)}</strong> is currently assigned to one or more users, it cannot be removed.")
: new ConfirmModel("Delete role",
$"The role <strong>{Html.Encode(roleData.Role)}</strong> will be permanently deleted. Are you sure?",
"Delete"));
}
[HttpPost("{storeId}/roles/{roleId}/delete")]
public async Task<IActionResult> DeleteRolePost(
string storeId,
[FromServices] StoreRepository storeRepository,
string role)
{
var roleId = new StoreRoleId(storeId, role);
var roleData = await storeRepository.GetStoreRole(roleId, true);
if (roleData == null)
return NotFound();
if (roleData.IsUsed is true)
{
return BadRequest();
}
await storeRepository.RemoveStoreRole(roleId);
TempData[WellKnownTempData.SuccessMessage] = "Role deleted";
return RedirectToAction(nameof(ListRoles), new { storeId });
}
}
}

View File

@ -130,6 +130,7 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> StoreUsers()
{
StoreUsersViewModel vm = new StoreUsersViewModel();
vm.Role = StoreRoleId.Guest.Role;
await FillUsers(vm);
return View(vm);
}
@ -142,7 +143,7 @@ namespace BTCPayServer.Controllers
{
Email = u.Email,
Id = u.Id,
Role = u.Role
Role = u.StoreRole.Role
}).ToList();
}
@ -150,7 +151,7 @@ namespace BTCPayServer.Controllers
[HttpPost]
[Route("{storeId}/users")]
public async Task<IActionResult> StoreUsers(StoreUsersViewModel vm)
public async Task<IActionResult> StoreUsers(string storeId, StoreUsersViewModel vm)
{
await FillUsers(vm);
if (!ModelState.IsValid)
@ -163,12 +164,16 @@ namespace BTCPayServer.Controllers
ModelState.AddModelError(nameof(vm.Email), "User not found");
return View(vm);
}
if (!StoreRoles.AllRoles.Contains(vm.Role))
var roles = await _Repo.GetStoreRoles(CurrentStore.Id);
if (roles.All(role => role.Id != vm.Role))
{
ModelState.AddModelError(nameof(vm.Role), "Invalid role");
return View(vm);
}
if (!await _Repo.AddStoreUser(CurrentStore.Id, user.Id, vm.Role))
var roleId = await _Repo.ResolveStoreRoleId(storeId, vm.Role);
if (!await _Repo.AddStoreUser(CurrentStore.Id, user.Id, roleId))
{
ModelState.AddModelError(nameof(vm.Email), "The user already has access to this store");
return View(vm);
@ -938,8 +943,9 @@ namespace BTCPayServer.Controllers
ViewBag.HidePublicKey = true;
ViewBag.ShowStores = true;
ViewBag.ShowMenu = false;
var stores = await _Repo.GetStoresByUserId(userId);
model.Stores = new SelectList(stores.Where(s => s.Role == StoreRoles.Owner), nameof(CurrentStore.Id), nameof(CurrentStore.StoreName));
var stores = (await _Repo.GetStoresByUserId(userId)).Where(data => data.HasPermission(userId, Policies.CanModifyStoreSettings)).ToArray();
model.Stores = new SelectList(stores, nameof(CurrentStore.Id), nameof(CurrentStore.StoreName));
if (!model.Stores.Any())
{
TempData[WellKnownTempData.ErrorMessage] = "You need to be owner of at least one store before pairing";
@ -1004,14 +1010,14 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
}
var stores = await _Repo.GetStoresByUserId(userId);
var stores = (await _Repo.GetStoresByUserId(userId)).Where(data => data.HasPermission(userId, Policies.CanModifyStoreSettings)).ToArray();
return View(new PairingModel
{
Id = pairing.Id,
Label = pairing.Label,
SIN = pairing.SIN ?? "Server-Initiated Pairing",
StoreId = selectedStore ?? stores.FirstOrDefault()?.Id,
Stores = stores.Where(u => u.Role == StoreRoles.Owner).Select(s => new PairingModel.StoreViewModel
Stores = stores.Select(s => new PairingModel.StoreViewModel
{
Id = s.Id,
Name = string.IsNullOrEmpty(s.StoreName) ? s.Id : s.StoreName

View File

@ -189,11 +189,7 @@ namespace BTCPayServer.Controllers
ListWalletsViewModel.WalletViewModel walletVm = new ListWalletsViewModel.WalletViewModel();
wallets.Wallets.Add(walletVm);
walletVm.Balance = await wallet.Balance + " " + wallet.Wallet.Network.CryptoCode;
walletVm.IsOwner = wallet.Store.Role == StoreRoles.Owner;
if (!walletVm.IsOwner)
{
walletVm.Balance = "";
}
walletVm.CryptoCode = wallet.Network.CryptoCode;
walletVm.StoreId = wallet.Store.Id;

View File

@ -74,6 +74,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
.Include(data => data.PullPaymentData)
.ThenInclude(data => data.StoreData)
.ThenInclude(data => data.UserStores)
.ThenInclude(data => data.StoreRole)
.Where(data =>
payoutIds.Contains(data.Id) &&
data.State == PayoutState.AwaitingPayment &&
@ -84,7 +85,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
if (approvedStores.TryGetValue(payout.PullPaymentData.StoreId, out var value))
return value;
value = payout.PullPaymentData.StoreData.UserStores
.Any(store => store.Role == StoreRoles.Owner && store.ApplicationUserId == userId);
.Any(store => store.ApplicationUserId == userId && store.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings));
approvedStores.Add(payout.PullPaymentData.StoreId, value);
return value;
}).ToList();

View File

@ -16,32 +16,6 @@ namespace BTCPayServer.Data
{
public static class StoreDataExtensions
{
public static PermissionSet GetPermissionSet(this StoreData store)
{
ArgumentNullException.ThrowIfNull(store);
if (store.Role is null)
return new PermissionSet();
return new PermissionSet(store.Role == StoreRoles.Owner
? new[]
{
Permission.Create(Policies.CanModifyStoreSettings, store.Id),
Permission.Create(Policies.CanTradeCustodianAccount, store.Id),
Permission.Create(Policies.CanWithdrawFromCustodianAccounts, store.Id),
Permission.Create(Policies.CanDepositToCustodianAccounts, store.Id)
}
: new[]
{
Permission.Create(Policies.CanViewStoreSettings, store.Id),
Permission.Create(Policies.CanModifyInvoices, store.Id),
Permission.Create(Policies.CanViewCustodianAccounts, store.Id),
Permission.Create(Policies.CanDepositToCustodianAccounts, store.Id)
});
}
public static bool HasPermission(this StoreData store, string permission)
{
ArgumentNullException.ThrowIfNull(store);
return store.GetPermissionSet().Contains(permission, store.Id);
}
#pragma warning disable CS0618
public static PaymentMethodId? GetDefaultPaymentId(this StoreData storeData)

View File

@ -1,11 +1,40 @@
#nullable enable
using System.Linq;
using BTCPayServer.Client;
using BTCPayServer.Data;
namespace BTCPayServer
{
public static class StoreExtensions
{
public static StoreRole? GetStoreRoleOfUser(this StoreData store, string userId)
{
return store.UserStores.FirstOrDefault(r => r.ApplicationUserId == userId)?.StoreRole;
}
public static PermissionSet GetPermissionSet(this StoreRole storeRole, string storeId)
{
return new PermissionSet(storeRole.Permissions
.Select(s => Permission.TryCreatePermission(s, storeId, out var permission) ? permission : null)
.Where(s => s != null).ToArray());
}
public static PermissionSet GetPermissionSet(this StoreData store, string userId)
{
return store.GetStoreRoleOfUser(userId)?.GetPermissionSet(store.Id)?? new PermissionSet();
}
public static bool HasPermission(this StoreData store, string userId, string permission)
{
return GetPermissionSet(store, userId).HasPermission(permission, store.Id);
}
public static bool HasPermission(this PermissionSet permissionSet, string permission, string storeId)
{
return permissionSet.Contains(permission, storeId);
}
public static DerivationSchemeSettings? GetDerivationSchemeSettings(this StoreData store, BTCPayNetworkProvider networkProvider, string cryptoCode)
{
var paymentMethod = store

View File

@ -6,12 +6,12 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Fido2;
using BTCPayServer.Fido2.Models;
using BTCPayServer.Logging;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Plugins.Crowdfund;
@ -40,7 +40,6 @@ namespace BTCPayServer.Hosting
{
public class MigrationStartupTask : IStartupTask
{
public Logs Logs { get; }
private readonly ApplicationDbContextFactory _DBContextFactory;
private readonly StoreRepository _StoreRepository;

View File

@ -1,5 +1,6 @@
using System;
using BTCPayServer.Data;
using BTCPayServer.Services.Stores;
namespace BTCPayServer.Models.AppViewModels
@ -14,12 +15,12 @@ namespace BTCPayServer.Models.AppViewModels
public string AppName { get; set; }
public string AppType { get; set; }
public string ViewStyle { get; set; }
public bool IsOwner { get; set; }
public string UpdateAction { get { return "Update" + AppType; } }
public string ViewAction { get { return "View" + AppType; } }
public DateTimeOffset Created { get; set; }
public AppData App { get; set; }
public StoreRepository.StoreRole Role { get; set; }
}
public ListAppViewModel[] Apps { get; set; }

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Services.Stores;
namespace BTCPayServer.Models.ServerViewModels
{
@ -20,5 +21,11 @@ namespace BTCPayServer.Models.ServerViewModels
public override int CurrentPageCount => Users.Count;
public Dictionary<string, string> Roles { get; set; }
}
public class RolesViewModel : BasePagingViewModel
{
public List<StoreRepository.StoreRole> Roles { get; set; } = new List<StoreRepository.StoreRole>();
public string DefaultRole { get; set; }
public override int CurrentPageCount => Roles.Count;
}
}

View File

@ -11,10 +11,6 @@ namespace BTCPayServer.Models.StoreViewModels
public string Role { get; set; }
public string Id { get; set; }
}
public StoreUsersViewModel()
{
Role = StoreRoles.Guest;
}
[Required]
[EmailAddress]
public string Email { get; set; }

View File

@ -12,7 +12,6 @@ namespace BTCPayServer.Models.WalletViewModels
public string StoreId { get; set; }
public string CryptoCode { get; set; }
public string Balance { get; set; }
public bool IsOwner { get; set; }
public WalletId Id { get; set; }
}

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
@ -57,7 +58,7 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Au
var lightningSupportedPaymentMethod = (LightningSupportedPaymentMethod)paymentMethod;
if (lightningSupportedPaymentMethod.IsInternalNode &&
!(await Task.WhenAll((await _storeRepository.GetStoreUsers(_PayoutProcesserSettings.StoreId))
.Where(user => user.Role == StoreRoles.Owner).Select(user => user.Id)
.Where(user => user.StoreRole.ToPermissionSet( _PayoutProcesserSettings.StoreId).Contains(Policies.CanModifyStoreSettings, _PayoutProcesserSettings.StoreId)).Select(user => user.Id)
.Select(s => _userService.IsAdminUser(s)))).Any(b => b))
{
return;

View File

@ -141,7 +141,7 @@ namespace BTCPayServer.Security
{
if (store is not null)
{
if (store.HasPermission(policy))
if (store.HasPermission(userId,policy))
{
success = true;
}

View File

@ -98,7 +98,7 @@ namespace BTCPayServer.Security.Greenfield
var store = await _storeRepository.FindStore(storeId, userid);
if (store == null)
break;
if (!store.HasPermission(policy))
if (!store.HasPermission(userid, policy))
break;
success = true;
_httpContext.SetStoreData(store);

View File

@ -5,6 +5,7 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Plugins.Crowdfund;
@ -236,15 +237,6 @@ namespace BTCPayServer.Services.Apps
return invoices;
}
public async Task<StoreData[]> GetOwnedStores(string userId)
{
await using var ctx = _ContextFactory.CreateContext();
return await ctx.UserStore
.Where(us => us.ApplicationUserId == userId && us.Role == StoreRoles.Owner)
.Select(u => u.StoreData)
.ToArrayAsync();
}
public async Task<bool> DeleteApp(AppData appData)
{
await using var ctx = _ContextFactory.CreateContext();
@ -256,25 +248,25 @@ namespace BTCPayServer.Services.Apps
public async Task<ListAppsViewModel.ListAppViewModel[]> GetAllApps(string? userId, bool allowNoUser = false, string? storeId = null)
{
await using var ctx = _ContextFactory.CreateContext();
var listApps = await ctx.UserStore
var listApps = (await ctx.UserStore
.Where(us =>
(allowNoUser && string.IsNullOrEmpty(userId) || us.ApplicationUserId == userId) &&
(storeId == null || us.StoreDataId == storeId))
.Join(ctx.Apps, us => us.StoreDataId, app => app.StoreDataId,
(us, app) =>
new ListAppsViewModel.ListAppViewModel
{
IsOwner = us.Role == StoreRoles.Owner,
StoreId = us.StoreDataId,
StoreName = us.StoreData.StoreName,
AppName = app.Name,
AppType = app.AppType,
Id = app.Id,
Created = app.Created,
App = app
})
.OrderBy(b => b.Created)
.ToArrayAsync();
.Include(store => store.StoreRole)
.Include(store => store.StoreData)
.Join(ctx.Apps, us => us.StoreDataId, app => app.StoreDataId, (us, app) => new { us, app })
.OrderBy(b => b.app.Created)
.ToArrayAsync()).Select(arg => new ListAppsViewModel.ListAppViewModel
{
Role = StoreRepository.ToStoreRole(arg.us.StoreRole),
StoreId = arg.us.StoreDataId,
StoreName = arg.us.StoreData.StoreName,
AppName = arg.app.Name,
AppType = arg.app.AppType,
Id = arg.app.Id,
Created = arg.app.Created,
App = arg.app
}).ToArray();
// allowNoUser can lead to apps being included twice, unify them with distinct
if (allowNoUser)
@ -368,7 +360,8 @@ namespace BTCPayServer.Services.Apps
return null;
await using var ctx = _ContextFactory.CreateContext();
var app = await ctx.UserStore
.Where(us => us.ApplicationUserId == userId && us.Role == StoreRoles.Owner)
.Include(store => store.StoreRole)
.Where(us => us.ApplicationUserId == userId && us.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings))
.SelectMany(us => us.StoreData.Apps.Where(a => a.Id == appId))
.FirstOrDefaultAsync();
if (app == null)

View File

@ -53,6 +53,9 @@ namespace BTCPayServer.Services
[Display(Name = "Enable experimental features")]
public bool Experimental { get; set; }
[Display(Name = "Default role for users on a new store")]
public string DefaultRole { get; set; }
public class BlockExplorerOverrideItem
{
public string CryptoCode { get; set; }

View File

@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Migrations;
@ -18,6 +19,7 @@ namespace BTCPayServer.Services.Stores
{
private readonly ApplicationDbContextFactory _ContextFactory;
private readonly EventAggregator _eventAggregator;
private readonly SettingsRepository _settingsRepository;
public JsonSerializerSettings SerializerSettings { get; }
@ -25,10 +27,11 @@ namespace BTCPayServer.Services.Stores
{
return _ContextFactory.CreateContext();
}
public StoreRepository(ApplicationDbContextFactory contextFactory, JsonSerializerSettings serializerSettings, EventAggregator eventAggregator)
public StoreRepository(ApplicationDbContextFactory contextFactory, JsonSerializerSettings serializerSettings, EventAggregator eventAggregator, SettingsRepository settingsRepository)
{
_ContextFactory = contextFactory ?? throw new ArgumentNullException(nameof(contextFactory));
_eventAggregator = eventAggregator;
_settingsRepository = settingsRepository;
SerializerSettings = serializerSettings;
}
@ -45,56 +48,168 @@ namespace BTCPayServer.Services.Stores
{
ArgumentNullException.ThrowIfNull(userId);
await using var ctx = _ContextFactory.CreateContext();
return (await ctx
.UserStore
.Where(us => us.ApplicationUserId == userId && us.StoreDataId == storeId)
.Include(store => store.StoreData.UserStores)
.Select(us => new
{
Store = us.StoreData,
Role = us.Role
}).ToArrayAsync())
.Select(us =>
{
us.Store.Role = us.Role;
return us.Store;
}).FirstOrDefault();
return await ctx
.UserStore
.Where(us => us.ApplicationUserId == userId && us.StoreDataId == storeId)
.Include(store => store.StoreData.UserStores)
.ThenInclude(store => store.StoreRole)
.Select(us => us.StoreData).FirstOrDefaultAsync();
}
#nullable disable
public class StoreUser
{
public string Id { get; set; }
public string Email { get; set; }
public StoreRole StoreRole { get; set; }
}
public class StoreRole
{
public PermissionSet ToPermissionSet(string storeId)
{
return new PermissionSet(Permissions
.Select(s => Permission.TryCreatePermission(s, storeId, out var permission) ? permission : null)
.Where(s => s != null).ToArray());
}
public string Role { get; set; }
public List<string> Permissions { get; set; }
public bool IsServerRole { get; set; }
public string Id { get; set; }
public bool? IsUsed { get; set; }
}
#nullable enable
public async Task<StoreRole[]> GetStoreRoles(string? storeId, bool includeUsers = false, bool storeOnly = false)
{
await using var ctx = _ContextFactory.CreateContext();
var query = ctx.StoreRoles.Where(u => (storeOnly && u.StoreDataId == storeId) || (!storeOnly && (u.StoreDataId == null || u.StoreDataId == storeId)));
if (includeUsers)
{
query = query.Include(u => u.Users);
}
return (await query.ToArrayAsync()).Select(role => ToStoreRole(role)).ToArray();
}
public async Task<StoreRoleId> GetDefaultRole()
{
var r = (await _settingsRepository.GetSettingAsync<PoliciesSettings>())?.DefaultRole;
if (r is not null)
return new StoreRoleId(r);
return StoreRoleId.Owner;
}
public async Task SetDefaultRole(string role)
{
var s = (await _settingsRepository.GetSettingAsync<PoliciesSettings>()) ?? new PoliciesSettings();
s.DefaultRole = role;
await _settingsRepository.UpdateSetting(s);
}
public async Task<StoreRole?> GetStoreRole(StoreRoleId role, bool includeUsers = false)
{
await using var ctx = _ContextFactory.CreateContext();
var query = ctx.StoreRoles.AsQueryable();
if (includeUsers)
{
query = query.Include(u => u.Users);
}
var match = await query.SingleOrDefaultAsync(r => r.Id == role.Id);
if (match == null)
return null;
if (match.StoreDataId != role.StoreId)
// Should never happen because the roleId include the storeId
throw new InvalidOperationException("Bug 03991: This should never happen");
return ToStoreRole(match);
}
public async Task<string?> RemoveStoreRole(StoreRoleId role)
{
await using var ctx = _ContextFactory.CreateContext();
if (await GetDefaultRole() == role)
{
return "Cannot remove the default role";
}
var match = await ctx.StoreRoles.FindAsync(role.Id);
if (match != null && match.StoreDataId == role.StoreId)
{
if (role.StoreId is null && match.Permissions.Contains(Policies.CanModifyStoreSettings) &&
await ctx.StoreRoles.CountAsync(role =>
role.StoreDataId == null && role.Permissions.Contains(Policies.CanModifyStoreSettings)) == 1)
return "This is the last role that allows to modify store settings, you cannot remove it";
ctx.StoreRoles.Remove(match);
await ctx.SaveChangesAsync();
return null;
}
return "Role not found";
}
public async Task<StoreRole?> AddOrUpdateStoreRole(StoreRoleId role, List<string> policies)
{
policies = policies.Where(s => Policies.IsValidPolicy(s) && Policies.IsStorePolicy(s)).ToList();
await using var ctx = _ContextFactory.CreateContext();
Data.StoreRole? match = await ctx.StoreRoles.FindAsync(role.Id);
if (match is null)
{
match = new Data.StoreRole() { Id = role.Id, StoreDataId = role.StoreId, Role = role.Role };
ctx.StoreRoles.Add(match);
}
match.Permissions = policies;
try
{
await ctx.SaveChangesAsync();
}
catch (DbUpdateException)
{
return null;
}
return ToStoreRole(match);
}
public async Task<StoreUser[]> GetStoreUsers(string storeId)
{
ArgumentNullException.ThrowIfNull(storeId);
using var ctx = _ContextFactory.CreateContext();
return await ctx
.UserStore
.Where(u => u.StoreDataId == storeId)
.Select(u => new StoreUser()
{
Id = u.ApplicationUserId,
Email = u.ApplicationUser.Email,
Role = u.Role
}).ToArrayAsync();
await using var ctx = _ContextFactory.CreateContext();
return (await
ctx
.UserStore
.Where(u => u.StoreDataId == storeId)
.Include(u => u.StoreRole)
.Select(u => new
{
Id = u.ApplicationUserId,
u.ApplicationUser.Email,
u.StoreRole
}).ToArrayAsync()).Select(arg => new StoreUser()
{
StoreRole = ToStoreRole(arg.StoreRole),
Id = arg.Id,
Email = arg.Email
}).ToArray();
}
public static StoreRole ToStoreRole(Data.StoreRole storeRole)
{
return new StoreRole()
{
Id = storeRole.Id,
Role = storeRole.Role,
Permissions = storeRole.Permissions,
IsServerRole = storeRole.StoreDataId == null,
IsUsed = storeRole.Users?.Any()
};
}
public async Task<StoreData[]> GetStoresByUserId(string userId, IEnumerable<string>? storeIds = null)
{
using var ctx = _ContextFactory.CreateContext();
await using var ctx = _ContextFactory.CreateContext();
return (await ctx.UserStore
.Where(u => u.ApplicationUserId == userId && (storeIds == null || storeIds.Contains(u.StoreDataId)))
.Select(u => new { u.StoreData, u.Role })
.ToArrayAsync())
.Select(u =>
{
u.StoreData.Role = u.Role;
return u.StoreData;
}).ToArray();
.Include(store => store.StoreData)
.ThenInclude(data => data.UserStores)
.ThenInclude(data => data.StoreRole)
.Select(store => store.StoreData)
.ToArrayAsync());
}
public async Task<StoreData?> GetStoreByInvoiceId(string invoiceId)
@ -105,29 +220,68 @@ namespace BTCPayServer.Services.Stores
return matched?.StoreData;
}
public async Task<bool> AddStoreUser(string storeId, string userId, string role)
/// <summary>
/// `role` can be passed in two format:
/// STOREID::ROLE or ROLE.
/// If the first case, this method make sure the storeId is same as <paramref name="storeId"/>.
/// In the second case, we interprete ROLE as a server level roleId first, then if it does not exist, check if there is a store level role.
/// </summary>
/// <param name="storeId"></param>
/// <param name="role"></param>
/// <returns></returns>
public async Task<StoreRoleId?> ResolveStoreRoleId(string storeId, string? role)
{
using var ctx = _ContextFactory.CreateContext();
var userStore = new UserStore() { StoreDataId = storeId, ApplicationUserId = userId, Role = role };
if (string.IsNullOrWhiteSpace(role))
return null;
if (role.Contains("::", StringComparison.OrdinalIgnoreCase) || storeId.Contains("::", StringComparison.OrdinalIgnoreCase))
return null;
var roleId = StoreRoleId.Parse(role);
if (roleId.StoreId != null && roleId.StoreId != storeId)
return null;
if ((await GetStoreRole(roleId)) != null)
return roleId;
if (roleId.IsServerRole)
roleId = new StoreRoleId(storeId, role);
if ((await GetStoreRole(roleId)) != null)
return roleId;
return null;
}
public async Task<bool> AddStoreUser(string storeId, string userId, StoreRoleId? roleId = null)
{
ArgumentNullException.ThrowIfNull(storeId);
AssertStoreRoleIfNeeded(storeId, roleId);
roleId ??= await GetDefaultRole();
await using var ctx = _ContextFactory.CreateContext();
var userStore = new UserStore() { StoreDataId = storeId, ApplicationUserId = userId, StoreRoleId = roleId.Id };
ctx.UserStore.Add(userStore);
try
{
await ctx.SaveChangesAsync();
return true;
}
catch (Microsoft.EntityFrameworkCore.DbUpdateException)
catch (DbUpdateException)
{
return false;
}
}
static void AssertStoreRoleIfNeeded(string storeId, StoreRoleId? roleId)
{
if (roleId?.StoreId != null && storeId != roleId.StoreId)
throw new ArgumentException("The roleId doesn't belong to this storeId", nameof(roleId));
}
public async Task CleanUnreachableStores()
{
await using var ctx = _ContextFactory.CreateContext();
if (!ctx.Database.SupportDropForeignKey())
return;
var events = new List<Events.StoreRemovedEvent>();
foreach (var store in await ctx.Stores.Where(s => s.UserStores.All(u => u.Role != StoreRoles.Owner)).ToArrayAsync())
foreach (var store in await ctx.Stores.Include(data => data.UserStores)
.ThenInclude(store => store.StoreRole).Where(s =>
s.UserStores.All(u => !u.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings)))
.ToArrayAsync())
{
ctx.Stores.Remove(store);
events.Add(new Events.StoreRemovedEvent(store.Id));
@ -139,8 +293,8 @@ namespace BTCPayServer.Services.Stores
public async Task<bool> RemoveStoreUser(string storeId, string userId)
{
await using var ctx = _ContextFactory.CreateContext();
if (!await ctx.UserStore.AnyAsync(store =>
store.StoreDataId == storeId && store.Role == StoreRoles.Owner &&
if (!await ctx.UserStore.Include(store => store.StoreRole).AnyAsync(store =>
store.StoreDataId == storeId && store.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings) &&
userId != store.ApplicationUserId))
return false;
var userStore = new UserStore() { StoreDataId = storeId, ApplicationUserId = userId };
@ -156,7 +310,7 @@ namespace BTCPayServer.Services.Stores
await using var ctx = _ContextFactory.CreateContext();
if (ctx.Database.SupportDropForeignKey())
{
if (!await ctx.UserStore.Where(u => u.StoreDataId == storeId && u.Role == StoreRoles.Owner).AnyAsync())
if (!await ctx.UserStore.Where(u => u.StoreDataId == storeId && u.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings)).AnyAsync())
{
var store = await ctx.Stores.FindAsync(storeId);
if (store != null)
@ -168,20 +322,23 @@ namespace BTCPayServer.Services.Stores
}
}
}
public async Task CreateStore(string ownerId, StoreData storeData)
public async Task CreateStore(string ownerId, StoreData storeData, StoreRoleId? roleId = null)
{
if (!string.IsNullOrEmpty(storeData.Id))
throw new ArgumentException("id should be empty", nameof(storeData.StoreName));
if (string.IsNullOrEmpty(storeData.StoreName))
throw new ArgumentException("name should not be empty", nameof(storeData.StoreName));
ArgumentNullException.ThrowIfNull(ownerId);
using var ctx = _ContextFactory.CreateContext();
AssertStoreRoleIfNeeded(storeData.Id, roleId);
await using var ctx = _ContextFactory.CreateContext();
storeData.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(32));
roleId ??= await GetDefaultRole();
var userStore = new UserStore
{
StoreDataId = storeData.Id,
ApplicationUserId = ownerId,
Role = StoreRoles.Owner,
StoreRoleId = roleId.Id,
};
ctx.Add(storeData);
@ -234,7 +391,7 @@ namespace BTCPayServer.Services.Stores
.Where(s => s.StoreId == storeId && s.WebhookId == webhookId)
.SelectMany(s => s.Webhook.Deliveries)
.OrderByDescending(s => s.Timestamp);
if (count is int c)
if (count is { } c)
req = req.Take(c);
return await req
.ToArrayAsync();
@ -431,4 +588,66 @@ retry:
return ctx.Database.SupportDropForeignKey();
}
}
public record StoreRoleId
{
public static StoreRoleId Parse(string str)
{
var i = str.IndexOf("::");
string? storeId = null;
string role;
if (i == -1)
{
role = str;
return new StoreRoleId(role);
}
else
{
role = str[0..i];
storeId = str[(i + 2)..];
return new StoreRoleId(storeId, role);
}
}
public StoreRoleId(string role)
{
if (string.IsNullOrWhiteSpace(role))
throw new ArgumentException("Role shouldn't be null or empty", nameof(role));
if (role.Contains("::", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Role shouldn't contains '::'", nameof(role));
Role = role;
}
public StoreRoleId(string storeId, string role)
{
if (string.IsNullOrWhiteSpace(role))
throw new ArgumentException("Role shouldn't be null or empty", nameof(role));
if (string.IsNullOrWhiteSpace(storeId))
throw new ArgumentException("StoreId shouldn't be null or empty", nameof(storeId));
if (role.Contains("::", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Role shouldn't contains '::'", nameof(role));
if (storeId.Contains("::", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("StoreId shouldn't contains '::'", nameof(storeId));
StoreId = storeId;
Role = role;
}
public static StoreRoleId Owner { get; } = new StoreRoleId("Owner");
public static StoreRoleId Guest { get; } = new StoreRoleId("Guest");
public string? StoreId { get; }
public string Role { get; }
public string Id
{
get
{
if (StoreId is null)
return Role;
return $"{StoreId}::{Role}";
}
}
public bool IsServerRole => StoreId is null;
public override string ToString()
{
return Id;
}
}
}

View File

@ -1,19 +1,13 @@
using System;
using System.Collections.Generic;
namespace BTCPayServer
{
public class StoreRoles
{
[Obsolete("You should check authorization policies instead of roles")]
public const string Owner = "Owner";
[Obsolete("You should check authorization policies instead of roles")]
public const string Guest = "Guest";
public static IEnumerable<String> AllRoles
{
get
{
yield return Owner;
yield return Guest;
}
}
}
}

View File

@ -0,0 +1,119 @@
@using BTCPayServer.Client
@using BTCPayServer.Views.Server
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Controllers
@using BTCPayServer.Views.Stores
@model UpdateRoleViewModel
@{
Layout = "_NavLayout.cshtml";
var role = Context.GetRouteValue("role") as string;
if (role == "create")
role = null;
var storeId = Context.GetRouteValue("storeId") as string;
var controller = ViewContext.RouteData.Values["controller"].ToString().TrimEnd("Controller", StringComparison.InvariantCultureIgnoreCase);
if (storeId is null)
ViewData.SetActivePage(ServerNavPages.Roles, role is null ? "Create role" : "Update role");
else
{
ViewData.SetActivePage(StoreNavPages.Roles, role is null ? "Create role" : "Update role");
}
var storePolicies = Policies.AllPolicies.Where(Policies.IsStorePolicy).ToArray();
}
<h3 class="mb-4">@ViewData["Title"]</h3>
<div class="row">
<div class="col-xxl-constrain">
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group" style="max-width:320px">
<label asp-for="Role" class="form-label"></label>
@if (role == null)
{
<input asp-for="Role" required="required" class="form-control" />
}
else
{
<input asp-for="Role" required="required" class="form-control" readonly />
}
<span asp-validation-for="Role" class="text-danger"></span>
</div>
<h4 class="mt-4 mb-3">Permissions</h4>
<select multiple="multiple" asp-for="Policies" class="form-select hide-when-js">
@foreach (var policy in storePolicies)
{
<option value="@policy" class="text-truncate" asp-selected="@(Model.Policies?.Contains(policy) ?? false)">@policy</option>
}
</select>
<div class="list-group mb-2">
@{
var storePolicyMap = Permission.PolicyMap.Where(pair => Policies.IsStorePolicy(pair.Key)).ToArray();
var topMostPolicies = storePolicyMap.Where(pair => !storePolicyMap.Any(valuePair => valuePair.Value.Contains(pair.Key)));
@foreach (var policy in topMostPolicies)
{
RenderTree(policy, storePolicyMap, Model.Policies.Contains(policy.Key));
}
}
</div>
<span asp-validation-for="Policies" class="text-danger"></span>
<button id="Save" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
</form>
</div>
</div>
@{
void RenderTree(KeyValuePair<string, HashSet<string>> policy, KeyValuePair<string, HashSet<string>>[] storePolicyMap, bool isChecked)
{
<div class="form-check mb-0">
<input type="checkbox" class="form-check-input policy-cb" checked="@isChecked" value="@policy.Key" id="Policy-@policy.Key.Replace(".", "_")" />
<label class="h5 fw-semibold form-check-label mb-1" for="Policy-@policy.Key.Replace(".", "_")" data-bs-toggle="tooltip" title="@policy.Key">
@UIManageController.AddApiKeyViewModel.PermissionValueItem.PermissionDescriptions[policy.Key].Title
</label>
<p class="text-muted">@UIManageController.AddApiKeyViewModel.PermissionValueItem.PermissionDescriptions[policy.Key].Description</p>
@if (policy.Value?.Any() is true)
{
<div class="list-group">
@foreach (var subPolicy in policy.Value)
{
var match = storePolicyMap.SingleOrDefault(pair => pair.Key == subPolicy);
RenderTree(match.Key is not null ? match : new KeyValuePair<string, HashSet<string>>(subPolicy, null), storePolicyMap, !isChecked && Model.Policies.Contains(subPolicy));
}
</div>
}
</div>
}
}
<script>
function handleCheckboxChange(element) {
const { checked, value: policy } = element;
const policySelect = document.getElementById('Policies');
const subPolicies = element.parentElement.querySelectorAll(`.list-group .policy-cb:not([value="${policy}"])`);
policySelect.querySelector(`option[value="${policy}"]`).selected = checked;
subPolicies.forEach(subPolicy => {
subPolicy.checked = checked? false : subPolicy.checked;
if (checked){
subPolicy.setAttribute("disabled", "disabled");
} else {
subPolicy.removeAttribute("disabled");
}
policySelect.querySelector(`option[value="${subPolicy.value}"]`).selected = subPolicy.checked;
});
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll(".policy-cb:checked").forEach(handleCheckboxChange);
delegate('change', '.policy-cb', event => {
handleCheckboxChange(event.target);
});
});
</script>

View File

@ -0,0 +1,147 @@
@using BTCPayServer.Components
@using BTCPayServer.Views.Server
@using BTCPayServer.Views.Stores
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Client
@model BTCPayServer.Models.ServerViewModels.RolesViewModel
@{
Layout = "_NavLayout.cshtml";
var storeId = Context.GetRouteValue("storeId") as string;
var controller = ViewContext.RouteData.Values["controller"].ToString().TrimEnd("Controller", StringComparison.InvariantCultureIgnoreCase);
if (storeId is null)
ViewData.SetActivePage(ServerNavPages.Roles);
else
{
ViewData.SetActivePage(StoreNavPages.Roles);
}
var nextRoleSortOrder = (string) ViewData["NextRoleSortOrder"];
String roleSortOrder = null;
switch (nextRoleSortOrder)
{
case "asc":
roleSortOrder = "desc";
break;
case "desc":
roleSortOrder = "asc";
break;
}
var sortIconClass = "fa-sort";
if (roleSortOrder != null)
{
sortIconClass = $"fa-sort-alpha-{roleSortOrder}";
}
var sortByDesc = "Sort by descending...";
var sortByAsc = "Sort by ascending...";
var showInUseColumn = !Model.Roles.Any(r => r.IsUsed is null);
}
<div class="d-flex align-items-center justify-content-between mb-3">
<h3 class="mb-0">@ViewData["Title"]</h3>
<a asp-action="CreateOrEditRole" asp-route-storeId="@storeId" class="btn btn-primary" role="button" id="CreateRole" asp-route-role="create"
asp-controller="@controller">
<span class="fa fa-plus"></span>
Add Role
</a>
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<a
asp-controller="@controller"
asp-action="ListRoles"
asp-route-storeId="@storeId"
asp-route-sortOrder="@(nextRoleSortOrder ?? "asc")"
class="text-nowrap"
title="@(nextRoleSortOrder == "desc" ? sortByAsc : sortByDesc)">
Role
<span class="fa @(sortIconClass)" />
</a>
</th>
<th >Permissions</th>
@if (showInUseColumn)
{
<th>In use</th>
}
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var role in Model.Roles)
{
<tr>
<td>
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="me-1">@role.Role</span>
@if (role.IsServerRole)
{
<span class="badge bg-dark">
Server-wide
</span>
@if (Model.DefaultRole == role.Id)
{
<span class="badge bg-info">
Default
</span>
}
}
</div>
</td>
<td>
@if (!role.Permissions.Any())
{
<span class="text-warning">No policies</span>
}
else
{
@foreach (var policy in role.Permissions)
{
<code class="d-block text-break">@policy</code>
}
}
</td>
@if (showInUseColumn)
{
<td class="text-center">
@if (role.IsUsed is true)
{
<span class="text-success fa fa-check"></span>
}
else
{
<span class="text-danger fa fa-times"></span>
}
</td>
}
<td class="text-end">
<a permission="@(role.IsServerRole ? Policies.CanModifyServerSettings : Policies.CanModifyStoreSettings)" asp-action="CreateOrEditRole" asp-route-storeId="@storeId" asp-route-role="@role.Role"
asp-controller="@(role.IsServerRole ? "UIServer" : "UIStores")">
Edit
</a> -
<a permission="@(role.IsServerRole ? Policies.CanModifyServerSettings : Policies.CanModifyStoreSettings)" asp-action="DeleteRole" asp-route-storeId="@storeId" asp-route-role="@role.Role"
asp-controller="@(role.IsServerRole ? "UIServer" : "UIStores")">
Remove
</a>
@if (role.IsServerRole && Model.DefaultRole != role.Id)
{
<a permission="@Policies.CanModifyServerSettings" asp-action="SetDefaultRole" asp-route-role="@role.Role"
asp-controller="UIServer" id="SetDefault">
- Set as default
</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<vc:pager view-model="Model"></vc:pager>

View File

@ -1,5 +1,6 @@
@using BTCPayServer.Services.Apps
@using BTCPayServer.Abstractions.Models
@using BTCPayServer.Client
@model ListAppsViewModel
@inject AppService AppService
@{
@ -79,14 +80,11 @@
{
<tr>
<td>
@if (app.IsOwner)
{
<span><a asp-controller="UIStores" asp-action="GeneralSettings" asp-route-storeId="@app.StoreId">@app.StoreName</a></span>
}
else
{
<span>@app.StoreName</span>
}
<span permission="@Policies.CanModifyStoreSettings">
<a asp-controller="UIStores" asp-action="GeneralSettings" asp-route-storeId="@app.StoreId">@app.StoreName</a>
</span>
<span not-permission="@Policies.CanModifyStoreSettings">@app.StoreName</span>
</td>
<td>@app.AppName</td>
<td>
@ -100,14 +98,15 @@
<span>@viewStyle</span>
}
</td>
<td class="text-end">
@if (app.IsOwner)
{
<td class="text-end" permission="@Policies.CanModifyStoreSettings">
<a asp-action="@app.UpdateAction" asp-controller="UIApps" asp-route-appId="@app.Id" asp-route-storeId="@app.StoreId">Settings</a>
<span> - </span>
}
<a asp-action="DeleteApp" asp-route-appId="@app.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="The app <strong>@Html.Encode(app.AppName)</strong> and its settings will be permanently deleted from your store <strong>@Html.Encode(app.StoreName)</strong>." data-confirm-input="DELETE">Delete</a>
</td>
<td class="text-end" no-permission="@Policies.CanModifyStoreSettings">
</td>
</tr>
}
</tbody>

View File

@ -2,6 +2,7 @@ namespace BTCPayServer.Views.Server
{
public enum ServerNavPages
{
Index, Users, Emails, Policies, Theme, Services, Maintenance, Logs, Files, Plugins
Index, Users, Emails, Policies, Theme, Services, Maintenance, Logs, Files, Plugins,
Roles
}
}

View File

@ -7,6 +7,7 @@
<nav id="SectionNav">
<div class="nav">
<a asp-controller="UIServer" id="SectionNav-@ServerNavPages.Users" class="nav-link @ViewData.IsActivePage(ServerNavPages.Users)" asp-action="ListUsers">Users</a>
<a asp-controller="UIServer" id="SectionNav-@ServerNavPages.Roles" class="nav-link @ViewData.IsActivePage(ServerNavPages.Roles)" asp-action="ListRoles">Roles</a>
<a asp-controller="UIServer" id="SectionNav-@ServerNavPages.Emails" class="nav-link @ViewData.IsActivePage(ServerNavPages.Emails)" asp-action="Emails">Email</a>
<a asp-controller="UIServer" id="SectionNav-@ServerNavPages.Policies" class="nav-link @ViewData.IsActivePage(ServerNavPages.Policies)" asp-action="Policies">Policies</a>
<a asp-controller="UIServer" id="SectionNav-@ServerNavPages.Services" class="nav-link @ViewData.IsActivePage(ServerNavPages.Services)" asp-action="Services">Services</a>

View File

@ -24,6 +24,7 @@ namespace BTCPayServer.Views.Stores
[Obsolete("Use StoreNavPages.Plugins instead")]
Integrations,
Emails,
Forms
Forms,
Roles
}
}

View File

@ -1,8 +1,13 @@
@using BTCPayServer.Abstractions.Models
@using BTCPayServer.Services.Stores
@using BTCPayServer.Abstractions.Contracts
@model StoreUsersViewModel
@inject IScopeProvider ScopeProvider
@inject StoreRepository StoreRepository
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.Users, "Store Users", Context.GetStoreData().Id);
var roles = new SelectList(await StoreRepository.GetStoreRoles(ScopeProvider.GetCurrentStoreId()), nameof(StoreRepository.StoreRole.Id), nameof(StoreRepository.StoreRole.Role), Model.Role);
}
<div class="row">
@ -24,9 +29,7 @@
<input asp-for="Email" type="text" class="form-control" placeholder="user@example.com">
</div>
<div class="ms-3">
<select asp-for="Role" class="form-select">
<option value="@StoreRoles.Owner">Owner</option>
<option value="@StoreRoles.Guest">Guest</option>
<select asp-for="Role" class="form-select" asp-items="roles">
</select>
</div>
<div class="ms-3">

View File

@ -14,6 +14,7 @@
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.CheckoutAppearance))" class="nav-link @ViewData.IsActivePage(StoreNavPages.CheckoutAppearance)" asp-controller="UIStores" asp-action="CheckoutAppearance" asp-route-storeId="@storeId">Checkout Appearance</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Tokens))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Tokens)" asp-controller="UIStores" asp-action="ListTokens" asp-route-storeId="@storeId">Access Tokens</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Users))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Users)" asp-controller="UIStores" asp-action="StoreUsers" asp-route-storeId="@storeId">Users</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Roles))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Roles)" asp-controller="UIStores" asp-action="ListRoles" asp-route-storeId="@storeId">Roles</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Webhooks))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Webhooks)" asp-controller="UIStores" asp-action="Webhooks" asp-route-storeId="@storeId">Webhooks</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.PayoutProcessors))" class="nav-link @ViewData.IsActivePage(StoreNavPages.PayoutProcessors)" asp-controller="UIPayoutProcessors" asp-action="ConfigureStorePayoutProcessors" asp-route-storeId="@storeId">Payout Processors</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Emails))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Emails)" asp-controller="UIStores" asp-action="StoreEmailSettings" asp-route-storeId="@storeId">Emails</a>

View File

@ -1,4 +1,5 @@
@model BTCPayServer.Models.WalletViewModels.ListWalletsViewModel
@using BTCPayServer.Client
@model BTCPayServer.Models.WalletViewModels.ListWalletsViewModel
@{
ViewData.SetActivePage(WalletsNavPages.Index, "Wallets");
}
@ -42,16 +43,18 @@
@foreach (var wallet in Model.Wallets)
{
<tr>
@if (wallet.IsOwner)
{
<td><a asp-action="GeneralSettings" asp-controller="UIStores" asp-route-storeId="@wallet.StoreId">@wallet.StoreName</a></td>
}
else
{
<td>@wallet.StoreName</td>
}
<td>
<a
permission="@Policies.CanModifyStoreSettings"
asp-action="GeneralSettings" asp-controller="UIStores" asp-route-storeId="@wallet.StoreId">
@wallet.StoreName
</a>
<span not-permission="@Policies.CanModifyStoreSettings">@wallet.StoreName</span>
</td>
<td>@wallet.CryptoCode</td>
<td>@wallet.Balance</td>
<td><span permission="@Policies.CanModifyStoreSettings">@wallet.Balance</span></td>
<td class="text-end">
<a asp-action="WalletTransactions" asp-route-walletId="@wallet.Id">Manage</a>
</td>

View File

@ -47,6 +47,42 @@
}
]
}
},
"/api/v1/server/roles": {
"get": {
"tags": [
"ServerInfo"
],
"summary": "Get store's roles",
"description": "View information about the store's roles at the server's scope",
"operationId": "Server_GetStoreRoles",
"responses": {
"200": {
"description": "The user roles available at the server's scope",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RoleData"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to get the store's roles"
},
"404": {
"description": "Store not found"
}
},
"security": [
{
"API_Key": [
"btcpay.server.canmodifyserversettings"
],
"Basic": []
}
]
}
}
},
"components": {

View File

@ -254,10 +254,92 @@
}
]
}
},
"/api/v1/stores/{storeId}/roles": {
"get": {
"tags": [
"Stores"
],
"summary": "Get store's roles",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to fetch",
"schema": {
"type": "string"
}
}
],
"description": "View information about the specified store's roles",
"operationId": "Stores_GetStoreRoles",
"responses": {
"200": {
"description": "The user roles available for this store",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RoleData"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to get the store's roles"
},
"404": {
"description": "Store not found"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
}
}
},
"components": {
"schemas": {
"RoleData": {
"type": "object",
"properties": {
"id": {
"description": "The role's Id (Same as role if the role is created at server level, if the role is created at the store level the format is `STOREID::ROLE`)",
"type": "string",
"nullable": false,
"example": "Owner"
},
"role": {
"description": "The role's name",
"type": "string",
"nullable": false,
"example": "Owner"
},
"permissions": {
"description": "The permissions attached to this role",
"type": "array",
"items": {
"type": "string"
},
"example": [
"btcpay.store.canmodifystoresettings",
"btcpay.store.cantradecustodianaccount",
"btcpay.store.canwithdrawfromcustodianaccount",
"btcpay.store.candeposittocustodianaccount"
]
},
"isServerRole": {
"description": "Whether this role is at the scope of the store or scope of the server",
"type": "boolean",
"example": true
}
}
},
"StoreDataList": {
"type": "array",
"items": {