diff --git a/dashboard.html b/dashboard.html
index 45ef72c..98750d9 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -55,7 +55,13 @@
-
Usage by Key
+
+
Usage by Key
+
+
| Key | Requests | OK | Err | Avg Time | Last Request |
@@ -170,7 +176,8 @@ async function refreshStatus() {
async function refreshUsage() {
try {
- const data = await api("/api/usage");
+ const showAll = localStorage.getItem("ocp_usage_show_all") === "1";
+ const data = await api(showAll ? "/api/usage?all=true" : "/api/usage");
const tbody = document.querySelector("#key-usage-table tbody");
tbody.innerHTML = (data.byKey || []).map(k => `
@@ -204,6 +211,8 @@ async function refreshKeys() {
try {
const data = await api("/api/keys");
document.getElementById("key-mgmt-section").style.display = "";
+ // Admin-only "Show all keys" toggle for /api/usage scope.
+ document.getElementById("usage-scope-toggle").style.display = "flex";
const tbody = document.querySelector("#keys-table tbody");
tbody.innerHTML = (data.keys || []).map(k => `
@@ -240,6 +249,16 @@ async function refreshAll() {
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
}
+// Wire "Show all keys" toggle (visibility gated to admin via refreshKeys()).
+(function setupUsageScopeToggle() {
+ const cb = document.getElementById("usage-show-all");
+ cb.checked = localStorage.getItem("ocp_usage_show_all") === "1";
+ cb.addEventListener("change", () => {
+ localStorage.setItem("ocp_usage_show_all", cb.checked ? "1" : "0");
+ refreshUsage();
+ });
+})();
+
refreshAll();
setInterval(refreshAll, 30000);
diff --git a/server.mjs b/server.mjs
index 635c323..6e48a47 100644
--- a/server.mjs
+++ b/server.mjs
@@ -1672,14 +1672,45 @@ const server = createServer(async (req, res) => {
}
if (req.url?.startsWith("/api/usage") && req.method === "GET") {
- if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
+ // Least-privilege scope rules (security audit follow-up):
+ // - non-admin authenticated key → only own rows
+ // - anonymous (PROXY_ANONYMOUS_KEY) → only "anonymous" rows; ?all=true ignored
+ // - admin without ?all=true → only own ("admin") rows
+ // - admin with ?all=true → full byKey/recent (legacy behavior); audited
+ // Authenticated callers are required (anyone reaching here passed the auth gate above);
+ // remote+no-auth requests would have been rejected before this point.
const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`);
const since = url.searchParams.get("since");
const until = url.searchParams.get("until");
+ const wantAll = url.searchParams.get("all") === "true";
+ const callerName = req._authKeyName;
+
+ // Anonymous callers may never opt into all-keys view, even if they pass ?all=true.
+ const isAnonCaller = callerName === "anonymous";
+ const fullScope = isAdmin && wantAll && !isAnonCaller;
+
+ // scopeName === null when fullScope is true (no filter); otherwise the key_name to filter by.
+ const scopeName = fullScope ? null : callerName;
+
+ if (fullScope) {
+ logEvent("info", "admin_usage_full_scope", { caller: callerName, ip: req.socket.remoteAddress || null });
+ }
+
+ const byKeyAll = getUsageByKey({ since, until });
+ const recentAll = getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500));
+ const timeline = getUsageTimeline({
+ keyName: scopeName || undefined,
+ hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720),
+ });
+
+ const byKey = scopeName ? byKeyAll.filter((row) => row.key_name === scopeName) : byKeyAll;
+ const recent = scopeName ? recentAll.filter((row) => row.key_name === scopeName) : recentAll;
+
return jsonResponse(res, 200, {
- byKey: getUsageByKey({ since, until }),
- timeline: getUsageTimeline({ hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720) }),
- recent: getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500)),
+ byKey,
+ timeline,
+ recent,
+ scope: { self: scopeName, all: fullScope },
});
}