feat(dashboard): scoped API keys with rate limits and quotas

Bearer keys (dlk_...) are checked per request against their owner's
current account (ban, lock, demotion, sign out everywhere stop or narrow
them at once) and their scope: permission routes need the permission in
scope, read-only keys only read, level-only routes need an all-permission
key, and session-only paths (sign-in, password, 2FA, key management)
are never reachable with a key. Per-key rate limit and daily quota with
429 and X-RateLimit/X-Quota/Retry-After headers; usage is written in
batches every minute. WebSocket subscriptions honour the scope too.

Routes to list, make, rotate and revoke keys; staff with
api_keys_manage can see and revoke others' keys under the rank rules.
POST /api/auth/token now makes an all-permission key. Audit entries for
create/rotate/revoke/denied, and actions done with a key are attributed
to "user (key name)".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 08:43:12 -05:00
parent 03d5fae0c5
commit fc4e4eda9f
30 changed files with 1194 additions and 33 deletions

View File

@@ -1,4 +1,5 @@
#include "RouteUtils.h"
#include "ApiKeyService.h"
#include "Permissions.h"
#include "Database.h"
@@ -32,8 +33,17 @@ namespace {
namespace RouteUtils {
namespace {
std::vector<RouteDoc> g_RouteDocs;
int g_ReadRoutes = 0;
std::shared_ptr<RequireAuthMiddleware> MakeRequireAuth(std::shared_ptr<RequireAuthMiddleware> middleware) {
if (g_ReadRoutes > 0) middleware->SetReadsOnly();
return middleware;
}
}
ReadRoutes::ReadRoutes() { g_ReadRoutes++; }
ReadRoutes::~ReadRoutes() { g_ReadRoutes--; }
void Register(eHTTPMethod method, const std::string& path, std::vector<MiddlewarePtr> middleware, Handler handler) {
Game::web.RegisterHTTPRoute({
.path = path,
@@ -53,7 +63,7 @@ namespace RouteUtils {
void Route(eHTTPMethod method, const std::string& path, int16_t minGmLevel, const std::string& description, Handler handler) {
std::vector<MiddlewarePtr> middleware;
if (minGmLevel >= 0) middleware.push_back(std::make_shared<RequireAuthMiddleware>(static_cast<uint8_t>(minGmLevel)));
if (minGmLevel >= 0) middleware.push_back(MakeRequireAuth(std::make_shared<RequireAuthMiddleware>(static_cast<uint8_t>(minGmLevel))));
g_RouteDocs.push_back({ std::string(magic_enum::enum_name(method)), path, minGmLevel, description, "" });
Register(method, path, std::move(middleware), std::move(handler));
}
@@ -61,13 +71,13 @@ namespace RouteUtils {
void Route(eHTTPMethod method, const std::string& path, const Perm& permission, const std::string& description, Handler handler) {
if (!Permissions::Find(permission.key)) LOG("Route %s uses unknown permission %s; nobody can use it", path.c_str(), permission.key.c_str());
std::vector<MiddlewarePtr> middleware;
middleware.push_back(std::make_shared<RequireAuthMiddleware>(std::function<uint8_t()>([key = permission.key] { return Permissions::Level(key); })));
middleware.push_back(MakeRequireAuth(std::make_shared<RequireAuthMiddleware>(std::function<uint8_t()>([key = permission.key] { return Permissions::Level(key); }), permission.key)));
g_RouteDocs.push_back({ std::string(magic_enum::enum_name(method)), path, Permissions::Level(permission.key), description, permission.key });
Register(method, path, std::move(middleware), std::move(handler));
}
bool Can(const HTTPContext& context, const std::string& permission) {
return context.isAuthenticated && Permissions::Allowed(context.gmLevel, permission);
return context.isAuthenticated && Permissions::Allowed(context.gmLevel, permission, context.apiKey.get());
}
std::optional<LWOOBJID> ResolveCharacter(std::string_view text) {
@@ -81,7 +91,7 @@ namespace RouteUtils {
}
bool CanViewCharacter(const HTTPContext& context, uint32_t ownerAccountId) {
return context.isAuthenticated && Permissions::CanViewCharacter(context.gmLevel, context.accountId, ownerAccountId);
return context.isAuthenticated && Permissions::CanViewCharacter(context.gmLevel, context.accountId, ownerAccountId, context.apiKey.get());
}
const std::vector<RouteDoc>& GetRouteDocs() {
@@ -125,13 +135,20 @@ namespace RouteUtils {
void Audit(const HTTPContext& context, const std::string& action, const std::string& description, const AuditTarget& target) {
// Staff acting on their own account or characters is called out, so it stands out in the log and in alerts
const auto text = description + OwnAccountNote(context.accountId, target.accountId);
// What was done with an API key says which key: "user (key name)"
auto actor = context.authenticatedUser;
if (context.apiKey) {
// The audit log's name column holds 64 characters; shorten the key's name rather than the account's
const auto room = actor.size() + 3 < 64 ? 64 - actor.size() - 3 : 0;
actor += " (" + context.apiKey->name.substr(0, room) + ")";
}
try {
Database::Get()->InsertAuditLog(context.accountId, context.authenticatedUser, action, text, target.accountId, target.characterId);
Database::Get()->InsertAuditLog(context.accountId, actor, action, text, target.accountId, target.characterId);
} catch (const std::exception& ex) {
LOG("Failed to write audit log entry %s: %s", action.c_str(), ex.what());
}
LOG("[audit] %s: %s %s", context.authenticatedUser.c_str(), action.c_str(), text.c_str());
Alerts::FromAudit(context.authenticatedUser, action, text);
LOG("[audit] %s: %s %s", actor.c_str(), action.c_str(), text.c_str());
Alerts::FromAudit(actor, action, text);
}
std::string HashPassword(const std::string& password) {
@@ -222,7 +239,7 @@ namespace RouteUtils {
}
bool CanManageAccount(const HTTPContext& context, uint8_t targetLevel, uint32_t targetAccountId, eAccountAction action) {
return context.isAuthenticated && AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action) == eManageDenial::NONE;
return context.isAuthenticated && AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action, context.apiKey.get()) == eManageDenial::NONE;
}
nlohmann::json ManageJson(const HTTPContext& context, uint8_t targetLevel, uint32_t targetAccountId) {
@@ -240,8 +257,14 @@ namespace RouteUtils {
return std::nullopt;
}
const uint8_t targetLevel = target.value("gm_level", 0);
const auto denial = AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action);
const auto denial = AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action, context.apiKey.get());
if (denial == eManageDenial::NONE) return targetLevel;
// The owner may do it, but the key's scope doesn't let it
if (context.apiKey && AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action) == eManageDenial::NONE) {
ApiKeyService::NoteDenied(context, AccountRules::DenialMessage(denial, action));
JsonError(reply, eHTTPStatusCode::FORBIDDEN, "This API key may not do this: " + AccountRules::DenialMessage(denial, action));
return std::nullopt;
}
JsonError(reply, eHTTPStatusCode::FORBIDDEN, AccountRules::DenialMessage(denial, action));
return std::nullopt;
}
@@ -261,7 +284,7 @@ namespace RouteUtils {
try {
data.merge_patch(context.GetUserDataJson());
data["current_page"] = page;
data["can"] = Permissions::ForLevel(context.isAuthenticated ? context.gmLevel : 0);
data["can"] = Permissions::ForLevel(context.isAuthenticated ? context.gmLevel : 0, context.apiKey.get());
// The account's view choices, on <body> so each page's toggles start as they were left (static/js/common.js)
// Names for the game's numbered values, from the server's enums (GameLabels.h)
data["labels"] = GameLabels::Json();