mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-03-11 01:35:22 +01:00
Refactor permissions of GreenField
This commit is contained in:
parent
eac33d494a
commit
29a807696b
31 changed files with 581 additions and 404 deletions
|
@ -5,25 +5,183 @@ using System.Text.Json.Serialization;
|
|||
|
||||
namespace BTCPayServer.Client
|
||||
{
|
||||
public static class Permissions
|
||||
public class Permission
|
||||
{
|
||||
public const string ServerManagement = nameof(ServerManagement);
|
||||
public const string StoreManagement = nameof(StoreManagement);
|
||||
public const string ProfileManagement = nameof(ProfileManagement);
|
||||
public const string CanModifyServerSettings = "btcpay.server.canmodifyserversettings";
|
||||
public const string CanModifyStoreSettings = "btcpay.store.canmodifystoresettings";
|
||||
public const string CanViewStoreSettings = "btcpay.store.canviewstoresettings";
|
||||
public const string CanCreateInvoice = "btcpay.store.cancreateinvoice";
|
||||
public const string CanModifyProfile = "btcpay.user.canmodifyprofile";
|
||||
public const string CanViewProfile = "btcpay.user.canviewprofile";
|
||||
public const string CanCreateUser = "btcpay.server.cancreateuser";
|
||||
public const string Unrestricted = "unrestricted";
|
||||
|
||||
public static string[] GetAllPermissionKeys()
|
||||
public static IEnumerable<string> AllPolicies
|
||||
{
|
||||
return new[]
|
||||
get
|
||||
{
|
||||
ServerManagement,
|
||||
StoreManagement,
|
||||
ProfileManagement
|
||||
};
|
||||
yield return CanCreateInvoice;
|
||||
yield return CanModifyServerSettings;
|
||||
yield return CanModifyStoreSettings;
|
||||
yield return CanViewStoreSettings;
|
||||
yield return CanModifyProfile;
|
||||
yield return CanViewProfile;
|
||||
yield return CanCreateUser;
|
||||
yield return Unrestricted;
|
||||
}
|
||||
}
|
||||
public static string GetStorePermission(string storeId) => $"{nameof(StoreManagement)}:{storeId}";
|
||||
|
||||
public static IEnumerable<string> ExtractStorePermissionsIds(IEnumerable<string> permissions) => permissions
|
||||
.Where(s => s.StartsWith($"{nameof(StoreManagement)}:", StringComparison.InvariantCulture))
|
||||
.Select(s => s.Split(":")[1]);
|
||||
public static Permission Create(string policy, string storeId = null)
|
||||
{
|
||||
if (TryCreatePermission(policy, storeId, out var r))
|
||||
return r;
|
||||
throw new ArgumentException("Invalid Permission");
|
||||
}
|
||||
|
||||
public static bool TryCreatePermission(string policy, string storeId, out Permission permission)
|
||||
{
|
||||
permission = null;
|
||||
if (policy == null)
|
||||
throw new ArgumentNullException(nameof(policy));
|
||||
policy = policy.Trim().ToLowerInvariant();
|
||||
if (!IsValidPolicy(policy))
|
||||
return false;
|
||||
if (storeId != null && !IsStorePolicy(policy))
|
||||
return false;
|
||||
permission = new Permission(policy, storeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryParse(string str, out Permission permission)
|
||||
{
|
||||
permission = null;
|
||||
if (str == null)
|
||||
throw new ArgumentNullException(nameof(str));
|
||||
str = str.Trim();
|
||||
var separator = str.IndexOf(':');
|
||||
if (separator == -1)
|
||||
{
|
||||
str = str.ToLowerInvariant();
|
||||
if (!IsValidPolicy(str))
|
||||
return false;
|
||||
permission = new Permission(str, null);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var policy = str.Substring(0, separator).ToLowerInvariant();
|
||||
if (!IsValidPolicy(policy))
|
||||
return false;
|
||||
if (!IsStorePolicy(policy))
|
||||
return false;
|
||||
var storeId = str.Substring(separator + 1);
|
||||
if (storeId.Length == 0)
|
||||
return false;
|
||||
permission = new Permission(policy, storeId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValidPolicy(string policy)
|
||||
{
|
||||
return AllPolicies.Any(p => p.Equals(policy, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsStorePolicy(string policy)
|
||||
{
|
||||
return policy.StartsWith("btcpay.store", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
internal Permission(string policy, string storeId)
|
||||
{
|
||||
Policy = policy;
|
||||
StoreId = storeId;
|
||||
}
|
||||
|
||||
public bool Contains(Permission subpermission)
|
||||
{
|
||||
if (subpermission is null)
|
||||
throw new ArgumentNullException(nameof(subpermission));
|
||||
|
||||
if (!ContainsPolicy(subpermission.Policy))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!IsStorePolicy(subpermission.Policy))
|
||||
return true;
|
||||
return StoreId == null || subpermission.StoreId == this.StoreId;
|
||||
}
|
||||
|
||||
public static IEnumerable<Permission> ToPermissions(string[] permissions)
|
||||
{
|
||||
if (permissions == null)
|
||||
throw new ArgumentNullException(nameof(permissions));
|
||||
foreach (var p in permissions)
|
||||
{
|
||||
if (TryParse(p, out var pp))
|
||||
yield return pp;
|
||||
}
|
||||
}
|
||||
public static IEnumerable<Permission> ToPermissions(string permissionsFormatted)
|
||||
{
|
||||
foreach(var part in permissionsFormatted.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (Permission.TryParse(part, out var p))
|
||||
yield return p;
|
||||
}
|
||||
}
|
||||
|
||||
private bool ContainsPolicy(string subpolicy)
|
||||
{
|
||||
if (this.Policy == Unrestricted)
|
||||
return true;
|
||||
if (this.Policy == subpolicy)
|
||||
return true;
|
||||
if (subpolicy == CanViewStoreSettings && this.Policy == CanModifyStoreSettings)
|
||||
return true;
|
||||
if (subpolicy == CanCreateInvoice && this.Policy == CanModifyStoreSettings)
|
||||
return true;
|
||||
if (subpolicy == CanViewProfile && this.Policy == CanModifyProfile)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public string StoreId { get; }
|
||||
public string Policy { get; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
if (StoreId != null)
|
||||
{
|
||||
return $"{Policy}:{StoreId}";
|
||||
}
|
||||
return Policy;
|
||||
}
|
||||
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
Permission item = obj as Permission;
|
||||
if (item == null)
|
||||
return false;
|
||||
return ToString().Equals(item.ToString());
|
||||
}
|
||||
public static bool operator ==(Permission a, Permission b)
|
||||
{
|
||||
if (System.Object.ReferenceEquals(a, b))
|
||||
return true;
|
||||
if (((object)a == null) || ((object)b == null))
|
||||
return false;
|
||||
return a.ToString() == b.ToString();
|
||||
}
|
||||
|
||||
public static bool operator !=(Permission a, Permission b)
|
||||
{
|
||||
return !(a == b);
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return ToString().GetHashCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,13 +27,6 @@ namespace BTCPayServer.Data
|
|||
public StoreData StoreData { get; set; }
|
||||
public ApplicationUser User { get; set; }
|
||||
public string Label { get; set; }
|
||||
public string[] GetPermissions() { return Permissions?.Split(';') ?? new string[0]; }
|
||||
|
||||
public void SetPermissions(IEnumerable<string> permissions)
|
||||
{
|
||||
Permissions = string.Join(';',
|
||||
permissions?.Select(s => s.Replace(";", string.Empty)) ?? new string[0]);
|
||||
}
|
||||
}
|
||||
|
||||
public enum APIKeyType
|
||||
|
|
|
@ -42,49 +42,46 @@ namespace BTCPayServer.Tests
|
|||
|
||||
var user = tester.NewAccount();
|
||||
user.GrantAccess();
|
||||
|
||||
await user.CreateStoreAsync();
|
||||
await user.MakeAdmin(false);
|
||||
s.GoToLogin();
|
||||
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
|
||||
s.GoToProfile(ManageNavPages.APIKeys);
|
||||
s.Driver.FindElement(By.Id("AddApiKey")).Click();
|
||||
if (!user.IsAdmin)
|
||||
{
|
||||
//not an admin, so this permission should not show
|
||||
Assert.DoesNotContain("ServerManagementPermission", s.Driver.PageSource);
|
||||
await user.MakeAdmin();
|
||||
s.Logout();
|
||||
s.GoToLogin();
|
||||
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
|
||||
s.GoToProfile(ManageNavPages.APIKeys);
|
||||
s.Driver.FindElement(By.Id("AddApiKey")).Click();
|
||||
}
|
||||
|
||||
//not an admin, so this permission should not show
|
||||
Assert.DoesNotContain("btcpay.server.canmodifyserversettings", s.Driver.PageSource);
|
||||
await user.MakeAdmin();
|
||||
s.Logout();
|
||||
s.GoToLogin();
|
||||
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
|
||||
s.GoToProfile(ManageNavPages.APIKeys);
|
||||
s.Driver.FindElement(By.Id("AddApiKey")).Click();
|
||||
Assert.Contains("btcpay.server.canmodifyserversettings", s.Driver.PageSource);
|
||||
|
||||
//server management should show now
|
||||
s.SetCheckbox(s, "ServerManagementPermission", true);
|
||||
s.SetCheckbox(s, "StoreManagementPermission", true);
|
||||
s.SetCheckbox(s, "btcpay.server.canmodifyserversettings", true);
|
||||
s.SetCheckbox(s, "btcpay.store.canmodifystoresettings", true);
|
||||
s.Driver.FindElement(By.Id("Generate")).Click();
|
||||
var superApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text;
|
||||
|
||||
//this api key has access to everything
|
||||
await TestApiAgainstAccessToken(superApiKey, tester, user, Permissions.ServerManagement,
|
||||
Permissions.StoreManagement);
|
||||
await TestApiAgainstAccessToken(superApiKey, tester, user, $"{Permission.CanModifyServerSettings};{Permission.CanModifyStoreSettings}");
|
||||
|
||||
|
||||
s.Driver.FindElement(By.Id("AddApiKey")).Click();
|
||||
s.SetCheckbox(s, "ServerManagementPermission", true);
|
||||
s.SetCheckbox(s, "btcpay.server.canmodifyserversettings", true);
|
||||
s.Driver.FindElement(By.Id("Generate")).Click();
|
||||
var serverOnlyApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text;
|
||||
await TestApiAgainstAccessToken(serverOnlyApiKey, tester, user,
|
||||
Permissions.ServerManagement);
|
||||
Permission.CanModifyServerSettings);
|
||||
|
||||
|
||||
s.Driver.FindElement(By.Id("AddApiKey")).Click();
|
||||
s.SetCheckbox(s, "StoreManagementPermission", true);
|
||||
s.SetCheckbox(s, "btcpay.store.canmodifystoresettings", true);
|
||||
s.Driver.FindElement(By.Id("Generate")).Click();
|
||||
var allStoreOnlyApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text;
|
||||
await TestApiAgainstAccessToken(allStoreOnlyApiKey, tester, user,
|
||||
Permissions.StoreManagement);
|
||||
Permission.CanModifyStoreSettings);
|
||||
|
||||
s.Driver.FindElement(By.Id("AddApiKey")).Click();
|
||||
s.Driver.FindElement(By.CssSelector("button[value=change-store-mode]")).Click();
|
||||
|
@ -96,12 +93,12 @@ namespace BTCPayServer.Tests
|
|||
s.Driver.FindElement(By.Id("Generate")).Click();
|
||||
var selectiveStoreApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text;
|
||||
await TestApiAgainstAccessToken(selectiveStoreApiKey, tester, user,
|
||||
Permissions.GetStorePermission(storeId));
|
||||
Permission.Create(Permission.CanModifyStoreSettings, storeId).ToString());
|
||||
|
||||
s.Driver.FindElement(By.Id("AddApiKey")).Click();
|
||||
s.Driver.FindElement(By.Id("Generate")).Click();
|
||||
var noPermissionsApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text;
|
||||
await TestApiAgainstAccessToken(noPermissionsApiKey, tester, user);
|
||||
await TestApiAgainstAccessToken(noPermissionsApiKey, tester, user, string.Empty);
|
||||
|
||||
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
|
||||
{
|
||||
|
@ -118,13 +115,13 @@ namespace BTCPayServer.Tests
|
|||
//strict
|
||||
//selectiveStores
|
||||
var authUrl = BTCPayServerClient.GenerateAuthorizeUri(tester.PayTester.ServerUri,
|
||||
new[] {Permissions.StoreManagement, Permissions.ServerManagement}).ToString();
|
||||
new[] {Permission.CanModifyStoreSettings, Permission.CanModifyServerSettings}).ToString();
|
||||
s.Driver.Navigate().GoToUrl(authUrl);
|
||||
s.Driver.PageSource.Contains("kukksappname");
|
||||
Assert.Equal("hidden", s.Driver.FindElement(By.Id("StoreManagementPermission")).GetAttribute("type").ToLowerInvariant());
|
||||
Assert.Equal("true", s.Driver.FindElement(By.Id("StoreManagementPermission")).GetAttribute("value").ToLowerInvariant());
|
||||
Assert.Equal("hidden", s.Driver.FindElement(By.Id("ServerManagementPermission")).GetAttribute("type").ToLowerInvariant());
|
||||
Assert.Equal("true",s.Driver.FindElement(By.Id("ServerManagementPermission")).GetAttribute("value").ToLowerInvariant());
|
||||
Assert.Equal("hidden", s.Driver.FindElement(By.Id("btcpay.store.canmodifystoresettings")).GetAttribute("type").ToLowerInvariant());
|
||||
Assert.Equal("true", s.Driver.FindElement(By.Id("btcpay.store.canmodifystoresettings")).GetAttribute("value").ToLowerInvariant());
|
||||
Assert.Equal("hidden", s.Driver.FindElement(By.Id("btcpay.server.canmodifyserversettings")).GetAttribute("type").ToLowerInvariant());
|
||||
Assert.Equal("true",s.Driver.FindElement(By.Id("btcpay.server.canmodifyserversettings")).GetAttribute("value").ToLowerInvariant());
|
||||
Assert.DoesNotContain("change-store-mode", s.Driver.PageSource);
|
||||
s.Driver.FindElement(By.Id("consent-yes")).Click();
|
||||
var url = s.Driver.Url;
|
||||
|
@ -134,20 +131,20 @@ namespace BTCPayServer.Tests
|
|||
var apiKeyRepo = s.Server.PayTester.GetService<APIKeyRepository>();
|
||||
|
||||
await TestApiAgainstAccessToken(results.Single(pair => pair.Key == "key").Value, tester, user,
|
||||
(await apiKeyRepo.GetKey(results.Single(pair => pair.Key == "key").Value)).GetPermissions());
|
||||
(await apiKeyRepo.GetKey(results.Single(pair => pair.Key == "key").Value)).Permissions);
|
||||
|
||||
authUrl = BTCPayServerClient.GenerateAuthorizeUri(tester.PayTester.ServerUri,
|
||||
new[] {Permissions.StoreManagement, Permissions.ServerManagement}, false, true).ToString();
|
||||
new[] {Permission.CanModifyStoreSettings, Permission.CanModifyServerSettings}, false, true).ToString();
|
||||
|
||||
s.Driver.Navigate().GoToUrl(authUrl);
|
||||
Assert.DoesNotContain("kukksappname", s.Driver.PageSource);
|
||||
|
||||
Assert.Equal("checkbox", s.Driver.FindElement(By.Id("StoreManagementPermission")).GetAttribute("type").ToLowerInvariant());
|
||||
Assert.Equal("true", s.Driver.FindElement(By.Id("StoreManagementPermission")).GetAttribute("value").ToLowerInvariant());
|
||||
Assert.Equal("checkbox", s.Driver.FindElement(By.Id("ServerManagementPermission")).GetAttribute("type").ToLowerInvariant());
|
||||
Assert.Equal("true",s.Driver.FindElement(By.Id("ServerManagementPermission")).GetAttribute("value").ToLowerInvariant());
|
||||
Assert.Equal("checkbox", s.Driver.FindElement(By.Id("btcpay.store.canmodifystoresettings")).GetAttribute("type").ToLowerInvariant());
|
||||
Assert.Equal("true", s.Driver.FindElement(By.Id("btcpay.store.canmodifystoresettings")).GetAttribute("value").ToLowerInvariant());
|
||||
Assert.Equal("checkbox", s.Driver.FindElement(By.Id("btcpay.server.canmodifyserversettings")).GetAttribute("type").ToLowerInvariant());
|
||||
Assert.Equal("true",s.Driver.FindElement(By.Id("btcpay.server.canmodifyserversettings")).GetAttribute("value").ToLowerInvariant());
|
||||
|
||||
s.SetCheckbox(s, "ServerManagementPermission", false);
|
||||
s.SetCheckbox(s, "btcpay.server.canmodifyserversettings", false);
|
||||
Assert.Contains("change-store-mode", s.Driver.PageSource);
|
||||
s.Driver.FindElement(By.Id("consent-yes")).Click();
|
||||
url = s.Driver.Url;
|
||||
|
@ -155,14 +152,15 @@ namespace BTCPayServer.Tests
|
|||
.Select(s1 => new KeyValuePair<string, string>(s1.Split("=")[0], s1.Split("=")[1]));
|
||||
|
||||
await TestApiAgainstAccessToken(results.Single(pair => pair.Key == "key").Value, tester, user,
|
||||
(await apiKeyRepo.GetKey(results.Single(pair => pair.Key == "key").Value)).GetPermissions());
|
||||
(await apiKeyRepo.GetKey(results.Single(pair => pair.Key == "key").Value)).Permissions);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
async Task TestApiAgainstAccessToken(string accessToken, ServerTester tester, TestAccount testAccount,
|
||||
params string[] permissions)
|
||||
string permissionFormatted)
|
||||
{
|
||||
var permissions = Permission.ToPermissions(permissionFormatted);
|
||||
var resultUser =
|
||||
await TestApiAgainstAccessToken<string>(accessToken, $"{TestApiPath}/me/id",
|
||||
tester.PayTester.HttpClient);
|
||||
|
@ -172,49 +170,68 @@ namespace BTCPayServer.Tests
|
|||
var secondUser = tester.NewAccount();
|
||||
secondUser.GrantAccess();
|
||||
|
||||
var selectiveStorePermissions = Permissions.ExtractStorePermissionsIds(permissions);
|
||||
if (permissions.Contains(Permissions.StoreManagement) || selectiveStorePermissions.Any())
|
||||
var canModifyAllStores = Permission.Create(Permission.CanModifyStoreSettings, null);
|
||||
var canModifyServer = Permission.Create(Permission.CanModifyServerSettings, null);
|
||||
var unrestricted = Permission.Create(Permission.Unrestricted, null);
|
||||
var selectiveStorePermissions = permissions.Where(p => p.StoreId != null && p.Policy == Permission.CanModifyStoreSettings);
|
||||
if (permissions.Contains(canModifyAllStores) || selectiveStorePermissions.Any())
|
||||
{
|
||||
var resultStores =
|
||||
await TestApiAgainstAccessToken<StoreData[]>(accessToken, $"{TestApiPath}/me/stores",
|
||||
tester.PayTester.HttpClient);
|
||||
|
||||
foreach (string selectiveStorePermission in selectiveStorePermissions)
|
||||
foreach (var selectiveStorePermission in selectiveStorePermissions)
|
||||
{
|
||||
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/stores/{selectiveStorePermission}/can-edit",
|
||||
$"{TestApiPath}/me/stores/{selectiveStorePermission.StoreId}/can-edit",
|
||||
tester.PayTester.HttpClient));
|
||||
|
||||
Assert.Contains(resultStores,
|
||||
data => data.Id.Equals(selectiveStorePermission, StringComparison.InvariantCultureIgnoreCase));
|
||||
data => data.Id.Equals(selectiveStorePermission.StoreId, StringComparison.InvariantCultureIgnoreCase));
|
||||
}
|
||||
|
||||
if (permissions.Contains(Permissions.StoreManagement))
|
||||
bool shouldBeAuthorized = false;
|
||||
if (permissions.Contains(canModifyAllStores) || selectiveStorePermissions.Contains(Permission.Create(Permission.CanViewStoreSettings, testAccount.StoreId)))
|
||||
{
|
||||
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/stores/actions",
|
||||
$"{TestApiPath}/me/stores/{testAccount.StoreId}/can-view",
|
||||
tester.PayTester.HttpClient));
|
||||
Assert.Contains(resultStores,
|
||||
data => data.Id.Equals(testAccount.StoreId, StringComparison.InvariantCultureIgnoreCase));
|
||||
shouldBeAuthorized = true;
|
||||
}
|
||||
if (permissions.Contains(canModifyAllStores) || selectiveStorePermissions.Contains(Permission.Create(Permission.CanModifyStoreSettings, testAccount.StoreId)))
|
||||
{
|
||||
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/stores/{testAccount.StoreId}/can-view",
|
||||
tester.PayTester.HttpClient));
|
||||
|
||||
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/stores/{testAccount.StoreId}/can-edit",
|
||||
tester.PayTester.HttpClient));
|
||||
Assert.Contains(resultStores,
|
||||
data => data.Id.Equals(testAccount.StoreId, StringComparison.InvariantCultureIgnoreCase));
|
||||
shouldBeAuthorized = true;
|
||||
}
|
||||
else
|
||||
|
||||
if (!shouldBeAuthorized)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
|
||||
{
|
||||
await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/stores/actions",
|
||||
tester.PayTester.HttpClient);
|
||||
$"{TestApiPath}/me/stores/{testAccount.StoreId}/can-edit",
|
||||
tester.PayTester.HttpClient);
|
||||
});
|
||||
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
|
||||
{
|
||||
await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/stores/{testAccount.StoreId}/can-view",
|
||||
tester.PayTester.HttpClient);
|
||||
});
|
||||
Assert.DoesNotContain(resultStores,
|
||||
data => data.Id.Equals(testAccount.StoreId, StringComparison.InvariantCultureIgnoreCase));
|
||||
}
|
||||
|
||||
Assert.DoesNotContain(resultStores,
|
||||
data => data.Id.Equals(secondUser.StoreId, StringComparison.InvariantCultureIgnoreCase));
|
||||
}
|
||||
else if(!permissions.Contains(Permissions.ServerManagement))
|
||||
else if(!permissions.Contains(unrestricted))
|
||||
{
|
||||
|
||||
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
|
||||
|
@ -231,7 +248,7 @@ namespace BTCPayServer.Tests
|
|||
tester.PayTester.HttpClient);
|
||||
}
|
||||
|
||||
if (!permissions.Contains(Permissions.ServerManagement))
|
||||
if (!permissions.Contains(unrestricted))
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
|
||||
{
|
||||
|
@ -245,7 +262,7 @@ namespace BTCPayServer.Tests
|
|||
tester.PayTester.HttpClient);
|
||||
}
|
||||
|
||||
if (permissions.Contains(Permissions.ServerManagement))
|
||||
if (permissions.Contains(canModifyServer))
|
||||
{
|
||||
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/is-admin",
|
||||
|
|
|
@ -38,7 +38,7 @@ namespace BTCPayServer.Tests
|
|||
var user = tester.NewAccount();
|
||||
user.GrantAccess();
|
||||
await user.MakeAdmin();
|
||||
var client = await user.CreateClient(Permissions.ServerManagement, Permissions.StoreManagement);
|
||||
var client = await user.CreateClient(Permission.CanModifyServerSettings, Permission.CanModifyStoreSettings);
|
||||
//Get current api key
|
||||
var apiKeyData = await client.GetCurrentAPIKeyInfo();
|
||||
Assert.NotNull(apiKeyData);
|
||||
|
@ -97,14 +97,14 @@ namespace BTCPayServer.Tests
|
|||
var adminAcc = tester.NewAccount();
|
||||
adminAcc.UserId = admin.Id;
|
||||
adminAcc.IsAdmin = true;
|
||||
var adminClient = await adminAcc.CreateClient(Permissions.ProfileManagement);
|
||||
var adminClient = await adminAcc.CreateClient(Permission.CanModifyProfile);
|
||||
|
||||
// We should be forbidden to create a new user without proper admin permissions
|
||||
await AssertHttpError(403, async () => await adminClient.CreateUser(new CreateApplicationUserRequest() { Email = "test4@gmail.com", Password = "afewfoiewiou" }));
|
||||
await AssertHttpError(403, async () => await adminClient.CreateUser(new CreateApplicationUserRequest() { Email = "test4@gmail.com", Password = "afewfoiewiou", IsAdministrator = true }));
|
||||
|
||||
// However, should be ok with the server management permissions
|
||||
adminClient = await adminAcc.CreateClient(Permissions.ServerManagement);
|
||||
adminClient = await adminAcc.CreateClient(Permission.CanModifyServerSettings);
|
||||
await adminClient.CreateUser(new CreateApplicationUserRequest() { Email = "test4@gmail.com", Password = "afewfoiewiou" });
|
||||
// Even creating new admin should be ok
|
||||
await adminClient.CreateUser(new CreateApplicationUserRequest() { Email = "admin4@gmail.com", Password = "afewfoiewiou", IsAdministrator = true });
|
||||
|
@ -112,7 +112,7 @@ namespace BTCPayServer.Tests
|
|||
var user1Acc = tester.NewAccount();
|
||||
user1Acc.UserId = user1.Id;
|
||||
user1Acc.IsAdmin = false;
|
||||
var user1Client = await user1Acc.CreateClient(Permissions.ServerManagement);
|
||||
var user1Client = await user1Acc.CreateClient(Permission.CanModifyServerSettings);
|
||||
// User1 trying to get server management would still fail to create user
|
||||
await AssertHttpError(403, async () => await user1Client.CreateUser(new CreateApplicationUserRequest() { Email = "test8@gmail.com", Password = "afewfoiewiou" }));
|
||||
|
||||
|
@ -141,9 +141,9 @@ namespace BTCPayServer.Tests
|
|||
var user = tester.NewAccount();
|
||||
user.GrantAccess();
|
||||
await user.MakeAdmin();
|
||||
var clientProfile = await user.CreateClient(Permissions.ProfileManagement);
|
||||
var clientServer = await user.CreateClient(Permissions.ServerManagement);
|
||||
var clientInsufficient = await user.CreateClient(Permissions.StoreManagement);
|
||||
var clientProfile = await user.CreateClient(Permission.CanModifyProfile);
|
||||
var clientServer = await user.CreateClient(Permission.CanModifyServerSettings, Permission.CanViewProfile);
|
||||
var clientInsufficient = await user.CreateClient(Permission.CanModifyStoreSettings);
|
||||
|
||||
|
||||
var apiKeyProfileUserData = await clientProfile.GetCurrentUser();
|
||||
|
@ -153,6 +153,7 @@ namespace BTCPayServer.Tests
|
|||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(async () => await clientInsufficient.GetCurrentUser());
|
||||
await clientServer.GetCurrentUser();
|
||||
await clientProfile.GetCurrentUser();
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(async () => await clientInsufficient.CreateUser(new CreateApplicationUserRequest()
|
||||
{
|
||||
|
|
|
@ -32,9 +32,9 @@ namespace BTCPayServer.Tests
|
|||
public IWebDriver Driver { get; set; }
|
||||
public ServerTester Server { get; set; }
|
||||
|
||||
public static SeleniumTester Create([CallerMemberNameAttribute] string scope = null)
|
||||
public static SeleniumTester Create([CallerMemberNameAttribute] string scope = null, bool newDb = false)
|
||||
{
|
||||
var server = ServerTester.Create(scope);
|
||||
var server = ServerTester.Create(scope, newDb);
|
||||
return new SeleniumTester()
|
||||
{
|
||||
Server = server
|
||||
|
@ -259,7 +259,7 @@ namespace BTCPayServer.Tests
|
|||
|
||||
public void SetCheckbox(SeleniumTester s, string inputName, bool value)
|
||||
{
|
||||
SetCheckbox(s.Driver.FindElement(By.Name(inputName)), value);
|
||||
SetCheckbox(s.Driver.FindElement(By.Id(inputName)), value);
|
||||
}
|
||||
|
||||
public void ScrollToElement(IWebElement element)
|
||||
|
|
|
@ -38,11 +38,14 @@ namespace BTCPayServer.Tests
|
|||
GrantAccessAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task MakeAdmin()
|
||||
public async Task MakeAdmin(bool isAdmin = true)
|
||||
{
|
||||
var userManager = parent.PayTester.GetService<UserManager<ApplicationUser>>();
|
||||
var u = await userManager.FindByIdAsync(UserId);
|
||||
await userManager.AddToRoleAsync(u, Roles.ServerAdmin);
|
||||
if (isAdmin)
|
||||
await userManager.AddToRoleAsync(u, Roles.ServerAdmin);
|
||||
else
|
||||
await userManager.RemoveFromRoleAsync(u, Roles.ServerAdmin);
|
||||
IsAdmin = true;
|
||||
}
|
||||
|
||||
|
|
|
@ -62,6 +62,7 @@ using BTCPayServer.U2F.Models;
|
|||
using BTCPayServer.Security.Bitpay;
|
||||
using MemoryCache = Microsoft.Extensions.Caching.Memory.MemoryCache;
|
||||
using Newtonsoft.Json.Schema;
|
||||
using BTCPayServer.Client;
|
||||
|
||||
namespace BTCPayServer.Tests
|
||||
{
|
||||
|
@ -3000,6 +3001,22 @@ noninventoryitem:
|
|||
await new ApplicationDbContext(builder.Options).Database.MigrateAsync();
|
||||
}
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
[Trait("Fast", "Fast")]
|
||||
public void CanUsePermission()
|
||||
{
|
||||
Assert.True(Permission.Create(Permission.CanModifyServerSettings).Contains(Permission.Create(Permission.CanModifyServerSettings)));
|
||||
Assert.True(Permission.Create(Permission.CanModifyProfile).Contains(Permission.Create(Permission.CanViewProfile)));
|
||||
Assert.True(Permission.Create(Permission.CanModifyStoreSettings).Contains(Permission.Create(Permission.CanViewStoreSettings)));
|
||||
Assert.False(Permission.Create(Permission.CanViewStoreSettings).Contains(Permission.Create(Permission.CanModifyStoreSettings)));
|
||||
Assert.False(Permission.Create(Permission.CanModifyServerSettings).Contains(Permission.Create(Permission.CanModifyStoreSettings)));
|
||||
Assert.True(Permission.Create(Permission.Unrestricted).Contains(Permission.Create(Permission.CanModifyStoreSettings)));
|
||||
Assert.True(Permission.Create(Permission.Unrestricted).Contains(Permission.Create(Permission.CanModifyStoreSettings, "abc")));
|
||||
|
||||
Assert.True(Permission.Create(Permission.CanViewStoreSettings).Contains(Permission.Create(Permission.CanViewStoreSettings, "abcd")));
|
||||
Assert.False(Permission.Create(Permission.CanModifyStoreSettings, "abcd").Contains(Permission.Create(Permission.CanModifyStoreSettings)));
|
||||
}
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
[Trait("Fast", "Fast")]
|
||||
public void CheckRatesProvider()
|
||||
|
|
|
@ -223,5 +223,5 @@
|
|||
<_ContentIncludedByDefault Remove="Views\Authorization\Authorize.cshtml" />
|
||||
</ItemGroup>
|
||||
|
||||
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
|
||||
<ProjectExtensions><VisualStudio><UserProperties /></VisualStudio></ProjectExtensions>
|
||||
</Project>
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Security;
|
||||
|
@ -12,7 +13,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
[BitpayAPIConstraint]
|
||||
[Authorize(Policies.CanCreateInvoice.Key, AuthenticationSchemes = AuthenticationSchemes.Bitpay)]
|
||||
[Authorize(Permission.CanCreateInvoice, AuthenticationSchemes = AuthenticationSchemes.Bitpay)]
|
||||
public class InvoiceControllerAPI : Controller
|
||||
{
|
||||
private InvoiceController _InvoiceController;
|
||||
|
|
|
@ -6,6 +6,7 @@ using System.Net.Mime;
|
|||
using System.Net.WebSockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Filters;
|
||||
|
@ -510,7 +511,7 @@ namespace BTCPayServer.Controllers
|
|||
|
||||
[HttpPost]
|
||||
[Route("invoices/create")]
|
||||
[Authorize(Policy = Policies.CanCreateInvoice.Key, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Authorize(Policy = Permission.CanCreateInvoice, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[BitpayAPIConstraint(false)]
|
||||
public async Task<IActionResult> CreateInvoice(CreateInvoiceModel model, CancellationToken cancellationToken)
|
||||
{
|
||||
|
|
|
@ -25,11 +25,11 @@ namespace BTCPayServer.Controllers
|
|||
{
|
||||
ApiKeyDatas = await _apiKeyRepository.GetKeys(new APIKeyRepository.APIKeyQuery()
|
||||
{
|
||||
UserId = new[] {_userManager.GetUserId(User)}
|
||||
UserId = new[] { _userManager.GetUserId(User) }
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("api-keys/{id}/delete")]
|
||||
public async Task<IActionResult> RemoveAPIKey(string id)
|
||||
{
|
||||
|
@ -96,22 +96,13 @@ namespace BTCPayServer.Controllers
|
|||
|
||||
permissions ??= Array.Empty<string>();
|
||||
|
||||
var vm = await SetViewModelValues(new AuthorizeApiKeysViewModel()
|
||||
var vm = await SetViewModelValues(new AuthorizeApiKeysViewModel(Permission.ToPermissions(permissions))
|
||||
{
|
||||
Label = applicationName,
|
||||
ServerManagementPermission = permissions.Contains(Permissions.ServerManagement),
|
||||
StoreManagementPermission = permissions.Contains(Permissions.StoreManagement),
|
||||
PermissionsFormatted = permissions,
|
||||
PermissionValues = permissions.Where(s =>
|
||||
!s.Contains(Permissions.StoreManagement, StringComparison.InvariantCultureIgnoreCase) &&
|
||||
s != Permissions.ServerManagement)
|
||||
.Select(s => new AddApiKeyViewModel.PermissionValueItem() {Permission = s, Value = true}).ToList(),
|
||||
ApplicationName = applicationName,
|
||||
SelectiveStores = selectiveStores,
|
||||
Strict = strict,
|
||||
});
|
||||
|
||||
vm.ServerManagementPermission = vm.ServerManagementPermission && vm.IsServerAdmin;
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
|
@ -126,22 +117,20 @@ namespace BTCPayServer.Controllers
|
|||
return ar;
|
||||
}
|
||||
|
||||
|
||||
if (viewModel.PermissionsFormatted.Contains(Permissions.ServerManagement))
|
||||
if (viewModel.Strict)
|
||||
{
|
||||
if (!viewModel.IsServerAdmin && viewModel.ServerManagementPermission)
|
||||
for (int i = 0; i < viewModel.PermissionValues.Count; i++)
|
||||
{
|
||||
viewModel.ServerManagementPermission = false;
|
||||
}
|
||||
|
||||
if (!viewModel.ServerManagementPermission && viewModel.Strict)
|
||||
{
|
||||
ModelState.AddModelError(nameof(viewModel.ServerManagementPermission),
|
||||
"This permission is required for this application.");
|
||||
if (viewModel.PermissionValues[i].Forbidden)
|
||||
{
|
||||
ModelState.AddModelError($"{viewModel.PermissionValues}[{i}].Value",
|
||||
$"The permission '{viewModel.PermissionValues[i].Title}' is required for this application.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (viewModel.PermissionsFormatted.Contains(Permissions.StoreManagement))
|
||||
var permissions = Permission.ToPermissions(viewModel.Permissions).ToHashSet();
|
||||
if (permissions.Contains(Permission.Create(Permission.CanModifyStoreSettings)))
|
||||
{
|
||||
if (!viewModel.SelectiveStores &&
|
||||
viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific)
|
||||
|
@ -151,10 +140,10 @@ namespace BTCPayServer.Controllers
|
|||
"This application does not allow selective store permissions.");
|
||||
}
|
||||
|
||||
if (!viewModel.StoreManagementPermission && !viewModel.SpecificStores.Any() && viewModel.Strict)
|
||||
if (!viewModel.StoreManagementPermission.Value && !viewModel.SpecificStores.Any() && viewModel.Strict)
|
||||
{
|
||||
ModelState.AddModelError(nameof(viewModel.StoreManagementPermission),
|
||||
"This permission is required for this application.");
|
||||
$"This permission '{viewModel.StoreManagementPermission.Title}' is required for this application.");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -174,8 +163,9 @@ namespace BTCPayServer.Controllers
|
|||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
Html = $"API key generated! <code>{key.Id}</code>"
|
||||
});
|
||||
return RedirectToAction("APIKeys", new { key = key.Id});
|
||||
default: return View(viewModel);
|
||||
return RedirectToAction("APIKeys", new { key = key.Id });
|
||||
default:
|
||||
return View(viewModel);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -225,15 +215,15 @@ namespace BTCPayServer.Controllers
|
|||
return View(viewModel);
|
||||
|
||||
case string x when x.StartsWith("remove-store", StringComparison.InvariantCultureIgnoreCase):
|
||||
{
|
||||
ModelState.Clear();
|
||||
var index = int.Parse(
|
||||
viewModel.Command.Substring(
|
||||
viewModel.Command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1),
|
||||
CultureInfo.InvariantCulture);
|
||||
viewModel.SpecificStores.RemoveAt(index);
|
||||
return View(viewModel);
|
||||
}
|
||||
{
|
||||
ModelState.Clear();
|
||||
var index = int.Parse(
|
||||
viewModel.Command.Substring(
|
||||
viewModel.Command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1),
|
||||
CultureInfo.InvariantCulture);
|
||||
viewModel.SpecificStores.RemoveAt(index);
|
||||
return View(viewModel);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
@ -248,53 +238,104 @@ namespace BTCPayServer.Controllers
|
|||
UserId = _userManager.GetUserId(User),
|
||||
Label = viewModel.Label
|
||||
};
|
||||
key.SetPermissions(GetPermissionsFromViewModel(viewModel));
|
||||
key.Permissions = string.Join(";", GetPermissionsFromViewModel(viewModel).Select(p => p.ToString()).Distinct().ToArray());
|
||||
await _apiKeyRepository.CreateKey(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetPermissionsFromViewModel(AddApiKeyViewModel viewModel)
|
||||
private IEnumerable<Permission> GetPermissionsFromViewModel(AddApiKeyViewModel viewModel)
|
||||
{
|
||||
var permissions = viewModel.PermissionValues.Where(tuple => tuple.Value).Select(tuple => tuple.Permission).ToList();
|
||||
|
||||
if (viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific)
|
||||
List<Permission> permissions = new List<Permission>();
|
||||
foreach (var p in viewModel.PermissionValues.Where(tuple => tuple.Value && !tuple.Forbidden))
|
||||
{
|
||||
permissions.AddRange(viewModel.SpecificStores.Select(Permissions.GetStorePermission));
|
||||
if (Permission.TryCreatePermission(p.Permission, null, out var pp))
|
||||
permissions.Add(pp);
|
||||
}
|
||||
else if (viewModel.StoreManagementPermission)
|
||||
if (viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.AllStores && viewModel.StoreManagementPermission.Value)
|
||||
{
|
||||
permissions.Add(Permissions.StoreManagement);
|
||||
permissions.Add(Permission.Create(Permission.CanModifyStoreSettings));
|
||||
}
|
||||
|
||||
if (viewModel.IsServerAdmin && viewModel.ServerManagementPermission)
|
||||
else if (viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific)
|
||||
{
|
||||
permissions.Add(Permissions.ServerManagement);
|
||||
permissions.AddRange(viewModel.SpecificStores.Select(s => Permission.Create(Permission.CanModifyStoreSettings, s)));
|
||||
}
|
||||
|
||||
return permissions.Distinct();
|
||||
}
|
||||
|
||||
private async Task<T> SetViewModelValues<T>(T viewModel) where T : AddApiKeyViewModel
|
||||
{
|
||||
viewModel.Stores = await _StoreRepository.GetStoresByUserId(_userManager.GetUserId(User));
|
||||
viewModel.IsServerAdmin =
|
||||
(await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings.Key)).Succeeded;
|
||||
viewModel.PermissionValues ??= Permissions.GetAllPermissionKeys().Where(s =>
|
||||
!s.Contains(Permissions.StoreManagement, StringComparison.InvariantCultureIgnoreCase) &&
|
||||
s != Permissions.ServerManagement)
|
||||
.Select(s => new AddApiKeyViewModel.PermissionValueItem() {Permission = s, Value = true}).ToList();
|
||||
var isAdmin = (await _authorizationService.AuthorizeAsync(User, Permission.CanModifyServerSettings)).Succeeded;
|
||||
viewModel.PermissionValues ??= Permission.AllPolicies.Where(p => p != Permission.CanModifyStoreSettings)
|
||||
.Select(s => new AddApiKeyViewModel.PermissionValueItem() { Permission = s, Value = false }).ToList();
|
||||
if (!isAdmin)
|
||||
{
|
||||
foreach (var p in viewModel.PermissionValues)
|
||||
{
|
||||
if (p.Permission == Permission.CanCreateUser ||
|
||||
p.Permission == Permission.CanModifyServerSettings)
|
||||
{
|
||||
p.Forbidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
public class AddApiKeyViewModel
|
||||
{
|
||||
public AddApiKeyViewModel()
|
||||
{
|
||||
StoreManagementPermission = new PermissionValueItem()
|
||||
{
|
||||
Permission = Permission.CanModifyStoreSettings,
|
||||
Value = false
|
||||
};
|
||||
StoreManagementSelectivePermission = new PermissionValueItem()
|
||||
{
|
||||
Permission = $"{Permission.CanModifyStoreSettings}:",
|
||||
Value = true
|
||||
};
|
||||
}
|
||||
public AddApiKeyViewModel(IEnumerable<Permission> permissions):this()
|
||||
{
|
||||
StoreManagementPermission.Value = permissions.Any(p => p.Policy == Permission.CanModifyStoreSettings && p.StoreId == null);
|
||||
PermissionValues = permissions.Where(p => p.Policy != Permission.CanModifyStoreSettings)
|
||||
.Select(p => new PermissionValueItem() { Permission = p.ToString(), Value = true })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public IEnumerable<Permission> GetPermissions()
|
||||
{
|
||||
if (!(PermissionValues is null))
|
||||
{
|
||||
foreach (var p in PermissionValues.Where(o => o.Value))
|
||||
{
|
||||
if (Permission.TryCreatePermission(p.Permission, null, out var pp))
|
||||
yield return pp;
|
||||
}
|
||||
}
|
||||
if (this.StoreMode == ApiKeyStoreMode.AllStores)
|
||||
{
|
||||
if (StoreManagementPermission.Value)
|
||||
yield return Permission.Create(Permission.CanModifyStoreSettings);
|
||||
}
|
||||
else if (this.StoreMode == ApiKeyStoreMode.Specific && SpecificStores is List<string>)
|
||||
{
|
||||
foreach (var p in SpecificStores)
|
||||
{
|
||||
if (Permission.TryCreatePermission(Permission.CanModifyStoreSettings, p, out var pp))
|
||||
yield return pp;
|
||||
}
|
||||
}
|
||||
}
|
||||
public string Label { get; set; }
|
||||
public StoreData[] Stores { get; set; }
|
||||
ApiKeyStoreMode _StoreMode;
|
||||
public ApiKeyStoreMode StoreMode { get; set; }
|
||||
public List<string> SpecificStores { get; set; } = new List<string>();
|
||||
public bool IsServerAdmin { get; set; }
|
||||
public bool ServerManagementPermission { get; set; }
|
||||
public bool StoreManagementPermission { get; set; }
|
||||
public PermissionValueItem StoreManagementPermission { get; set; }
|
||||
public PermissionValueItem StoreManagementSelectivePermission { get; set; }
|
||||
public string Command { get; set; }
|
||||
public List<PermissionValueItem> PermissionValues { get; set; }
|
||||
|
||||
|
@ -306,29 +347,52 @@ namespace BTCPayServer.Controllers
|
|||
|
||||
public class PermissionValueItem
|
||||
{
|
||||
public static readonly Dictionary<string, (string Title, string Description)> PermissionDescriptions = new Dictionary<string, (string Title, string Description)>()
|
||||
{
|
||||
{BTCPayServer.Client.Permission.Unrestricted, ("Unrestricted access", "The app will have unrestricted access to your account.")},
|
||||
{BTCPayServer.Client.Permission.CanCreateUser, ("Create new users", "The app will be able to create new users on this server.")},
|
||||
{BTCPayServer.Client.Permission.CanModifyStoreSettings, ("Modify your stores", "The app will be able to create, view and modify, delete and create new invoices on the all your stores.")},
|
||||
{BTCPayServer.Client.Permission.CanViewStoreSettings, ("View your stores", "The app will be able to create, view all your stores.")},
|
||||
{$"{BTCPayServer.Client.Permission.CanModifyStoreSettings}:", ("Manage selected stores", "The app will be able to view, modify, delete and create new invoices on the selected stores.")},
|
||||
{BTCPayServer.Client.Permission.CanModifyServerSettings, ("Manage your server", "The app will have total control on your server")},
|
||||
{BTCPayServer.Client.Permission.CanViewProfile, ("View your profile", "The app will be able to view your user profile.")},
|
||||
{BTCPayServer.Client.Permission.CanModifyProfile, ("Manage your profile", "The app will be able to view and modify your user profile.")},
|
||||
{BTCPayServer.Client.Permission.CanCreateInvoice, ("Create an invoice", "The app will be able to create new invoice.")},
|
||||
};
|
||||
public string Title
|
||||
{
|
||||
get
|
||||
{
|
||||
return PermissionDescriptions[Permission].Title;
|
||||
}
|
||||
}
|
||||
public string Description
|
||||
{
|
||||
get
|
||||
{
|
||||
return PermissionDescriptions[Permission].Description;
|
||||
}
|
||||
}
|
||||
public string Permission { get; set; }
|
||||
public bool Value { get; set; }
|
||||
public bool Forbidden { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public class AuthorizeApiKeysViewModel : AddApiKeyViewModel
|
||||
{
|
||||
public AuthorizeApiKeysViewModel()
|
||||
{
|
||||
|
||||
}
|
||||
public AuthorizeApiKeysViewModel(IEnumerable<Permission> permissions) : base(permissions)
|
||||
{
|
||||
Permissions = string.Join(';', permissions.Select(p => p.ToString()).ToArray());
|
||||
}
|
||||
public string ApplicationName { get; set; }
|
||||
public bool Strict { get; set; }
|
||||
public bool SelectiveStores { get; set; }
|
||||
public string Permissions { get; set; }
|
||||
|
||||
public string[] PermissionsFormatted
|
||||
{
|
||||
get
|
||||
{
|
||||
return Permissions?.Split(";", StringSplitOptions.RemoveEmptyEntries)?? Array.Empty<string>();
|
||||
}
|
||||
set
|
||||
{
|
||||
Permissions = string.Join(';', value ?? Array.Empty<string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
using System.Threading.Tasks;
|
||||
using System.Linq;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Security;
|
||||
|
@ -43,7 +45,7 @@ namespace BTCPayServer.Controllers.RestApi.ApiKeys
|
|||
{
|
||||
return new ApiKeyData()
|
||||
{
|
||||
Permissions = data.GetPermissions(),
|
||||
Permissions = Permission.ToPermissions(data.Permissions).Select(c => c.ToString()).ToArray(),
|
||||
ApiKey = data.Id,
|
||||
UserId = data.UserId,
|
||||
Label = data.Label
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Security.APIKeys;
|
||||
|
@ -27,45 +28,45 @@ namespace BTCPayServer.Controllers.RestApi
|
|||
}
|
||||
|
||||
[HttpGet("me/id")]
|
||||
[Authorize(Policy = Permission.CanViewProfile, AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
public string GetCurrentUserId()
|
||||
{
|
||||
return _userManager.GetUserId(User);
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize(Policy = Permission.CanViewProfile, AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
public async Task<ApplicationUser> GetCurrentUser()
|
||||
{
|
||||
return await _userManager.GetUserAsync(User);
|
||||
}
|
||||
|
||||
[HttpGet("me/is-admin")]
|
||||
[Authorize(Policy = Policies.CanModifyServerSettings.Key, AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
[Authorize(Policy = Permission.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
public bool AmIAnAdmin()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
[HttpGet("me/stores")]
|
||||
[Authorize(Policy = Policies.CanListStoreSettings.Key,
|
||||
AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
public async Task<StoreData[]> GetCurrentUserStores()
|
||||
[Authorize(Policy = Permission.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
public StoreData[] GetCurrentUserStores()
|
||||
{
|
||||
return await User.GetStores(_userManager, _storeRepository);
|
||||
return this.HttpContext.GetStoresData();
|
||||
}
|
||||
|
||||
[HttpGet("me/stores/actions")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings.Key,
|
||||
|
||||
[HttpGet("me/stores/{storeId}/can-view")]
|
||||
[Authorize(Policy = Permission.CanViewStoreSettings,
|
||||
AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
public bool CanDoNonImplicitStoreActions()
|
||||
public bool CanViewStore(string storeId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("me/stores/{storeId}/can-edit")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings.Key,
|
||||
[Authorize(Policy = Permission.CanModifyStoreSettings,
|
||||
AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
public bool CanEdit(string storeId)
|
||||
public bool CanEditStore(string storeId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Identity;
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using NicolasDorier.RateLimits;
|
||||
using BTCPayServer.Client;
|
||||
|
||||
namespace BTCPayServer.Controllers.RestApi.Users
|
||||
{
|
||||
|
@ -54,7 +55,7 @@ namespace BTCPayServer.Controllers.RestApi.Users
|
|||
_authorizationService = authorizationService;
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.CanModifyProfile.Key, AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
[Authorize(Policy = Permission.CanViewProfile, AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
[HttpGet("~/api/v1/users/me")]
|
||||
public async Task<ActionResult<ApplicationUserData>> GetCurrentUser()
|
||||
{
|
||||
|
@ -86,26 +87,21 @@ namespace BTCPayServer.Controllers.RestApi.Users
|
|||
if (anyAdmin && request.IsAdministrator is true && !isAuth)
|
||||
return Forbid(AuthenticationSchemes.ApiKey);
|
||||
// You are de-facto admin if there is no other admin, else you need to be auth and pass policy requirements
|
||||
bool isAdmin = anyAdmin ? (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanModifyServerSettings.Key))).Succeeded
|
||||
bool isAdmin = anyAdmin ? (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Permission.CanModifyServerSettings))).Succeeded
|
||||
&& isAuth
|
||||
: true;
|
||||
// You need to be admin to create an admin
|
||||
if (request.IsAdministrator is true && !isAdmin)
|
||||
return Forbid(AuthenticationSchemes.ApiKey);
|
||||
|
||||
var canCreateUser = (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanCreateUser.Key))).Succeeded;
|
||||
if (!isAdmin && policies.LockSubscription)
|
||||
{
|
||||
// If we are not admin and subscriptions are locked, we need to check the Policies.CanCreateUser.Key permission
|
||||
var canCreateUser = (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Permission.CanCreateUser))).Succeeded;
|
||||
if (!isAuth || !canCreateUser)
|
||||
return Forbid(AuthenticationSchemes.ApiKey);
|
||||
}
|
||||
|
||||
// TODO: Check if needed to reenable
|
||||
// Forbid non-admin users without CanCreateUser permission to create accounts
|
||||
//if (isAuth && !isAdmin && !canCreateUser)
|
||||
// return Forbid(AuthenticationSchemes.ApiKey);
|
||||
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = request.Email,
|
||||
|
|
|
@ -35,10 +35,11 @@ using BTCPayServer.Services.Apps;
|
|||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using BTCPayServer.Client;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
[Authorize(Policy = BTCPayServer.Security.Policies.CanModifyServerSettings.Key,
|
||||
[Authorize(Policy = Permission.CanModifyServerSettings,
|
||||
AuthenticationSchemes = BTCPayServer.Security.AuthenticationSchemes.Cookie)]
|
||||
public partial class ServerController : Controller
|
||||
{
|
||||
|
|
|
@ -24,6 +24,7 @@ using Newtonsoft.Json;
|
|||
using Newtonsoft.Json.Linq;
|
||||
using BTCPayServer.Logging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using BTCPayServer.Client;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
|
@ -386,7 +387,7 @@ namespace BTCPayServer.Controllers
|
|||
|
||||
private async Task<bool> CanUseHotWallet()
|
||||
{
|
||||
var isAdmin = (await _authorizationService.AuthorizeAsync(User, BTCPayServer.Security.Policies.CanModifyServerSettings.Key)).Succeeded;
|
||||
var isAdmin = (await _authorizationService.AuthorizeAsync(User, Permission.CanModifyServerSettings)).Succeeded;
|
||||
if (isAdmin)
|
||||
return true;
|
||||
var policies = await _settingsRepository.GetSettingAsync<PoliciesSettings>();
|
||||
|
|
|
@ -5,6 +5,7 @@ using System.Linq;
|
|||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Configuration;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
|
@ -33,7 +34,7 @@ namespace BTCPayServer.Controllers
|
|||
{
|
||||
[Route("stores")]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings.Key, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Authorize(Policy = Permission.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public partial class StoresController : Controller
|
||||
{
|
||||
|
|
|
@ -7,6 +7,7 @@ using System.Net.WebSockets;
|
|||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Hwi;
|
||||
using BTCPayServer.ModelBinders;
|
||||
|
@ -127,7 +128,7 @@ namespace BTCPayServer.Controllers
|
|||
}
|
||||
await websocketHelper.Send("{ \"info\": \"ready\"}", cancellationToken);
|
||||
o = JObject.Parse(await websocketHelper.NextMessageAsync(cancellationToken));
|
||||
var authorization = await _authorizationService.AuthorizeAsync(User, Policies.CanModifyStoreSettings.Key);
|
||||
var authorization = await _authorizationService.AuthorizeAsync(User, Permission.CanModifyStoreSettings);
|
||||
if (!authorization.Succeeded)
|
||||
{
|
||||
await websocketHelper.Send("{ \"error\": \"not-authorized\"}", cancellationToken);
|
||||
|
|
|
@ -6,6 +6,7 @@ using System.Net.WebSockets;
|
|||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.ModelBinders;
|
||||
|
@ -30,7 +31,7 @@ using Newtonsoft.Json;
|
|||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
[Route("wallets")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings.Key, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Authorize(Policy = Permission.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public partial class WalletsController : Controller
|
||||
{
|
||||
|
@ -366,7 +367,7 @@ namespace BTCPayServer.Controllers
|
|||
|
||||
private async Task<bool> CanUseHotWallet()
|
||||
{
|
||||
var isAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings.Key)).Succeeded;
|
||||
var isAdmin = (await _authorizationService.AuthorizeAsync(User, Permission.CanModifyServerSettings)).Succeeded;
|
||||
if (isAdmin)
|
||||
return true;
|
||||
var policies = await _settingsRepository.GetSettingAsync<PoliciesSettings>();
|
||||
|
@ -839,7 +840,7 @@ namespace BTCPayServer.Controllers
|
|||
|
||||
var vm = new RescanWalletModel();
|
||||
vm.IsFullySync = _dashboard.IsFullySynched(walletId.CryptoCode, out var unused);
|
||||
vm.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings.Key)).Succeeded;
|
||||
vm.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Permission.CanModifyServerSettings)).Succeeded;
|
||||
vm.IsSupportedByCurrency = _dashboard.Get(walletId.CryptoCode)?.Status?.BitcoinStatus?.Capabilities?.CanScanTxoutSet == true;
|
||||
var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
|
||||
var scanProgress = await explorer.GetScanUTXOSetInformationAsync(paymentMethod.AccountDerivation);
|
||||
|
@ -869,7 +870,7 @@ namespace BTCPayServer.Controllers
|
|||
|
||||
[HttpPost]
|
||||
[Route("{walletId}/rescan")]
|
||||
[Authorize(Policy = Policies.CanModifyServerSettings.Key, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Authorize(Policy = Permission.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public async Task<IActionResult> WalletRescan(
|
||||
[ModelBinder(typeof(WalletIdModelBinder))]
|
||||
WalletId walletId, RescanWalletModel vm)
|
||||
|
|
|
@ -425,6 +425,15 @@ namespace BTCPayServer
|
|||
ctx.Items["BTCPAY.STOREDATA"] = storeData;
|
||||
}
|
||||
|
||||
public static StoreData[] GetStoresData(this HttpContext ctx)
|
||||
{
|
||||
return ctx.Items.TryGet("BTCPAY.STORESDATA") as StoreData[];
|
||||
}
|
||||
public static void SetStoresData(this HttpContext ctx, StoreData[] storeData)
|
||||
{
|
||||
ctx.Items["BTCPAY.STORESDATA"] = storeData;
|
||||
}
|
||||
|
||||
private static JsonSerializerSettings jsonSettings = new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() };
|
||||
public static string ToJson(this object o)
|
||||
{
|
||||
|
|
|
@ -4,6 +4,7 @@ using System.Linq;
|
|||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Security.Bitpay;
|
||||
using BTCPayServer.Services.Stores;
|
||||
|
@ -44,11 +45,8 @@ namespace BTCPayServer.Security.APIKeys
|
|||
}
|
||||
|
||||
List<Claim> claims = new List<Claim>();
|
||||
|
||||
claims.Add(new Claim(_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, key.UserId));
|
||||
claims.AddRange(key.GetPermissions()
|
||||
.Select(permission => new Claim(APIKeyConstants.ClaimTypes.Permissions, permission)));
|
||||
|
||||
claims.AddRange(Permission.ToPermissions(key.Permissions).Select(permission => new Claim(APIKeyConstants.ClaimTypes.Permission, permission.ToString())));
|
||||
return AuthenticateResult.Success(new AuthenticationTicket(
|
||||
new ClaimsPrincipal(new ClaimsIdentity(claims, APIKeyConstants.AuthenticationType)), APIKeyConstants.AuthenticationType));
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
|
@ -35,52 +36,54 @@ namespace BTCPayServer.Security.APIKeys
|
|||
bool success = false;
|
||||
switch (requirement.Policy)
|
||||
{
|
||||
case Policies.CanModifyProfile.Key:
|
||||
success = context.HasPermissions(Permissions.ProfileManagement);
|
||||
case Permission.CanModifyProfile:
|
||||
case Permission.CanViewProfile:
|
||||
success = context.HasPermission(Permission.Create(requirement.Policy));
|
||||
break;
|
||||
case Policies.CanListStoreSettings.Key:
|
||||
var selectiveStorePermissions =
|
||||
Permissions.ExtractStorePermissionsIds(context.GetPermissions());
|
||||
success = context.HasPermissions(Permissions.StoreManagement) ||
|
||||
selectiveStorePermissions.Any();
|
||||
break;
|
||||
case Policies.CanModifyStoreSettings.Key:
|
||||
string storeId = _HttpContext.GetImplicitStoreId();
|
||||
if (!context.HasPermissions(Permissions.StoreManagement) &&
|
||||
!context.HasPermissions(Permissions.GetStorePermission(storeId)))
|
||||
break;
|
||||
|
||||
if (storeId == null)
|
||||
case Permission.CanViewStoreSettings:
|
||||
case Permission.CanModifyStoreSettings:
|
||||
var storeId = _HttpContext.GetImplicitStoreId();
|
||||
var userid = _userManager.GetUserId(context.User);
|
||||
// Specific store action
|
||||
if (storeId != null)
|
||||
{
|
||||
success = true;
|
||||
if (context.HasPermission(Permission.Create(requirement.Policy, storeId)))
|
||||
{
|
||||
if (string.IsNullOrEmpty(userid))
|
||||
break;
|
||||
var store = await _storeRepository.FindStore((string)storeId, userid);
|
||||
if (store == null)
|
||||
break;
|
||||
success = true;
|
||||
_HttpContext.SetStoreData(store);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var userid = _userManager.GetUserId(context.User);
|
||||
if (string.IsNullOrEmpty(userid))
|
||||
var stores = await _storeRepository.GetStoresByUserId(userid);
|
||||
List<StoreData> permissionedStores = new List<StoreData>();
|
||||
foreach (var store in stores)
|
||||
{
|
||||
if (context.HasPermission(Permission.Create(requirement.Policy, store.Id)))
|
||||
permissionedStores.Add(store);
|
||||
}
|
||||
_HttpContext.SetStoresData(stores.ToArray());
|
||||
success = true;
|
||||
}
|
||||
break;
|
||||
case Permission.CanCreateUser:
|
||||
case Permission.CanModifyServerSettings:
|
||||
if (context.HasPermission(Permission.Create(requirement.Policy)))
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(context.User);
|
||||
if (user == null)
|
||||
break;
|
||||
var store = await _storeRepository.FindStore((string)storeId, userid);
|
||||
if (store == null)
|
||||
if (!await _userManager.IsInRoleAsync(user, Roles.ServerAdmin))
|
||||
break;
|
||||
success = true;
|
||||
_HttpContext.SetStoreData(store);
|
||||
}
|
||||
|
||||
break;
|
||||
case Policies.CanCreateUser.Key:
|
||||
case Policies.CanModifyServerSettings.Key:
|
||||
if (!context.HasPermissions(Permissions.ServerManagement))
|
||||
break;
|
||||
// For this authorization, we still check in database because it is super sensitive.
|
||||
success = await IsUserAdmin(context.User);
|
||||
break;
|
||||
}
|
||||
|
||||
//if you do not have the specific permissions, BUT you have server management, we enable god mode
|
||||
if (!success && context.HasPermissions(Permissions.ServerManagement) &&
|
||||
requirement.Policy != Policies.CanModifyServerSettings.Key)
|
||||
{
|
||||
success = await IsUserAdmin(context.User);
|
||||
}
|
||||
|
||||
if (success)
|
||||
|
@ -88,15 +91,5 @@ namespace BTCPayServer.Security.APIKeys
|
|||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsUserAdmin(ClaimsPrincipal contextUser)
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(contextUser);
|
||||
if (user == null)
|
||||
return false;
|
||||
if (!await _userManager.IsInRoleAsync(user, Roles.ServerAdmin))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using BTCPayServer.Client;
|
||||
|
||||
namespace BTCPayServer.Security.APIKeys
|
||||
{
|
||||
|
@ -8,19 +9,7 @@ namespace BTCPayServer.Security.APIKeys
|
|||
|
||||
public static class ClaimTypes
|
||||
{
|
||||
public const string Permissions = nameof(APIKeys) + "." + nameof(Permissions);
|
||||
}
|
||||
|
||||
public static class Permissions
|
||||
{
|
||||
public static readonly Dictionary<string, (string Title, string Description)> PermissionDescriptions = new Dictionary<string, (string Title, string Description)>()
|
||||
{
|
||||
{Client.Permissions.StoreManagement, ("Manage your stores", "The app will be able to create, modify and delete all your stores.")},
|
||||
{$"{nameof(Client.Permissions.StoreManagement)}:", ("Manage selected stores", "The app will be able to modify and delete selected stores.")},
|
||||
{Client.Permissions.ServerManagement, ("Manage your server", "The app will have total control on your server")},
|
||||
{Client.Permissions.ProfileManagement, ("Manage your profile", "The app will be able to view and modify your user profile.")},
|
||||
};
|
||||
|
||||
public const string Permission = "APIKey.Permission";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,22 +29,6 @@ namespace BTCPayServer.Security.APIKeys
|
|||
return false;
|
||||
}
|
||||
|
||||
public static Task<StoreData[]> GetStores(this ClaimsPrincipal claimsPrincipal,
|
||||
UserManager<ApplicationUser> userManager, StoreRepository storeRepository)
|
||||
{
|
||||
var permissions =
|
||||
claimsPrincipal.Claims.Where(claim => claim.Type == APIKeyConstants.ClaimTypes.Permissions)
|
||||
.Select(claim => claim.Value).ToList();
|
||||
|
||||
if (permissions.Contains(Permissions.StoreManagement))
|
||||
{
|
||||
return storeRepository.GetStoresByUserId(userManager.GetUserId(claimsPrincipal));
|
||||
}
|
||||
|
||||
var storeIds = Permissions.ExtractStorePermissionsIds(permissions);
|
||||
return storeRepository.GetStoresByUserId(userManager.GetUserId(claimsPrincipal), storeIds);
|
||||
}
|
||||
|
||||
public static AuthenticationBuilder AddAPIKeyAuthentication(this AuthenticationBuilder builder)
|
||||
{
|
||||
builder.AddScheme<APIKeyAuthenticationOptions, APIKeyAuthenticationHandler>(AuthenticationSchemes.ApiKey,
|
||||
|
@ -62,15 +46,24 @@ namespace BTCPayServer.Security.APIKeys
|
|||
public static string[] GetPermissions(this AuthorizationHandlerContext context)
|
||||
{
|
||||
return context.User.Claims.Where(c =>
|
||||
c.Type.Equals(APIKeyConstants.ClaimTypes.Permissions, StringComparison.InvariantCultureIgnoreCase))
|
||||
c.Type.Equals(APIKeyConstants.ClaimTypes.Permission, StringComparison.InvariantCultureIgnoreCase))
|
||||
.Select(claim => claim.Value).ToArray();
|
||||
}
|
||||
|
||||
public static bool HasPermissions(this AuthorizationHandlerContext context, params string[] scopes)
|
||||
public static bool HasPermission(this AuthorizationHandlerContext context, Permission permission)
|
||||
{
|
||||
return scopes.All(s => context.User.HasClaim(c =>
|
||||
c.Type.Equals(APIKeyConstants.ClaimTypes.Permissions, StringComparison.InvariantCultureIgnoreCase) &&
|
||||
c.Value.Split(' ').Contains(s)));
|
||||
foreach (var claim in context.User.Claims.Where(c =>
|
||||
c.Type.Equals(APIKeyConstants.ClaimTypes.Permission, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
if (Permission.TryParse(claim.Value, out var claimPermission))
|
||||
{
|
||||
if (claimPermission.Contains(permission))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Routing;
|
|||
using Microsoft.AspNetCore.Authentication;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Security.Bitpay;
|
||||
using BTCPayServer.Client;
|
||||
|
||||
namespace BTCPayServer.Security.Bitpay
|
||||
{
|
||||
|
@ -54,7 +55,7 @@ namespace BTCPayServer.Security.Bitpay
|
|||
var anyoneCanInvoice = store.GetStoreBlob().AnyoneCanInvoice;
|
||||
switch (requirement.Policy)
|
||||
{
|
||||
case Policies.CanCreateInvoice.Key:
|
||||
case Permission.CanCreateInvoice:
|
||||
if (!isAnonymous || (isAnonymous && anyoneCanInvoice))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
|
|
|
@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Identity;
|
|||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using BTCPayServer.Client;
|
||||
|
||||
namespace BTCPayServer.Security
|
||||
{
|
||||
|
@ -35,7 +36,7 @@ namespace BTCPayServer.Security
|
|||
var isAdmin = context.User.IsInRole(Roles.ServerAdmin);
|
||||
switch (requirement.Policy)
|
||||
{
|
||||
case Policies.CanModifyServerSettings.Key:
|
||||
case Permission.CanModifyServerSettings:
|
||||
if (isAdmin)
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
|
@ -56,11 +57,11 @@ namespace BTCPayServer.Security
|
|||
bool success = false;
|
||||
switch (requirement.Policy)
|
||||
{
|
||||
case Policies.CanModifyStoreSettings.Key:
|
||||
case Permission.CanModifyStoreSettings:
|
||||
if (store.Role == StoreRoles.Owner || isAdmin)
|
||||
success = true;
|
||||
break;
|
||||
case Policies.CanCreateInvoice.Key:
|
||||
case Permission.CanCreateInvoice:
|
||||
if (store.Role == StoreRoles.Owner ||
|
||||
store.Role == StoreRoles.Guest ||
|
||||
isAdmin ||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using BTCPayServer.Client;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace BTCPayServer.Security
|
||||
{
|
||||
|
@ -6,14 +7,11 @@ namespace BTCPayServer.Security
|
|||
{
|
||||
public static AuthorizationOptions AddBTCPayPolicies(this AuthorizationOptions options)
|
||||
{
|
||||
options.AddPolicy(CanModifyStoreSettings.Key);
|
||||
options.AddPolicy(CanListStoreSettings.Key);
|
||||
options.AddPolicy(CanCreateInvoice.Key);
|
||||
foreach (var p in Permission.AllPolicies)
|
||||
{
|
||||
options.AddPolicy(p);
|
||||
}
|
||||
options.AddPolicy(CanGetRates.Key);
|
||||
options.AddPolicy(CanModifyServerSettings.Key);
|
||||
options.AddPolicy(CanModifyServerSettings.Key);
|
||||
options.AddPolicy(CanModifyProfile.Key);
|
||||
options.AddPolicy(CanCreateUser.Key);
|
||||
return options;
|
||||
}
|
||||
|
||||
|
@ -21,36 +19,9 @@ namespace BTCPayServer.Security
|
|||
{
|
||||
options.AddPolicy(policy, o => o.AddRequirements(new PolicyRequirement(policy)));
|
||||
}
|
||||
|
||||
public class CanModifyServerSettings
|
||||
{
|
||||
public const string Key = "btcpay.store.canmodifyserversettings";
|
||||
}
|
||||
public class CanModifyProfile
|
||||
{
|
||||
public const string Key = "btcpay.store.canmodifyprofile";
|
||||
}
|
||||
public class CanModifyStoreSettings
|
||||
{
|
||||
public const string Key = "btcpay.store.canmodifystoresettings";
|
||||
}
|
||||
public class CanListStoreSettings
|
||||
{
|
||||
public const string Key = "btcpay.store.canliststoresettings";
|
||||
}
|
||||
public class CanCreateInvoice
|
||||
{
|
||||
public const string Key = "btcpay.store.cancreateinvoice";
|
||||
}
|
||||
|
||||
public class CanGetRates
|
||||
{
|
||||
public const string Key = "btcpay.store.cangetrates";
|
||||
}
|
||||
|
||||
public class CanCreateUser
|
||||
{
|
||||
public const string Key = "btcpay.store.cancreateuser";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,14 +20,15 @@ using Microsoft.AspNetCore.Http;
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.Client;
|
||||
|
||||
namespace BTCPayServer.Services.Altcoins.Monero.UI
|
||||
{
|
||||
[Route("stores/{storeId}/monerolike")]
|
||||
[OnlyIfSupportAttribute("XMR")]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings.Key, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Authorize(Policy = Policies.CanModifyServerSettings.Key, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Authorize(Policy = Permission.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Authorize(Policy = Permission.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public class MoneroLikeStoreController : Controller
|
||||
{
|
||||
private readonly MoneroLikeConfiguration _MoneroLikeConfiguration;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
@namespace BTCPayServer.Client
|
||||
@model BTCPayServer.Controllers.ManageController.ApiKeysViewModel
|
||||
@{
|
||||
ViewData.SetActivePageAndTitle(ManageNavPages.APIKeys, "Manage your API Keys");
|
||||
|
@ -27,7 +28,7 @@
|
|||
}
|
||||
else
|
||||
{
|
||||
<span>@string.Join(", ", keyData.GetPermissions())</span>
|
||||
<span>@string.Join(", ", Permission.ToPermissions(keyData.Permissions).Select(c => c.ToString()).Distinct().ToArray())</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
|
|
|
@ -5,16 +5,6 @@
|
|||
|
||||
@{
|
||||
ViewData.SetActivePageAndTitle(ManageNavPages.APIKeys, "Add API Key");
|
||||
|
||||
string GetDescription(string permission)
|
||||
{
|
||||
return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description;
|
||||
}
|
||||
|
||||
string GetTitle(string permission)
|
||||
{
|
||||
return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description;
|
||||
}
|
||||
}
|
||||
|
||||
<h4>@ViewData["Title"]</h4>
|
||||
|
@ -26,53 +16,47 @@
|
|||
<div class="col-md-12">
|
||||
<form method="post" asp-action="AddApiKey" class="list-group">
|
||||
|
||||
<input type="hidden" asp-for="StoreMode" value="@Model.StoreMode"/>
|
||||
<input type="hidden" asp-for="StoreMode" value="@Model.StoreMode" />
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
|
||||
<div class="list-group-item ">
|
||||
<div class="form-group">
|
||||
<label asp-for="Label"></label>
|
||||
<input asp-for="Label" class="form-control"/>
|
||||
<input asp-for="Label" class="form-control" />
|
||||
<span asp-validation-for="Label" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
@if (Model.IsServerAdmin)
|
||||
{
|
||||
<div class="list-group-item form-group">
|
||||
<input asp-for="ServerManagementPermission" class="form-check-inline"/>
|
||||
<label asp-for="ServerManagementPermission" class="h5">@GetTitle(Permissions.ServerManagement)</label>
|
||||
<span asp-validation-for="ServerManagementPermission" class="text-danger"></span>
|
||||
<p>@GetDescription(Permissions.ServerManagement).</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@for (int i = 0; i < Model.PermissionValues.Count; i++)
|
||||
{
|
||||
@if (!Model.PermissionValues[i].Forbidden)
|
||||
{
|
||||
<div class="list-group-item form-group">
|
||||
<input type="hidden" asp-for="PermissionValues[i].Permission">
|
||||
<input type="checkbox" asp-for="PermissionValues[i].Value" class="form-check-inline"/>
|
||||
<label asp-for="PermissionValues[i].Value" class="h5">@GetTitle(Model.PermissionValues[i].Permission)</label>
|
||||
<input id="@Model.PermissionValues[i].Permission" type="checkbox" asp-for="PermissionValues[i].Value" class="form-check-inline" />
|
||||
<label asp-for="PermissionValues[i].Value" class="h5">@Model.PermissionValues[i].Title</label>
|
||||
<span asp-validation-for="PermissionValues[i].Value" class="text-danger"></span>
|
||||
<p>@GetDescription(Model.PermissionValues[i].Permission).</p>
|
||||
<p>@Model.PermissionValues[i].Description</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores)
|
||||
{
|
||||
<div class="list-group-item form-group">
|
||||
@Html.CheckBoxFor(model => model.StoreManagementPermission, new Dictionary<string, string>() {{"class", "form-check-inline"}})
|
||||
<div class="list-group-item form-group">
|
||||
<input id="@Model.StoreManagementPermission.Permission" type="checkbox" asp-for="@Model.StoreManagementPermission.Value" class="form-check-inline" />
|
||||
|
||||
<label asp-for="StoreManagementPermission" class="h5">@GetTitle(Permissions.StoreManagement)</label>
|
||||
<span asp-validation-for="StoreManagementPermission" class="text-danger"></span>
|
||||
<p class="mb-0">@GetDescription(Permissions.StoreManagement).</p>
|
||||
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to specific stores instead</button>
|
||||
</div>
|
||||
<label asp-for="StoreManagementPermission" class="h5">@Model.StoreManagementPermission.Title</label>
|
||||
<span asp-validation-for="StoreManagementPermission" class="text-danger"></span>
|
||||
<p class="mb-0">@Model.StoreManagementPermission.Description</p>
|
||||
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to specific stores instead</button>
|
||||
</div>
|
||||
}
|
||||
else if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.Specific)
|
||||
{
|
||||
<div class="list-group-item p-0 border-0 mb-2">
|
||||
<li class="list-group-item ">
|
||||
<h5 class="mb-1">@GetTitle(Permissions.StoreManagement + ":")</h5>
|
||||
<p class="mb-0">@GetDescription(Permissions.StoreManagement + ":").</p>
|
||||
<h5 class="mb-1">@Model.StoreManagementSelectivePermission.Title</h5>
|
||||
<p class="mb-0">@Model.StoreManagementSelectivePermission.Description</p>
|
||||
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to all stores instead</button>
|
||||
</li>
|
||||
@if (!Model.Stores.Any())
|
||||
|
|
|
@ -6,24 +6,17 @@
|
|||
@{
|
||||
Layout = "_Layout";
|
||||
ViewData["Title"] = $"Authorize {(Model.ApplicationName ?? "Application")}";
|
||||
|
||||
string GetDescription(string permission)
|
||||
{
|
||||
return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description;
|
||||
}
|
||||
|
||||
string GetTitle(string permission)
|
||||
{
|
||||
return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description;
|
||||
}
|
||||
var permissions = Permission.ToPermissions(Model.Permissions);
|
||||
var hasStorePermission = permissions.Any(p => p.Policy == Permission.CanModifyStoreSettings);
|
||||
}
|
||||
|
||||
<partial name="_StatusMessage"/>
|
||||
<form method="post" asp-action="AuthorizeAPIKey">
|
||||
<input type="hidden" asp-for="Permissions" value="@Model.Permissions"/>
|
||||
<input type="hidden" asp-for="Strict" value="@Model.Strict"/>
|
||||
<input type="hidden" asp-for="ApplicationName" value="@Model.ApplicationName"/>
|
||||
<input type="hidden" asp-for="SelectiveStores" value="@Model.SelectiveStores"/>
|
||||
<input type="hidden" asp-for="Permissions" value="@Model.Permissions" />
|
||||
<input type="hidden" asp-for="Strict" value="@Model.Strict" />
|
||||
<input type="hidden" asp-for="ApplicationName" value="@Model.ApplicationName" />
|
||||
<input type="hidden" asp-for="SelectiveStores" value="@Model.SelectiveStores" />
|
||||
<input type="hidden" asp-for="StoreMode" value="@Model.StoreMode" />
|
||||
<section>
|
||||
<div class="card container">
|
||||
<div class="row">
|
||||
|
@ -39,76 +32,60 @@
|
|||
<div class="list-group-item ">
|
||||
<div class="form-group">
|
||||
<label asp-for="Label"></label>
|
||||
<input asp-for="Label" class="form-control"/>
|
||||
<input asp-for="Label" class="form-control" />
|
||||
<span asp-validation-for="Label" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
@if (!Model.PermissionsFormatted.Any())
|
||||
@if (!permissions.Any())
|
||||
{
|
||||
<div class="list-group-item form-group">
|
||||
<p >There are no associated permissions to the API key being requested here. The application cannot do anything with your BTCPay account other than validating your account exists.</p>
|
||||
<p>There are no associated permissions to the API key being requested here. The application cannot do anything with your BTCPay account other than validating your account exists.</p>
|
||||
</div>
|
||||
}
|
||||
@if (Model.PermissionsFormatted.Contains(Permissions.ServerManagement) && (Model.IsServerAdmin || Model.Strict))
|
||||
{
|
||||
<div class="list-group-item form-group">
|
||||
@if (Model.Strict || !Model.IsServerAdmin)
|
||||
{
|
||||
<input type="hidden" asp-for="ServerManagementPermission"/>
|
||||
<input type="checkbox" class="form-check-inline" checked="@Model.ServerManagementPermission" disabled/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<input type="checkbox" asp-for="ServerManagementPermission" class="form-check-inline"/>
|
||||
}
|
||||
|
||||
<label asp-for="ServerManagementPermission" class="h5">@GetTitle(Permissions.ServerManagement)</label>
|
||||
@if (!Model.IsServerAdmin)
|
||||
{
|
||||
<span class="text-danger">
|
||||
The server management permission is being requested but your account is not an administrator
|
||||
</span>
|
||||
}
|
||||
|
||||
<span asp-validation-for="ServerManagementPermission" class="text-danger"></span>
|
||||
<p>@GetDescription(Permissions.ServerManagement).</p>
|
||||
</div>
|
||||
}
|
||||
@for (int i = 0; i < Model.PermissionValues.Count; i++)
|
||||
{
|
||||
<div class="list-group-item form-group">
|
||||
<input type="hidden" asp-for="PermissionValues[i].Permission">
|
||||
@if (Model.Strict || !Model.IsServerAdmin)
|
||||
@if (Model.Strict)
|
||||
{
|
||||
<input type="hidden" asp-for="PermissionValues[i].Value"/>
|
||||
<input type="checkbox" class="form-check-inline" checked="@Model.PermissionValues[i].Value" disabled/>
|
||||
<input id="@Model.PermissionValues[i].Permission" type="hidden" asp-for="PermissionValues[i].Value" />
|
||||
<input type="checkbox" class="form-check-inline" checked="@Model.PermissionValues[i].Value" disabled />
|
||||
}
|
||||
else
|
||||
{
|
||||
<input type="checkbox" asp-for="PermissionValues[i].Value" class="form-check-inline"/>
|
||||
<input id="@Model.PermissionValues[i].Permission" type="checkbox" asp-for="PermissionValues[i].Value" class="form-check-inline" />
|
||||
}
|
||||
<label asp-for="PermissionValues[i].Value" class="h5">@GetTitle(Model.PermissionValues[i].Permission)</label>
|
||||
<span asp-validation-for="PermissionValues[i].Value" class="text-danger"></span>
|
||||
<p>@GetDescription(Model.PermissionValues[i].Permission).</p>
|
||||
<label asp-for="PermissionValues[i].Value" class="h5">@Model.PermissionValues[i].Title</label>
|
||||
|
||||
@if (Model.PermissionValues[i].Forbidden)
|
||||
{
|
||||
<br />
|
||||
<span class="text-danger">
|
||||
This permission is not available for your account.
|
||||
</span>
|
||||
}
|
||||
<p>@Model.PermissionValues[i].Description</p>
|
||||
</div>
|
||||
}
|
||||
@if (Model.PermissionsFormatted.Contains(Permissions.StoreManagement))
|
||||
@if (hasStorePermission)
|
||||
{
|
||||
@if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores)
|
||||
{
|
||||
<div class="list-group-item form-group">
|
||||
@if (Model.Strict)
|
||||
{
|
||||
<input type="hidden" asp-for="StoreManagementPermission"/>
|
||||
<input type="checkbox" class="form-check-inline" checked="@Model.StoreManagementPermission" disabled/>
|
||||
<input id="@Model.StoreManagementPermission.Permission" type="hidden" asp-for="StoreManagementPermission.Value" />
|
||||
<input type="checkbox" class="form-check-inline" checked="@Model.StoreManagementPermission.Value" disabled />
|
||||
}
|
||||
else
|
||||
{
|
||||
<input type="checkbox" asp-for="StoreManagementPermission" class="form-check-inline"/>
|
||||
<input id="@Model.StoreManagementPermission.Permission" type="checkbox" asp-for="StoreManagementPermission.Value" class="form-check-inline" />
|
||||
}
|
||||
<label asp-for="StoreManagementPermission" class="h5">@GetTitle(Permissions.StoreManagement)</label>
|
||||
<label asp-for="StoreManagementPermission" class="h5">@Model.StoreManagementPermission.Title</label>
|
||||
<br />
|
||||
<span asp-validation-for="StoreManagementPermission" class="text-danger"></span>
|
||||
<p class="mb-0">@GetDescription(Permissions.StoreManagement).</p>
|
||||
<p class="mb-0">@Model.StoreManagementPermission.Description</p>
|
||||
@if (Model.SelectiveStores)
|
||||
{
|
||||
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to specific stores instead</button>
|
||||
|
@ -119,8 +96,8 @@
|
|||
{
|
||||
<div class="list-group-item p-0 border-0 mb-2">
|
||||
<li class="list-group-item">
|
||||
<h5 class="mb-1">@GetTitle(Permissions.StoreManagement + ":")</h5>
|
||||
<p class="mb-0">@GetDescription(Permissions.StoreManagement + ":").</p>
|
||||
<h5 class="mb-1">@Model.StoreManagementSelectivePermission.Title</h5>
|
||||
<p class="mb-0">@Model.StoreManagementSelectivePermission.Description</p>
|
||||
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to all stores instead</button>
|
||||
</li>
|
||||
@if (!Model.Stores.Any())
|
||||
|
|
Loading…
Add table
Reference in a new issue