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

View File

@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
@ -11,5 +12,11 @@ namespace BTCPayServer.Client
var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/server/info"), token); var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/server/info"), token);
return await HandleResponse<ServerInfoData>(response); 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 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, public virtual async Task<IEnumerable<StoreUserData>> GetStoreUsers(string storeId,
CancellationToken token = default) CancellationToken token = default)
{ {

View File

@ -1,3 +1,5 @@
using System.Collections.Generic;
namespace BTCPayServer.Client.Models namespace BTCPayServer.Client.Models
{ {
public class StoreData : StoreBaseData public class StoreData : StoreBaseData
@ -17,4 +19,12 @@ namespace BTCPayServer.Client.Models
public string Role { get; set; } 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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Linq.Expressions;
namespace BTCPayServer.Client namespace BTCPayServer.Client
{ {
@ -134,7 +136,7 @@ namespace BTCPayServer.Client
{ {
static Permission() static Permission()
{ {
Init(); PolicyMap = Init();
} }
public static Permission Create(string policy, string scope = null) 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)); 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.CanManageCustodianAccounts,
Policies.CanManagePullPayments, Policies.CanManagePullPayments,
Policies.CanModifyInvoices, Policies.CanModifyInvoices,
@ -248,25 +252,42 @@ namespace BTCPayServer.Client
Policies.CanModifyPaymentRequests, Policies.CanModifyPaymentRequests,
Policies.CanUseLightningNodeInStore); Policies.CanUseLightningNodeInStore);
PolicyHasChild(Policies.CanManageUsers, Policies.CanCreateUser); PolicyHasChild(policyMap,Policies.CanManageUsers, Policies.CanCreateUser);
PolicyHasChild(Policies.CanManagePullPayments, Policies.CanCreatePullPayments); PolicyHasChild(policyMap,Policies.CanManagePullPayments, Policies.CanCreatePullPayments);
PolicyHasChild(Policies.CanCreatePullPayments, Policies.CanCreateNonApprovedPullPayments); PolicyHasChild(policyMap,Policies.CanCreatePullPayments, Policies.CanCreateNonApprovedPullPayments);
PolicyHasChild(Policies.CanModifyPaymentRequests, Policies.CanViewPaymentRequests); PolicyHasChild(policyMap,Policies.CanModifyPaymentRequests, Policies.CanViewPaymentRequests);
PolicyHasChild(Policies.CanModifyProfile, Policies.CanViewProfile); PolicyHasChild(policyMap,Policies.CanModifyProfile, Policies.CanViewProfile);
PolicyHasChild(Policies.CanUseLightningNodeInStore, Policies.CanViewLightningInvoiceInStore, Policies.CanCreateLightningInvoiceInStore); PolicyHasChild(policyMap,Policies.CanUseLightningNodeInStore, Policies.CanViewLightningInvoiceInStore, Policies.CanCreateLightningInvoiceInStore);
PolicyHasChild(Policies.CanManageNotificationsForUser, Policies.CanViewNotificationsForUser); PolicyHasChild(policyMap,Policies.CanManageNotificationsForUser, Policies.CanViewNotificationsForUser);
PolicyHasChild(Policies.CanModifyServerSettings, PolicyHasChild(policyMap,Policies.CanModifyServerSettings,
Policies.CanUseInternalLightningNode, Policies.CanUseInternalLightningNode,
Policies.CanManageUsers); Policies.CanManageUsers);
PolicyHasChild(Policies.CanUseInternalLightningNode, Policies.CanCreateLightningInvoiceInternalNode, Policies.CanViewLightningInvoiceInternalNode); PolicyHasChild(policyMap, Policies.CanUseInternalLightningNode, Policies.CanCreateLightningInvoiceInternalNode, Policies.CanViewLightningInvoiceInternalNode);
PolicyHasChild(Policies.CanManageCustodianAccounts, Policies.CanViewCustodianAccounts); PolicyHasChild(policyMap, Policies.CanManageCustodianAccounts, Policies.CanViewCustodianAccounts);
PolicyHasChild(Policies.CanModifyInvoices, Policies.CanViewInvoices, Policies.CanCreateInvoice, Policies.CanCreateLightningInvoiceInStore); PolicyHasChild(policyMap, Policies.CanModifyInvoices, Policies.CanViewInvoices, Policies.CanCreateInvoice, Policies.CanCreateLightningInvoiceInStore);
PolicyHasChild(Policies.CanViewStoreSettings, Policies.CanViewInvoices, Policies.CanViewPaymentRequests); 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) foreach (string subPolicy in subPolicies)
{ {
@ -275,7 +296,7 @@ namespace BTCPayServer.Client
} }
else 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<U2FDevice> U2FDevices { get; set; }
public DbSet<Fido2Credential> Fido2Credentials { get; set; } public DbSet<Fido2Credential> Fido2Credentials { get; set; }
public DbSet<UserStore> UserStore { get; set; } public DbSet<UserStore> UserStore { get; set; }
public DbSet<StoreRole> StoreRoles { get; set; }
[Obsolete] [Obsolete]
public DbSet<WalletData> Wallets { get; set; } public DbSet<WalletData> Wallets { get; set; }
public DbSet<WalletObjectData> WalletObjects { get; set; } public DbSet<WalletObjectData> WalletObjects { get; set; }
@ -129,6 +130,7 @@ namespace BTCPayServer.Data
PayoutProcessorData.OnModelCreating(builder, Database); PayoutProcessorData.OnModelCreating(builder, Database);
WebhookData.OnModelCreating(builder, Database); WebhookData.OnModelCreating(builder, Database);
FormData.OnModelCreating(builder, Database); FormData.OnModelCreating(builder, Database);
StoreRole.OnModelCreating(builder, Database);
if (Database.IsSqlite() && !_designTime) if (Database.IsSqlite() && !_designTime)

View File

@ -1,13 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text; using System.Text;
using BTCPayServer.Client;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using PayoutProcessorData = BTCPayServer.Data.PayoutProcessorData;
namespace BTCPayServer.Data namespace BTCPayServer.Data
{ {
@ -37,8 +34,6 @@ namespace BTCPayServer.Data
public byte[] StoreCertificate { get; set; } public byte[] StoreCertificate { get; set; }
[NotMapped] public string Role { get; set; }
public string StoreBlob { get; set; } public string StoreBlob { get; set; }
[Obsolete("Use GetDefaultPaymentId instead")] [Obsolete("Use GetDefaultPaymentId instead")]
@ -52,6 +47,7 @@ namespace BTCPayServer.Data
public IEnumerable<CustodianAccountData> CustodianAccounts { get; set; } public IEnumerable<CustodianAccountData> CustodianAccounts { get; set; }
public IEnumerable<StoreSettingData> Settings { get; set; } public IEnumerable<StoreSettingData> Settings { get; set; }
public IEnumerable<FormData> Forms { get; set; } public IEnumerable<FormData> Forms { get; set; }
public IEnumerable<StoreRole> StoreRoles { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade) 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; using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Data namespace BTCPayServer.Data
@ -9,7 +10,10 @@ namespace BTCPayServer.Data
public string StoreDataId { get; set; } public string StoreDataId { get; set; }
public StoreData StoreData { 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) internal static void OnModelCreating(ModelBuilder builder)
@ -32,6 +36,10 @@ namespace BTCPayServer.Data
.HasOne(pt => pt.StoreData) .HasOne(pt => pt.StoreData)
.WithMany(t => t.UserStores) .WithMany(t => t.UserStores)
.HasForeignKey(pt => pt.StoreDataId); .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"); 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 => modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@ -292,6 +242,31 @@ namespace BTCPayServer.Migrations
b.ToTable("Fido2Credentials"); 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 => modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@ -655,6 +630,34 @@ namespace BTCPayServer.Migrations
b.ToTable("Payouts"); 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 => modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@ -802,6 +805,28 @@ namespace BTCPayServer.Migrations
b.ToTable("Files"); 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 => modelBuilder.Entity("BTCPayServer.Data.StoreSettingData", b =>
{ {
b.Property<string>("StoreId") b.Property<string>("StoreId")
@ -878,13 +903,16 @@ namespace BTCPayServer.Migrations
b.Property<string>("StoreDataId") b.Property<string>("StoreDataId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Role") b.Property<string>("StoreRoleId")
.HasColumnType("TEXT"); .HasColumnType("TEXT")
.HasColumnName("Role");
b.HasKey("ApplicationUserId", "StoreDataId"); b.HasKey("ApplicationUserId", "StoreDataId");
b.HasIndex("StoreDataId"); b.HasIndex("StoreDataId");
b.HasIndex("StoreRoleId");
b.ToTable("UserStore"); b.ToTable("UserStore");
}); });
@ -1188,26 +1216,6 @@ namespace BTCPayServer.Migrations
b.Navigation("StoreData"); 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 => modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b =>
{ {
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser") b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
@ -1218,6 +1226,16 @@ namespace BTCPayServer.Migrations
b.Navigation("ApplicationUser"); 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 => modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b =>
{ {
b.HasOne("BTCPayServer.Data.StoreData", "StoreData") b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
@ -1343,6 +1361,16 @@ namespace BTCPayServer.Migrations
b.Navigation("StoreData"); 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 => modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b =>
{ {
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
@ -1392,6 +1420,16 @@ namespace BTCPayServer.Migrations
b.Navigation("ApplicationUser"); 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 => modelBuilder.Entity("BTCPayServer.Data.StoreSettingData", b =>
{ {
b.HasOne("BTCPayServer.Data.StoreData", "Store") b.HasOne("BTCPayServer.Data.StoreData", "Store")
@ -1446,9 +1484,15 @@ namespace BTCPayServer.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("BTCPayServer.Data.StoreRole", "StoreRole")
.WithMany("Users")
.HasForeignKey("StoreRoleId");
b.Navigation("ApplicationUser"); b.Navigation("ApplicationUser");
b.Navigation("StoreData"); b.Navigation("StoreData");
b.Navigation("StoreRole");
}); });
modelBuilder.Entity("BTCPayServer.Data.WalletObjectLinkData", b => modelBuilder.Entity("BTCPayServer.Data.WalletObjectLinkData", b =>
@ -1606,9 +1650,16 @@ namespace BTCPayServer.Migrations
b.Navigation("Settings"); b.Navigation("Settings");
b.Navigation("StoreRoles");
b.Navigation("UserStores"); b.Navigation("UserStores");
}); });
modelBuilder.Entity("BTCPayServer.Data.StoreRole", b =>
{
b.Navigation("Users");
});
modelBuilder.Entity("BTCPayServer.Data.WalletData", b => modelBuilder.Entity("BTCPayServer.Data.WalletData", b =>
{ {
b.Navigation("WalletTransactions"); b.Navigation("WalletTransactions");

View File

@ -1,5 +1,6 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Controllers; using BTCPayServer.Controllers;
using BTCPayServer.Data; using BTCPayServer.Data;
@ -55,7 +56,7 @@ namespace BTCPayServer.Tests
Assert.Empty(appList2.Apps); Assert.Empty(appList2.Apps);
Assert.Equal("test", appList.Apps[0].AppName); Assert.Equal("test", appList.Apps[0].AppName);
Assert.Equal(apps.CreatedAppId, appList.Apps[0].Id); 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.Equal(user.StoreId, appList.Apps[0].StoreId);
Assert.IsType<NotFoundResult>(apps2.DeleteApp(appList.Apps[0].Id)); Assert.IsType<NotFoundResult>(apps2.DeleteApp(appList.Apps[0].Id));
Assert.IsType<ViewResult>(apps.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.Custodian.Client.MockCustodian;
using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs; using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@ -228,7 +229,7 @@ namespace BTCPayServer.Tests
await Assert.ThrowsAsync<GreenfieldAPIException>(() => newUserClient.GetInvoices(store.Id)); await Assert.ThrowsAsync<GreenfieldAPIException>(() => newUserClient.GetInvoices(store.Id));
// if user is a guest or owner, then it should be ok // 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); 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 // We strip the user's Owner right, so the key should not work
using var ctx = tester.PayTester.GetService<Data.ApplicationDbContextFactory>().CreateContext(); using var ctx = tester.PayTester.GetService<Data.ApplicationDbContextFactory>().CreateContext();
var storeEntity = await ctx.UserStore.SingleAsync(u => u.ApplicationUserId == user.UserId && u.StoreDataId == newStore.Id); 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 ctx.SaveChangesAsync();
await AssertHttpError(403, async () => await client.UpdateStore(newStore.Id, new UpdateStoreRequest() { Name = "B" })); 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 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 users = await client.GetStoreUsers(user.StoreId);
var storeuser = Assert.Single(users); var storeuser = Assert.Single(users);
Assert.Equal(user.UserId, storeuser.UserId); Assert.Equal(user.UserId, storeuser.UserId);
Assert.Equal(StoreRoles.Owner, storeuser.Role); Assert.Equal(ownerRole.Id, storeuser.Role);
var user2 = tester.NewAccount(); var user2 = tester.NewAccount();
await user2.GrantAccessAsync(false); 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.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.RemoveStoreUser(user.StoreId, user.UserId)); 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 //test no access to api when only a guest
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.GetStoreUsers(user.StoreId)); await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.GetStoreUsers(user.StoreId));
@ -3394,10 +3401,10 @@ namespace BTCPayServer.Tests
await user2Client.GetStore(user.StoreId)); 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 AssertAPIError("duplicate-store-user-role", async () =>
await client.AddStoreUser(user.StoreId, 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); await user2Client.RemoveStoreUser(user.StoreId, user.UserId);

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Buffers;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
@ -1741,11 +1742,12 @@ namespace BTCPayServer.Tests
{ {
s.Driver.Navigate().Refresh(); s.Driver.Navigate().Refresh();
Assert.Contains("transaction-label", s.Driver.PageSource); 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.GoToStore(s.StoreId, StoreNavPages.Payouts);
s.Driver.FindElement(By.Id($"{PayoutState.InProgress}-view")).Click(); s.Driver.FindElement(By.Id($"{PayoutState.InProgress}-view")).Click();
@ -2468,6 +2470,145 @@ retry:
Assert.StartsWith(s.ServerUri.ToString(), s.Driver.Url); 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) private static void CanBrowseContent(SeleniumTester s)
{ {

View File

@ -470,7 +470,10 @@ namespace BTCPayServer.Tests
var req = await _server.GetNextRequest(cancellation); var req = await _server.GetNextRequest(cancellation);
var bytes = await req.Request.Body.ReadBytesAsync((int)req.Request.Headers.ContentLength); var bytes = await req.Request.Body.ReadBytesAsync((int)req.Request.Headers.ContentLength);
var callback = Encoding.UTF8.GetString(bytes); var callback = Encoding.UTF8.GetString(bytes);
_webhookEvents.Add(JsonConvert.DeserializeObject<WebhookInvoiceEvent>(callback)); lock (_webhookEvents)
{
_webhookEvents.Add(JsonConvert.DeserializeObject<WebhookInvoiceEvent>(callback));
}
req.Response.StatusCode = 200; req.Response.StatusCode = 200;
_server.Done(); _server.Done();
} }
@ -487,18 +490,21 @@ namespace BTCPayServer.Tests
{ {
int retry = 0; int retry = 0;
retry: retry:
foreach (var evt in WebhookEvents) lock (WebhookEvents)
{ {
if (evt.Type == eventType) foreach (var evt in WebhookEvents)
{ {
var typedEvt = evt.ReadAs<TEvent>(); if (evt.Type == eventType)
try
{
assert(typedEvt);
return typedEvt;
}
catch (XunitException)
{ {
var typedEvt = evt.ReadAs<TEvent>();
try
{
assert(typedEvt);
return typedEvt;
}
catch (XunitException)
{
}
} }
} }
} }
@ -540,12 +546,12 @@ retry:
public async Task AddGuest(string userId) public async Task AddGuest(string userId)
{ {
var repo = this.parent.PayTester.GetService<StoreRepository>(); 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) public async Task AddOwner(string userId)
{ {
var repo = this.parent.PayTester.GetService<StoreRepository>(); 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.Empty(appList2.Apps);
Assert.Equal("test", appList.Apps[0].AppName); Assert.Equal("test", appList.Apps[0].AppName);
Assert.Equal(apps.CreatedAppId, appList.Apps[0].Id); 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.Equal(user.StoreId, appList.Apps[0].StoreId);
Assert.IsType<NotFoundResult>(apps2.DeleteApp(appList.Apps[0].Id)); Assert.IsType<NotFoundResult>(apps2.DeleteApp(appList.Apps[0].Id));
Assert.IsType<ViewResult>(apps.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 vm.Apps = apps.Select(a => new StoreApp
{ {
Id = a.Id, Id = a.Id,
IsOwner = a.IsOwner,
AppName = a.AppName, AppName = a.AppName,
AppType = a.AppType AppType = a.AppType
}).ToList(); }).ToList();

View File

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

View File

@ -1,6 +1,7 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.Extensions @using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.Contracts @using BTCPayServer.Abstractions.Contracts
@using BTCPayServer.Client
@using BTCPayServer.Services @using BTCPayServer.Services
@inject SignInManager<ApplicationUser> SignInManager @inject SignInManager<ApplicationUser> SignInManager
@inject BTCPayServerEnvironment Env @inject BTCPayServerEnvironment Env
@ -29,13 +30,11 @@
{ {
<a asp-controller="UIHome" asp-action="Index" id="StoreSelectorHome" class="navbar-brand py-2">@{await LogoContent();}</a> <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 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()) @if (Model.Options.Any())
{ {

View File

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

View File

@ -8,7 +8,6 @@ namespace BTCPayServer.Components.StoreSelector
public string CurrentStoreId { get; set; } public string CurrentStoreId { get; set; }
public string CurrentStoreLogoFileId { get; set; } public string CurrentStoreLogoFileId { get; set; }
public string CurrentDisplayName { get; set; } public string CurrentDisplayName { get; set; }
public bool CurrentStoreIsOwner { get; set; }
} }
public class StoreSelectorOption 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(); return StoreNotFound();
} }
//we do not need to validate the role string as any value other than `StoreRoles.Owner` is currently treated like a guest StoreRoleId roleId = null;
if (await _storeRepository.AddStoreUser(storeId, request.UserId, request.Role))
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(); return Ok();
} }
@ -74,7 +85,7 @@ namespace BTCPayServer.Controllers.Greenfield
private IEnumerable<StoreUserData> FromModel(Data.StoreData data) 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() private IActionResult StoreNotFound()
{ {

View File

@ -1319,5 +1319,27 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
return GetFromActionResult<CrowdfundAppData>(await GetController<GreenfieldAppsController>().GetCrowdfundApp(appId)); 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.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
@ -9,30 +8,18 @@ using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Components.StoreSelector;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Filters; using BTCPayServer.Filters;
using BTCPayServer.HostedServices;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Security;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using ExchangeSharp;
using Google.Apis.Auth.OAuth2;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.Payment;
using NBitpayClient;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@ -92,13 +79,13 @@ namespace BTCPayServer.Controllers
var store = await _storeRepository.FindStore(storeId, userId); var store = await _storeRepository.FindStore(storeId, userId);
if (store != null) if (store != null)
{ {
return RedirectToStore(store); return RedirectToStore(userId, store);
} }
} }
var stores = await _storeRepository.GetStoresByUserId(userId); var stores = await _storeRepository.GetStoresByUserId(userId);
return stores.Any() return stores.Any()
? RedirectToStore(stores.First()) ? RedirectToStore(userId, stores.First())
: RedirectToAction(nameof(UIUserStoresController.CreateStore), "UIUserStores"); : RedirectToAction(nameof(UIUserStoresController.CreateStore), "UIUserStores");
} }
@ -211,9 +198,9 @@ namespace BTCPayServer.Controllers
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); 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("Dashboard", "UIStores", new { storeId = store.Id })
: RedirectToAction("ListInvoices", "UIInvoice", new { storeId = store.Id }); : RedirectToAction("ListInvoices", "UIInvoice", new { storeId = store.Id });
} }

View File

@ -638,7 +638,7 @@ namespace BTCPayServer.Controllers
} }
if (explorer is null) if (explorer is null)
return NotSupported("This feature is only available to BTC wallets"); return NotSupported("This feature is only available to BTC wallets");
if (this.GetCurrentStore().Role != StoreRoles.Owner) if (!GetCurrentStore().HasPermission(GetUserId(), Policies.CanModifyStoreSettings))
return Forbid(); return Forbid();
var derivationScheme = (this.GetCurrentStore().GetDerivationSchemeSettings(_NetworkProvider, network.CryptoCode))?.AccountDerivation; 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() public async Task<IActionResult> StoreUsers()
{ {
StoreUsersViewModel vm = new StoreUsersViewModel(); StoreUsersViewModel vm = new StoreUsersViewModel();
vm.Role = StoreRoleId.Guest.Role;
await FillUsers(vm); await FillUsers(vm);
return View(vm); return View(vm);
} }
@ -142,7 +143,7 @@ namespace BTCPayServer.Controllers
{ {
Email = u.Email, Email = u.Email,
Id = u.Id, Id = u.Id,
Role = u.Role Role = u.StoreRole.Role
}).ToList(); }).ToList();
} }
@ -150,7 +151,7 @@ namespace BTCPayServer.Controllers
[HttpPost] [HttpPost]
[Route("{storeId}/users")] [Route("{storeId}/users")]
public async Task<IActionResult> StoreUsers(StoreUsersViewModel vm) public async Task<IActionResult> StoreUsers(string storeId, StoreUsersViewModel vm)
{ {
await FillUsers(vm); await FillUsers(vm);
if (!ModelState.IsValid) if (!ModelState.IsValid)
@ -163,12 +164,16 @@ namespace BTCPayServer.Controllers
ModelState.AddModelError(nameof(vm.Email), "User not found"); ModelState.AddModelError(nameof(vm.Email), "User not found");
return View(vm); 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"); ModelState.AddModelError(nameof(vm.Role), "Invalid role");
return View(vm); 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"); ModelState.AddModelError(nameof(vm.Email), "The user already has access to this store");
return View(vm); return View(vm);
@ -938,8 +943,9 @@ namespace BTCPayServer.Controllers
ViewBag.HidePublicKey = true; ViewBag.HidePublicKey = true;
ViewBag.ShowStores = true; ViewBag.ShowStores = true;
ViewBag.ShowMenu = false; ViewBag.ShowMenu = false;
var stores = await _Repo.GetStoresByUserId(userId); var stores = (await _Repo.GetStoresByUserId(userId)).Where(data => data.HasPermission(userId, Policies.CanModifyStoreSettings)).ToArray();
model.Stores = new SelectList(stores.Where(s => s.Role == StoreRoles.Owner), nameof(CurrentStore.Id), nameof(CurrentStore.StoreName));
model.Stores = new SelectList(stores, nameof(CurrentStore.Id), nameof(CurrentStore.StoreName));
if (!model.Stores.Any()) if (!model.Stores.Any())
{ {
TempData[WellKnownTempData.ErrorMessage] = "You need to be owner of at least one store before pairing"; 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"); 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 return View(new PairingModel
{ {
Id = pairing.Id, Id = pairing.Id,
Label = pairing.Label, Label = pairing.Label,
SIN = pairing.SIN ?? "Server-Initiated Pairing", SIN = pairing.SIN ?? "Server-Initiated Pairing",
StoreId = selectedStore ?? stores.FirstOrDefault()?.Id, 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, Id = s.Id,
Name = string.IsNullOrEmpty(s.StoreName) ? s.Id : s.StoreName Name = string.IsNullOrEmpty(s.StoreName) ? s.Id : s.StoreName

View File

@ -189,11 +189,7 @@ namespace BTCPayServer.Controllers
ListWalletsViewModel.WalletViewModel walletVm = new ListWalletsViewModel.WalletViewModel(); ListWalletsViewModel.WalletViewModel walletVm = new ListWalletsViewModel.WalletViewModel();
wallets.Wallets.Add(walletVm); wallets.Wallets.Add(walletVm);
walletVm.Balance = await wallet.Balance + " " + wallet.Wallet.Network.CryptoCode; 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.CryptoCode = wallet.Network.CryptoCode;
walletVm.StoreId = wallet.Store.Id; walletVm.StoreId = wallet.Store.Id;

View File

@ -74,6 +74,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
.Include(data => data.PullPaymentData) .Include(data => data.PullPaymentData)
.ThenInclude(data => data.StoreData) .ThenInclude(data => data.StoreData)
.ThenInclude(data => data.UserStores) .ThenInclude(data => data.UserStores)
.ThenInclude(data => data.StoreRole)
.Where(data => .Where(data =>
payoutIds.Contains(data.Id) && payoutIds.Contains(data.Id) &&
data.State == PayoutState.AwaitingPayment && data.State == PayoutState.AwaitingPayment &&
@ -84,7 +85,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
if (approvedStores.TryGetValue(payout.PullPaymentData.StoreId, out var value)) if (approvedStores.TryGetValue(payout.PullPaymentData.StoreId, out var value))
return value; return value;
value = payout.PullPaymentData.StoreData.UserStores 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); approvedStores.Add(payout.PullPaymentData.StoreId, value);
return value; return value;
}).ToList(); }).ToList();

View File

@ -16,32 +16,6 @@ namespace BTCPayServer.Data
{ {
public static class StoreDataExtensions 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 #pragma warning disable CS0618
public static PaymentMethodId? GetDefaultPaymentId(this StoreData storeData) public static PaymentMethodId? GetDefaultPaymentId(this StoreData storeData)

View File

@ -1,11 +1,40 @@
#nullable enable #nullable enable
using System.Linq; using System.Linq;
using BTCPayServer.Client;
using BTCPayServer.Data; using BTCPayServer.Data;
namespace BTCPayServer namespace BTCPayServer
{ {
public static class StoreExtensions 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) public static DerivationSchemeSettings? GetDerivationSchemeSettings(this StoreData store, BTCPayNetworkProvider networkProvider, string cryptoCode)
{ {
var paymentMethod = store var paymentMethod = store

View File

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

View File

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

View File

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using BTCPayServer.Services.Stores;
namespace BTCPayServer.Models.ServerViewModels namespace BTCPayServer.Models.ServerViewModels
{ {
@ -20,5 +21,11 @@ namespace BTCPayServer.Models.ServerViewModels
public override int CurrentPageCount => Users.Count; public override int CurrentPageCount => Users.Count;
public Dictionary<string, string> Roles { get; set; } 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 Role { get; set; }
public string Id { get; set; } public string Id { get; set; }
} }
public StoreUsersViewModel()
{
Role = StoreRoles.Guest;
}
[Required] [Required]
[EmailAddress] [EmailAddress]
public string Email { get; set; } public string Email { get; set; }

View File

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

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Configuration; using BTCPayServer.Configuration;
using BTCPayServer.Data; using BTCPayServer.Data;
@ -57,7 +58,7 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Au
var lightningSupportedPaymentMethod = (LightningSupportedPaymentMethod)paymentMethod; var lightningSupportedPaymentMethod = (LightningSupportedPaymentMethod)paymentMethod;
if (lightningSupportedPaymentMethod.IsInternalNode && if (lightningSupportedPaymentMethod.IsInternalNode &&
!(await Task.WhenAll((await _storeRepository.GetStoreUsers(_PayoutProcesserSettings.StoreId)) !(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)) .Select(s => _userService.IsAdminUser(s)))).Any(b => b))
{ {
return; return;

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events; using BTCPayServer.Events;
using BTCPayServer.Migrations; using BTCPayServer.Migrations;
@ -18,6 +19,7 @@ namespace BTCPayServer.Services.Stores
{ {
private readonly ApplicationDbContextFactory _ContextFactory; private readonly ApplicationDbContextFactory _ContextFactory;
private readonly EventAggregator _eventAggregator; private readonly EventAggregator _eventAggregator;
private readonly SettingsRepository _settingsRepository;
public JsonSerializerSettings SerializerSettings { get; } public JsonSerializerSettings SerializerSettings { get; }
@ -25,10 +27,11 @@ namespace BTCPayServer.Services.Stores
{ {
return _ContextFactory.CreateContext(); 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)); _ContextFactory = contextFactory ?? throw new ArgumentNullException(nameof(contextFactory));
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
_settingsRepository = settingsRepository;
SerializerSettings = serializerSettings; SerializerSettings = serializerSettings;
} }
@ -45,56 +48,168 @@ namespace BTCPayServer.Services.Stores
{ {
ArgumentNullException.ThrowIfNull(userId); ArgumentNullException.ThrowIfNull(userId);
await using var ctx = _ContextFactory.CreateContext(); await using var ctx = _ContextFactory.CreateContext();
return (await ctx return await ctx
.UserStore .UserStore
.Where(us => us.ApplicationUserId == userId && us.StoreDataId == storeId) .Where(us => us.ApplicationUserId == userId && us.StoreDataId == storeId)
.Include(store => store.StoreData.UserStores) .Include(store => store.StoreData.UserStores)
.Select(us => new .ThenInclude(store => store.StoreRole)
{ .Select(us => us.StoreData).FirstOrDefaultAsync();
Store = us.StoreData,
Role = us.Role
}).ToArrayAsync())
.Select(us =>
{
us.Store.Role = us.Role;
return us.Store;
}).FirstOrDefault();
} }
#nullable disable #nullable disable
public class StoreUser public class StoreUser
{ {
public string Id { get; set; } public string Id { get; set; }
public string Email { 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 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 #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) public async Task<StoreUser[]> GetStoreUsers(string storeId)
{ {
ArgumentNullException.ThrowIfNull(storeId); ArgumentNullException.ThrowIfNull(storeId);
using var ctx = _ContextFactory.CreateContext(); await using var ctx = _ContextFactory.CreateContext();
return await ctx return (await
.UserStore ctx
.Where(u => u.StoreDataId == storeId) .UserStore
.Select(u => new StoreUser() .Where(u => u.StoreDataId == storeId)
{ .Include(u => u.StoreRole)
Id = u.ApplicationUserId, .Select(u => new
Email = u.ApplicationUser.Email, {
Role = u.Role Id = u.ApplicationUserId,
}).ToArrayAsync(); 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) 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 return (await ctx.UserStore
.Where(u => u.ApplicationUserId == userId && (storeIds == null || storeIds.Contains(u.StoreDataId))) .Where(u => u.ApplicationUserId == userId && (storeIds == null || storeIds.Contains(u.StoreDataId)))
.Select(u => new { u.StoreData, u.Role }) .Include(store => store.StoreData)
.ToArrayAsync()) .ThenInclude(data => data.UserStores)
.Select(u => .ThenInclude(data => data.StoreRole)
{ .Select(store => store.StoreData)
u.StoreData.Role = u.Role; .ToArrayAsync());
return u.StoreData;
}).ToArray();
} }
public async Task<StoreData?> GetStoreByInvoiceId(string invoiceId) public async Task<StoreData?> GetStoreByInvoiceId(string invoiceId)
@ -105,29 +220,68 @@ namespace BTCPayServer.Services.Stores
return matched?.StoreData; 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(); if (string.IsNullOrWhiteSpace(role))
var userStore = new UserStore() { StoreDataId = storeId, ApplicationUserId = userId, Role = 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); ctx.UserStore.Add(userStore);
try try
{ {
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
return true; return true;
} }
catch (Microsoft.EntityFrameworkCore.DbUpdateException) catch (DbUpdateException)
{ {
return false; 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() public async Task CleanUnreachableStores()
{ {
await using var ctx = _ContextFactory.CreateContext(); await using var ctx = _ContextFactory.CreateContext();
if (!ctx.Database.SupportDropForeignKey()) if (!ctx.Database.SupportDropForeignKey())
return; return;
var events = new List<Events.StoreRemovedEvent>(); 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); ctx.Stores.Remove(store);
events.Add(new Events.StoreRemovedEvent(store.Id)); events.Add(new Events.StoreRemovedEvent(store.Id));
@ -139,8 +293,8 @@ namespace BTCPayServer.Services.Stores
public async Task<bool> RemoveStoreUser(string storeId, string userId) public async Task<bool> RemoveStoreUser(string storeId, string userId)
{ {
await using var ctx = _ContextFactory.CreateContext(); await using var ctx = _ContextFactory.CreateContext();
if (!await ctx.UserStore.AnyAsync(store => if (!await ctx.UserStore.Include(store => store.StoreRole).AnyAsync(store =>
store.StoreDataId == storeId && store.Role == StoreRoles.Owner && store.StoreDataId == storeId && store.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings) &&
userId != store.ApplicationUserId)) userId != store.ApplicationUserId))
return false; return false;
var userStore = new UserStore() { StoreDataId = storeId, ApplicationUserId = userId }; var userStore = new UserStore() { StoreDataId = storeId, ApplicationUserId = userId };
@ -156,7 +310,7 @@ namespace BTCPayServer.Services.Stores
await using var ctx = _ContextFactory.CreateContext(); await using var ctx = _ContextFactory.CreateContext();
if (ctx.Database.SupportDropForeignKey()) 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); var store = await ctx.Stores.FindAsync(storeId);
if (store != null) 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)) if (!string.IsNullOrEmpty(storeData.Id))
throw new ArgumentException("id should be empty", nameof(storeData.StoreName)); throw new ArgumentException("id should be empty", nameof(storeData.StoreName));
if (string.IsNullOrEmpty(storeData.StoreName)) if (string.IsNullOrEmpty(storeData.StoreName))
throw new ArgumentException("name should not be empty", nameof(storeData.StoreName)); throw new ArgumentException("name should not be empty", nameof(storeData.StoreName));
ArgumentNullException.ThrowIfNull(ownerId); 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)); storeData.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(32));
roleId ??= await GetDefaultRole();
var userStore = new UserStore var userStore = new UserStore
{ {
StoreDataId = storeData.Id, StoreDataId = storeData.Id,
ApplicationUserId = ownerId, ApplicationUserId = ownerId,
Role = StoreRoles.Owner, StoreRoleId = roleId.Id,
}; };
ctx.Add(storeData); ctx.Add(storeData);
@ -234,7 +391,7 @@ namespace BTCPayServer.Services.Stores
.Where(s => s.StoreId == storeId && s.WebhookId == webhookId) .Where(s => s.StoreId == storeId && s.WebhookId == webhookId)
.SelectMany(s => s.Webhook.Deliveries) .SelectMany(s => s.Webhook.Deliveries)
.OrderByDescending(s => s.Timestamp); .OrderByDescending(s => s.Timestamp);
if (count is int c) if (count is { } c)
req = req.Take(c); req = req.Take(c);
return await req return await req
.ToArrayAsync(); .ToArrayAsync();
@ -431,4 +588,66 @@ retry:
return ctx.Database.SupportDropForeignKey(); 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;
using System.Collections.Generic;
namespace BTCPayServer namespace BTCPayServer
{ {
public class StoreRoles public class StoreRoles
{ {
[Obsolete("You should check authorization policies instead of roles")]
public const string Owner = "Owner"; public const string Owner = "Owner";
[Obsolete("You should check authorization policies instead of roles")]
public const string Guest = "Guest"; 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.Services.Apps
@using BTCPayServer.Abstractions.Models @using BTCPayServer.Abstractions.Models
@using BTCPayServer.Client
@model ListAppsViewModel @model ListAppsViewModel
@inject AppService AppService @inject AppService AppService
@{ @{
@ -79,14 +80,11 @@
{ {
<tr> <tr>
<td> <td>
@if (app.IsOwner) <span permission="@Policies.CanModifyStoreSettings">
{ <a asp-controller="UIStores" asp-action="GeneralSettings" asp-route-storeId="@app.StoreId">@app.StoreName</a>
<span><a asp-controller="UIStores" asp-action="GeneralSettings" asp-route-storeId="@app.StoreId">@app.StoreName</a></span> </span>
}
else <span not-permission="@Policies.CanModifyStoreSettings">@app.StoreName</span>
{
<span>@app.StoreName</span>
}
</td> </td>
<td>@app.AppName</td> <td>@app.AppName</td>
<td> <td>
@ -100,14 +98,15 @@
<span>@viewStyle</span> <span>@viewStyle</span>
} }
</td> </td>
<td class="text-end"> <td class="text-end" permission="@Policies.CanModifyStoreSettings">
@if (app.IsOwner)
{
<a asp-action="@app.UpdateAction" asp-controller="UIApps" asp-route-appId="@app.Id" asp-route-storeId="@app.StoreId">Settings</a> <a asp-action="@app.UpdateAction" asp-controller="UIApps" asp-route-appId="@app.Id" asp-route-storeId="@app.StoreId">Settings</a>
<span> - </span> <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> <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>
<td class="text-end" no-permission="@Policies.CanModifyStoreSettings">
</td>
</tr> </tr>
} }
</tbody> </tbody>

View File

@ -2,6 +2,7 @@ namespace BTCPayServer.Views.Server
{ {
public enum ServerNavPages 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"> <nav id="SectionNav">
<div class="nav"> <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.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.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.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> <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")] [Obsolete("Use StoreNavPages.Plugins instead")]
Integrations, Integrations,
Emails, Emails,
Forms Forms,
Roles
} }
} }

View File

@ -1,8 +1,13 @@
@using BTCPayServer.Abstractions.Models @using BTCPayServer.Abstractions.Models
@using BTCPayServer.Services.Stores
@using BTCPayServer.Abstractions.Contracts
@model StoreUsersViewModel @model StoreUsersViewModel
@inject IScopeProvider ScopeProvider
@inject StoreRepository StoreRepository
@{ @{
Layout = "../Shared/_NavLayout.cshtml"; Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.Users, "Store Users", Context.GetStoreData().Id); 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"> <div class="row">
@ -24,9 +29,7 @@
<input asp-for="Email" type="text" class="form-control" placeholder="user@example.com"> <input asp-for="Email" type="text" class="form-control" placeholder="user@example.com">
</div> </div>
<div class="ms-3"> <div class="ms-3">
<select asp-for="Role" class="form-select"> <select asp-for="Role" class="form-select" asp-items="roles">
<option value="@StoreRoles.Owner">Owner</option>
<option value="@StoreRoles.Guest">Guest</option>
</select> </select>
</div> </div>
<div class="ms-3"> <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.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.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.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.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.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> <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"); ViewData.SetActivePage(WalletsNavPages.Index, "Wallets");
} }
@ -42,16 +43,18 @@
@foreach (var wallet in Model.Wallets) @foreach (var wallet in Model.Wallets)
{ {
<tr> <tr>
@if (wallet.IsOwner)
{ <td>
<td><a asp-action="GeneralSettings" asp-controller="UIStores" asp-route-storeId="@wallet.StoreId">@wallet.StoreName</a></td> <a
} permission="@Policies.CanModifyStoreSettings"
else asp-action="GeneralSettings" asp-controller="UIStores" asp-route-storeId="@wallet.StoreId">
{ @wallet.StoreName
<td>@wallet.StoreName</td> </a>
} <span not-permission="@Policies.CanModifyStoreSettings">@wallet.StoreName</span>
</td>
<td>@wallet.CryptoCode</td> <td>@wallet.CryptoCode</td>
<td>@wallet.Balance</td> <td><span permission="@Policies.CanModifyStoreSettings">@wallet.Balance</span></td>
<td class="text-end"> <td class="text-end">
<a asp-action="WalletTransactions" asp-route-walletId="@wallet.Id">Manage</a> <a asp-action="WalletTransactions" asp-route-walletId="@wallet.Id">Manage</a>
</td> </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": { "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": { "components": {
"schemas": { "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": { "StoreDataList": {
"type": "array", "type": "array",
"items": { "items": {
@ -463,7 +545,7 @@
"nullable": true, "nullable": true,
"items": { "items": {
"$ref": "#/components/schemas/PaymentMethodCriteriaData" "$ref": "#/components/schemas/PaymentMethodCriteriaData"
}, },
"description": "The criteria required to activate specific payment methods." "description": "The criteria required to activate specific payment methods."
} }
} }