Include API key - permission analysis for improved security (#6771)
What changed, and why it matters
This commit adds a new feature that tracks which permissions each API key actually uses and shows the owner a dashboard of used, unused, and stale permissions. It is a security-hardening and visibility improvement, not a fix for an active vulnerability. The change also deletes usage records when an API key is removed.
Treat as a routine security-enhancement feature. Review the new authorization check on /api-keys/{id}/view-analysis for correctness, ensure the raw SQL upsert is safe from injection (parameters appear used correctly), and confirm migration rollback behavior in production deployments. No urgent patching is indicated.
Security signals we found
New API key permission usage tracking table and migration
New UI page showing used/unused/stale API key permissions
Permission usage recorded on every successful authorization check
Usage records deleted when API key is deleted
Access control check prevents users from viewing other users' key analysis
Tests verify authorization boundary and usage counting
Evidence from the diff
The patch introduces an ApiKeyPermissionUsage entity, a database migration, and a UI page at /api-keys/{id}/view-analysis. It records permission usage via BuiltInPermissionHandler when an API key is used, stores last-used time and usage count, and exposes analytics to the key owner. It also adds an UpdateKey repository method and cleans up usage rows on key deletion. There is no evidence in the diff of a vulnerability being patched; the title and tests frame this as a proactive security improvement.
Changed components
BTCPayServer.Data/ApplicationDbContext.csBTCPayServer.Data/Data/ApiKeyPermissionUsage.csBTCPayServer.Data/Migrations/20260302142959_includeApiKeyUsage.csBTCPayServer/Controllers/UIManageController.APIKeys.csBTCPayServer/Security/BuiltInPermissionHandler.csBTCPayServer/Security/GreenField/APIKeyRepository.csBTCPayServer/Views/UIManage/APIKeyPermissionAnalysis.cshtmlBTCPayServer/Views/UIManage/APIKeys.cshtmlInspect captured patch +546 / −4
diff --git a/BTCPayServer.Data/ApplicationDbContext.cs b/BTCPayServer.Data/ApplicationDbContext.cs
index f6e2108..73651e5 100644
--- a/BTCPayServer.Data/ApplicationDbContext.cs
+++ b/BTCPayServer.Data/ApplicationDbContext.cs
@@ -23,6 +23,7 @@ namespace BTCPayServer.Data
}
public DbSet<AddressInvoiceData> AddressInvoices { get; set; }
public DbSet<APIKeyData> ApiKeys { get; set; }
+ public DbSet<ApiKeyPermissionUsage> ApiKeyPermissionUsages { get; set; }
public DbSet<AppData> Apps { get; set; }
public DbSet<StoredFile> Files { get; set; }
public DbSet<InvoiceSearchData> InvoiceSearches { get; set; }
diff --git a/BTCPayServer.Data/Data/ApiKeyPermissionUsage.cs b/BTCPayServer.Data/Data/ApiKeyPermissionUsage.cs
new file mode 100644
index 0000000..d361581
--- /dev/null
+++ b/BTCPayServer.Data/Data/ApiKeyPermissionUsage.cs
@@ -0,0 +1,15 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+
+namespace BTCPayServer.Data
+{
+ public class ApiKeyPermissionUsage
+ {
+ [Key]
+ public string Id { get; set; } // Id in the format [apiKey]-[permission]
+ public string ApiKey { get; set; }
+ public string Permission { get; set; }
+ public DateTimeOffset LastUsed { get; set; }
+ public int UsageCount { get; set; }
+ }
+}
diff --git a/BTCPayServer.Data/Data/Subscriptions/SubscriberData.cs b/BTCPayServer.Data/Data/Subscriptions/SubscriberData.cs
index cbd30b3..b22ea27 100644
--- a/BTCPayServer.Data/Data/Subscriptions/SubscriberData.cs
+++ b/BTCPayServer.Data/Data/Subscriptions/SubscriberData.cs
@@ -1,4 +1,4 @@
-#nullable enable
+#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
@@ -134,7 +134,7 @@ public class SubscriberData : BaseEntityData
{
NextPlan:
{
- Status: Data.Subscriptions.PlanData.PlanStatus.Active
+ Status: PlanData.PlanStatus.Active
},
IsSuspended: false
}
@@ -143,7 +143,7 @@ public class SubscriberData : BaseEntityData
&& (newSubscriber || this.PlanId != this.NextPlan.Id || this.IsNextPlanRenewable);
[NotMapped]
- public bool IsNextPlanRenewable => this.NextPlan is { Renewable: true, Status: Data.Subscriptions.PlanData.PlanStatus.Active };
+ public bool IsNextPlanRenewable => this.NextPlan is { Renewable: true, Status: PlanData.PlanStatus.Active };
public PhaseTypes GetExpectedPhase(DateTimeOffset time)
=> this switch
diff --git a/BTCPayServer.Data/Migrations/20260302142959_includeApiKeyUsage.cs b/BTCPayServer.Data/Migrations/20260302142959_includeApiKeyUsage.cs
new file mode 100644
index 0000000..c0d358f
--- /dev/null
+++ b/BTCPayServer.Data/Migrations/20260302142959_includeApiKeyUsage.cs
@@ -0,0 +1,41 @@
+using System;
+using BTCPayServer.Data;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace BTCPayServer.Migrations
+{
+
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260302142959_includeApiKeyUsage")]
+ public partial class includeApiKeyUsage : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "ApiKeyPermissionUsages",
+ columns: table => new
+ {
+ Id = table.Column<string>(type: "text", nullable: false),
+ ApiKey = table.Column<string>(type: "text", nullable: true),
+ Permission = table.Column<string>(type: "text", nullable: true),
+ LastUsed = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
+ UsageCount = table.Column<int>(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ApiKeyPermissionUsages", x => x.Id);
+ });
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "ApiKeyPermissionUsages");
+ }
+ }
+}
diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
index f4a0134..86da9a5 100644
--- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -76,6 +76,28 @@ namespace BTCPayServer.Migrations
b.ToTable("AddressInvoices");
});
+ modelBuilder.Entity("BTCPayServer.Data.ApiKeyPermissionUsage", b =>
+ {
+ b.Property<string>("Id")
+ .HasColumnType("text");
+
+ b.Property<string>("ApiKey")
+ .HasColumnType("text");
+
+ b.Property<DateTimeOffset>("LastUsed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property<string>("Permission")
+ .HasColumnType("text");
+
+ b.Property<int>("UsageCount")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("ApiKeyPermissionUsages");
+ });
+
modelBuilder.Entity("BTCPayServer.Data.AppData", b =>
{
b.Property<string>("Id")
diff --git a/BTCPayServer.Tests/ApiKeysTests.cs b/BTCPayServer.Tests/ApiKeysTests.cs
index 57aa74a..bcba57f 100644
--- a/BTCPayServer.Tests/ApiKeysTests.cs
+++ b/BTCPayServer.Tests/ApiKeysTests.cs
@@ -252,6 +252,102 @@ namespace BTCPayServer.Tests
Assert.Contains("There are no associated permissions to the API key being requested", emptyPageContent);
}
+
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ public async Task CanViewApiKeyPermissionAnalysis()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ var tester = s.Server;
+ var user = tester.NewAccount();
+ await user.GrantAccessAsync();
+ await s.GoToLogin();
+ await s.LogIn(user.RegisterDetails.Email, user.RegisterDetails.Password);
+
+ await s.GoToProfile(ManageNavPages.APIKeys);
+ await s.ClickPagePrimary();
+ await s.Page.SetCheckedAsync("#btcpay\\.store\\.cancreateinvoice", true);
+ await s.Page.SetCheckedAsync("#btcpay\\.store\\.canviewinvoices", true);
+ await s.ClickPagePrimary();
+ var apiKey = await (await s.FindAlertMessage()).Locator("code").TextContentAsync();
+
+ await s.GoToUrl($"api-keys/{apiKey}/view-analysis");
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+
+ var cards = await s.Page.Locator(".display-6.fw-bold").AllTextContentsAsync();
+ Assert.Equal("2", cards[0].Trim());
+ Assert.Equal("0", cards[1].Trim());
+ Assert.Equal("2", cards[2].Trim());
+
+ var allRows = s.Page.Locator("#all-permissions tbody tr");
+ Assert.Equal(2, await allRows.CountAsync());
+ var neverUsedBadges = s.Page.Locator("#all-permissions .badge:has-text('Never Used')");
+ Assert.Equal(2, await neverUsedBadges.CountAsync());
+
+ await s.Page.Locator("#used-tab").ClickAsync();
+ await s.Page.Locator("#used-permissions.show.active").WaitForAsync();
+ Assert.Equal(0, await s.Page.Locator("#used-permissions tbody tr").CountAsync());
+
+ await s.Page.Locator("#unused-tab").ClickAsync();
+ await s.Page.Locator("#unused-permissions.show.active").WaitForAsync();
+ var unusedRows = s.Page.Locator("#unused-permissions tbody tr");
+ Assert.Equal(2, await unusedRows.CountAsync());
+
+ var uri = new Uri(tester.PayTester.ServerUri, $"api/v1/stores/{user.StoreId}/invoices");
+ var request = new HttpRequestMessage(HttpMethod.Get, uri);
+ request.Headers.Authorization = new AuthenticationHeaderValue("token", apiKey);
+ var response = await tester.PayTester.HttpClient.SendAsync(request);
+ response.EnsureSuccessStatusCode();
+
+ await s.Page.ReloadAsync();
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+
+ cards = await s.Page.Locator(".display-6.fw-bold").AllTextContentsAsync();
+ Assert.Equal("2", cards[0].Trim());
+ Assert.Equal("1", cards[1].Trim());
+ Assert.Equal("1", cards[2].Trim());
+
+ await s.Page.Locator("#used-tab").ClickAsync();
+ await s.Page.Locator("#used-permissions.show.active").WaitForAsync();
+ var usedRows = s.Page.Locator("#used-permissions tbody tr");
+ Assert.Equal(1, await usedRows.CountAsync());
+ await s.Page.Locator("#used-permissions .badge.bg-success:has-text('Active')").WaitForAsync();
+
+ await s.Page.Locator("#unused-tab").ClickAsync();
+ await s.Page.Locator("#unused-permissions.show.active").WaitForAsync();
+ unusedRows = s.Page.Locator("#unused-permissions tbody tr");
+ Assert.Equal(1, await unusedRows.CountAsync());
+ }
+
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ public async Task CannotViewOtherUsersApiKeyAnalysis()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ var tester = s.Server;
+
+ var user1 = tester.NewAccount();
+ await user1.GrantAccessAsync();
+ var user2 = tester.NewAccount();
+ await user2.GrantAccessAsync();
+
+ await s.GoToLogin();
+ await s.LogIn(user1.RegisterDetails.Email, user1.RegisterDetails.Password);
+ await s.GoToProfile(ManageNavPages.APIKeys);
+ await s.ClickPagePrimary();
+ await s.ClickPagePrimary();
+ var user1ApiKey = await (await s.FindAlertMessage()).Locator("code").TextContentAsync();
+ await s.Logout();
+
+ await s.GoToLogin();
+ await s.LogIn(user2.RegisterDetails.Email, user2.RegisterDetails.Password);
+ await s.GoToUrl($"api-keys/{user1ApiKey}/view-analysis");
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ Assert.Contains("404", await s.Page.ContentAsync());
+ }
+
async Task TestApiAgainstAccessToken(string accessToken, ServerTester tester, TestAccount testAccount,
params string[] expectedPermissionsArr)
{
diff --git a/BTCPayServer/Controllers/UIManageController.APIKeys.cs b/BTCPayServer/Controllers/UIManageController.APIKeys.cs
index 4578476..2a41bc0 100644
--- a/BTCPayServer/Controllers/UIManageController.APIKeys.cs
+++ b/BTCPayServer/Controllers/UIManageController.APIKeys.cs
@@ -31,6 +31,50 @@ namespace BTCPayServer.Controllers
});
}
+ [HttpGet("~/api-keys/{id}/view-analysis")]
+ public async Task<IActionResult> APIKeyPermissionAnalysis(string id)
+ {
+ var key = await _apiKeyRepository.GetKey(id);
+ if (key == null || key.UserId != _userManager.GetUserId(User))
+ return NotFound();
+
+ var allPermissions = key.GetBlob().Permissions;
+ var usageRecords = await _apiKeyRepository.GetAPIPermissionUsageRecords(id);
+ var usageByPermission = usageRecords.ToDictionary(u => u.Permission, u => u);
+
+ var usedPermissions = new List<PermissionUsageViewModel>();
+ var unusedPermissions = new List<string>();
+ var allPermissionVMs = new List<PermissionViewModel>();
+
+ foreach (var permission in allPermissions)
+ {
+ if (usageByPermission.TryGetValue(permission, out var usage))
+ {
+ var usageVM = new PermissionUsageViewModel
+ {
+ Permission = usage.Permission,
+ LastUsed = usage.LastUsed,
+ UsageCount = usage.UsageCount
+ };
+ usedPermissions.Add(usageVM);
+ allPermissionVMs.Add(new PermissionViewModel { Permission = permission, Usage = usageVM });
+ }
+ else
+ {
+ unusedPermissions.Add(permission);
+ allPermissionVMs.Add(new PermissionViewModel { Permission = permission, Usage = null });
+ }
+ }
+ return View(new ApiKeyPermissionAnalyticsViewModel
+ {
+ ApiKey = key.Id,
+ Label = key.Label,
+ UsedPermissions = usedPermissions,
+ UnusedPermissions = unusedPermissions,
+ AllPermissions = allPermissionVMs
+ });
+ }
+
[HttpGet("~/api-keys/{id}/delete")]
public async Task<IActionResult> DeleteAPIKey(string id)
{
@@ -586,5 +630,27 @@ namespace BTCPayServer.Controllers
{
public List<APIKeyData> ApiKeyDatas { get; set; }
}
+
+ public class ApiKeyPermissionAnalyticsViewModel
+ {
+ public string ApiKey { get; set; }
+ public string Label { get; set; }
+ public List<PermissionUsageViewModel> UsedPermissions { get; set; } = new();
+ public List<string> UnusedPermissions { get; set; } = new();
+ public List<PermissionViewModel> AllPermissions { get; set; } = new();
+ public int TotalPermissions => AllPermissions.Count;
+ }
+
+ public class PermissionUsageViewModel
+ {
+ public string Permission { get; set; }
+ public DateTimeOffset LastUsed { get; set; }
+ public int UsageCount { get; set; }
+ }
+ public class PermissionViewModel
+ {
+ public string Permission { get; set; }
+ public PermissionUsageViewModel Usage { get; set; }
+ }
}
}
diff --git a/BTCPayServer/Security/BuiltInPermissionHandler.cs b/BTCPayServer/Security/BuiltInPermissionHandler.cs
index 6d50a20..86fdac7 100644
--- a/BTCPayServer/Security/BuiltInPermissionHandler.cs
+++ b/BTCPayServer/Security/BuiltInPermissionHandler.cs
@@ -13,7 +13,8 @@ namespace BTCPayServer.Security;
public class BuiltInPermissionHandler(
StoreRepository storeRepository,
- PermissionService permissionService) : IPermissionHandler
+ PermissionService permissionService,
+ APIKeyRepository apiKeyRepository) : IPermissionHandler
{
public const string StoreKey = "BuiltInPermissionHandler-Store";
public const string StoresKey = "BuiltInPermissionHandler-Stores";
@@ -73,6 +74,10 @@ public class BuiltInPermissionHandler(
if (success is true)
{
+ if (permContext.HttpContext.GetAPIKey(out var apiKey))
+ {
+ _ = apiKeyRepository.RecordPermissionUsage(apiKey, permContext.Permission);
+ }
authContext.Succeed(permContext.Requirement);
if (permissionedStore is not null)
permContext.HttpContext.Items[StoreKey] = permissionedStore;
diff --git a/BTCPayServer/Security/GreenField/APIKeyRepository.cs b/BTCPayServer/Security/GreenField/APIKeyRepository.cs
index 4125961..1d564e9 100644
--- a/BTCPayServer/Security/GreenField/APIKeyRepository.cs
+++ b/BTCPayServer/Security/GreenField/APIKeyRepository.cs
@@ -2,8 +2,12 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
+using BTCPayServer.Client;
using BTCPayServer.Data;
+using BTCPayServer.Plugins.Shopify.ApiModels;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Internal;
+using Npgsql;
namespace BTCPayServer.Security.Greenfield
{
@@ -52,6 +56,26 @@ namespace BTCPayServer.Security.Greenfield
await context.SaveChangesAsync();
}
+ public async Task UpdateKey(string id, Permission[] permissions, string label, string userId)
+ {
+ using var context = _applicationDbContextFactory.CreateContext();
+ var key = await EntityFrameworkQueryableExtensions.SingleOrDefaultAsync(context.ApiKeys,
+ data => data.Id == id && data.UserId == userId);
+ if (key != null)
+ {
+ var keyBlob = key.GetBlob();
+ key.Label = label;
+ key.SetBlob(new APIKeyBlob
+ {
+ Permissions = permissions.Select(p => p.ToString()).ToArray(),
+ ApplicationAuthority = keyBlob.ApplicationAuthority,
+ ApplicationIdentifier = keyBlob.ApplicationIdentifier
+ });
+ context.ApiKeys.Update(key);
+ await context.SaveChangesAsync();
+ }
+ }
+
public async Task<bool> Remove(string id, string getUserId)
{
using (var context = _applicationDbContextFactory.CreateContext())
@@ -60,12 +84,39 @@ namespace BTCPayServer.Security.Greenfield
data => data.Id == id && data.UserId == getUserId);
if (key == null)
return false;
+
+ await context.ApiKeyPermissionUsages.Where(u => u.Id.StartsWith(id)).ExecuteDeleteAsync();
context.ApiKeys.Remove(key);
await context.SaveChangesAsync();
}
return true;
}
+ public async Task RecordPermissionUsage(string apiKey, Permission permission)
+ {
+ using var context = _applicationDbContextFactory.CreateContext();
+ var sql = @"
+ INSERT INTO ""ApiKeyPermissionUsages"" (""Id"", ""ApiKey"", ""Permission"", ""LastUsed"", ""UsageCount"")
+ VALUES (@Id, @ApiKey, @Permission, @LastUsed, 1)
+ ON CONFLICT (""Id"")
+ DO UPDATE SET
+ ""LastUsed"" = @LastUsed,
+ ""UsageCount"" = ""ApiKeyPermissionUsages"".""UsageCount"" + 1";
+
+ await context.Database.ExecuteSqlRawAsync(sql,
+ new NpgsqlParameter("@Id", $"{apiKey}-{permission}"),
+ new NpgsqlParameter("@ApiKey", apiKey),
+ new NpgsqlParameter("@Permission", permission.Policy),
+ new NpgsqlParameter("@LastUsed", DateTimeOffset.UtcNow));
+ }
+
+ public async Task<List<ApiKeyPermissionUsage>> GetAPIPermissionUsageRecords(string apiKey)
+ {
+ await using var ctx = _applicationDbContextFactory.CreateContext();
+ var entity = ctx.ApiKeyPermissionUsages.Where(c => c.ApiKey == apiKey).ToList();
+ return entity.Any() ? entity : new List<ApiKeyPermissionUsage>();
+ }
+
public class APIKeyQuery
{
public string[] UserId { get; set; }
diff --git a/BTCPayServer/Views/UIManage/APIKeyPermissionAnalysis.cshtml b/BTCPayServer/Views/UIManage/APIKeyPermissionAnalysis.cshtml
new file mode 100644
index 0000000..c37d389
--- /dev/null
+++ b/BTCPayServer/Views/UIManage/APIKeyPermissionAnalysis.cshtml
@@ -0,0 +1,243 @@
+@using BTCPayServer.Client
+@model BTCPayServer.Controllers.UIManageController.ApiKeyPermissionAnalyticsViewModel
+@{
+ var staleThresholdDays = 100;
+ var usedPermissionsPercentage = Model.TotalPermissions > 0 ? (int)Math.Round((double)Model.UsedPermissions.Count / Model.TotalPermissions * 100) : 0;
+ ViewData.SetLayoutModel(new LayoutModel(nameof(ManageNavPages.APIKeys), StringLocalizer["API Key Analysis"])
+ .SetCategory(nameof(ManageNavPages)));
+}
+
+<div class="sticky-header">
+ <nav aria-label="breadcrumb">
+ <ol class="breadcrumb">
+ <li class="breadcrumb-item">
+ <a asp-action="APIKeys" text-translate="true">API Keys</a>
+ </li>
+ <li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
+ </ol>
+ <h2>@ViewData["Title"]</h2>
+ </nav>
+</div>
+
+<div class="row g-3 mb-4">
+ <div class="col-md-4">
+ <div class="card h-100 border-0 shadow-sm">
+ <div class="card-body">
+ <div class="d-flex align-items-center justify-content-between mb-2">
+ <span class="text-muted small" text-translate="true">Total Permissions</span>
+ <i class="bi bi-key fs-5 text-muted"></i>
+ </div>
+ <div class="display-6 fw-bold mb-2">@Model.TotalPermissions</div>
+ <div class="progress" style="height:4px">
+ <div class="progress-bar" style="width:100%"></div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="col-md-4">
+ <div class="card h-100 border-0 shadow-sm">
+ <div class="card-body">
+ <div class="d-flex align-items-center justify-content-between mb-2">
+ <span class="text-muted small" text-translate="true">Used Permissions</span>
+ <i class="bi bi-check-circle fs-5 text-muted"></i>
+ </div>
+ <div class="display-6 fw-bold mb-1">@Model.UsedPermissions.Count</div>
+ <div class="small text-muted mb-2">@usedPermissionsPercentage% of total</div>
+ <div class="progress" style="height:4px">
+ <div class="progress-bar" style="width:@usedPermissionsPercentage%"></div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="col-md-4">
+ <div class="card h-100 border-0 shadow-sm">
+ <div class="card-body">
+ <div class="d-flex align-items-center justify-content-between mb-2">
+ <span class="text-muted small" text-translate="true">Unused Permissions</span>
+ <i class="bi bi-exclamation-circle fs-5 text-muted"></i>
+ </div>
+ <div class="display-6 fw-bold mb-1">@Model.UnusedPermissions.Count</div>
+ <div class="small text-muted mb-2">@(100 - usedPermissionsPercentage)% of total</div>
+ <div class="progress" style="height:4px">
+ <div class="progress-bar" style="width:@(100 - usedPermissionsPercentage)%"></div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+
+<div class="card border-0 shadow-sm overflow-hidden">
+ <div class="card-header bg-white">
+ <ul class="nav nav-underline" id="permissionTabs" role="tablist">
+ <li class="nav-item" role="presentation">
+ <button class="nav-link active" id="all-tab" data-bs-toggle="tab" data-bs-target="#all-permissions" type="button" role="tab">
+ @ViewLocalizer["All ({0})", Model.AllPermissions.Count]
+ </button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button class="nav-link" id="used-tab" data-bs-toggle="tab" data-bs-target="#used-permissions" type="button" role="tab">
+ @ViewLocalizer["Used ({0})", Model.UsedPermissions.Count]
+ </button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button class="nav-link" id="unused-tab" data-bs-toggle="tab" data-bs-target="#unused-permissions" type="button" role="tab">
+ @ViewLocalizer["Unused ({0})", Model.UnusedPermissions.Count]
+ </button>
+ </li>
+ </ul>
+ </div>
+ <div class="card-body p-3">
+ <div class="tab-content" id="permissionTabContent">
+
+ <div class="tab-pane fade show active" id="all-permissions" role="tabpanel">
+ @if (Model.AllPermissions.Any())
+ {
+ <table class="table table-hover mb-0">
+ <thead class="table-light">
+ <tr>
+ <th text-translate="true">Permission</th>
+ <th text-translate="true">Usage Count</th>
+ <th text-translate="true">Last Used</th>
+ <th text-translate="true">Status</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var permission in Model.AllPermissions.OrderBy(p => p.Permission))
+ {
+ var usage = Model.UsedPermissions.FirstOrDefault(u => u.Permission == permission.Permission);
+ var isUsed = usage != null;
+ var isStale = isUsed && (DateTimeOffset.UtcNow - usage.LastUsed).TotalDays > staleThresholdDays;
+ <tr>
+ <td><code>@permission.Permission</code></td>
+ <td>
+ @if (isUsed)
+ {
+ <span class="fw-semibold">@usage.UsageCount.ToString("N0")</span>
+ }
+ else
+ {
+ <span class="text-muted">—</span>
+ }
+ </td>
+ <td>
+ @if (isUsed)
+ {
+ <span class="text-muted">@usage.LastUsed.ToTimeAgo()</span>
+ }
+ else
+ {
+ <span class="text-muted" text-translate="true">Never</span>
+ }
+ </td>
+ <td>
+ @if (!isUsed)
+ {
+ <span class="badge bg-secondary" text-translate="true">Never Used</span>
+ }
+ else if (isStale)
+ {
+ <span class="badge bg-warning text-dark" text-translate="true">Stale</span>
+ }
+ else
+ {
+ <span class="badge bg-success" text-translate="true">Active</span>
+ }
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ }
+ else
+ {
+ <div class="text-center py-5 text-muted">
+ <i class="bi bi-inbox fs-1 d-block mb-2"></i>
+ <span text-translate="true">No Permission Usage Recorded</span>
+ </div>
+ }
+ </div>
+
+ <div class="tab-pane fade" id="used-permissions" role="tabpanel">
+ @if (Model.UsedPermissions.Any())
+ {
+ <table class="table table-hover mb-0">
+ <thead class="table-light">
+ <tr>
+ <th text-translate="true">Permission</th>
+ <th text-translate="true">Usage Count</th>
+ <th text-translate="true">Last Used</th>
+ <th text-translate="true">Status</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var permission in Model.UsedPermissions.OrderByDescending(p => p.LastUsed))
+ {
+ var isStale = (DateTimeOffset.UtcNow - permission.LastUsed).TotalDays > staleThresholdDays;
+ <tr>
+ <td><code>@permission.Permission</code></td>
+ <td><span class="fw-semibold">@permission.UsageCount.ToString("N0")</span></td>
+ <td><span class="text-muted">@permission.LastUsed.ToTimeAgo()</span></td>
+ <td>
+ @if (isStale)
+ {
+ <span class="badge bg-warning text-dark" text-translate="true">Stale</span>
+ }
+ else
+ {
+ <span class="badge bg-success" text-translate="true">Active</span>
+ }
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ }
+ else
+ {
+ <div class="text-center py-5 text-muted">
+ <i class="bi bi-inbox fs-1 d-block mb-2"></i>
+ <span text-translate="true">No Permission Usage Recorded</span>
+ </div>
+ }
+ </div>
+
+ <div class="tab-pane fade" id="unused-permissions" role="tabpanel">
+ @if (Model.UnusedPermissions.Any())
+ {
+ <table class="table table-hover mb-0">
+ <thead class="table-light">
+ <tr>
+ <th text-translate="true">Permission</th>
+ <th text-translate="true">Status</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var permission in Model.UnusedPermissions.OrderBy(p => p))
+ {
+ <tr>
+ <td><code>@permission</code></td>
+ <td><span class="badge bg-secondary" text-translate="true">Never Used</span></td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ }
+ else
+ {
+ <div class="text-center py-5">
+ <i class="bi bi-shield-check text-success fs-1 d-block mb-2"></i>
+ @if (Model.TotalPermissions > 0)
+ {
+ <span class="fw-semibold" text-translate="true">All permissions are in use!</span>
+ }
+ else
+ {
+ <span class="text-muted" text-translate="true">No Permission Usage Recorded</span>
+ }
+ </div>
+ }
+ </div>
+
+ </div>
+ </div>
+</div>
diff --git a/BTCPayServer/Views/UIManage/APIKeys.cshtml b/BTCPayServer/Views/UIManage/APIKeys.cshtml
index c8625ce..ef57be8 100644
--- a/BTCPayServer/Views/UIManage/APIKeys.cshtml
+++ b/BTCPayServer/Views/UIManage/APIKeys.cshtml
@@ -77,6 +77,8 @@
<a asp-action="DeleteAPIKey" asp-route-id="@keyData.Id" asp-controller="UIManage" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="Any application using the API key <strong>@Html.Encode(keyData.Label ?? keyData.Id)</strong> will immediately lose access." data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Delete</a>
<span>-</span>
<button type="button" class="btn btn-link only-for-js p-0" data-qr="@index" text-translate="true">Show QR</button>
+ <span>-</span>
+ <a id="viewusage-@keyData.Id" asp-action="APIKeyPermissionAnalysis" asp-route-id="@keyData.Id" asp-controller="UIManage" text-translate="true">View Usage</a>
</div>
</td>
</tr>
Why this scored 21/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.