mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
What someone may do on the dashboard is now their GM level's permissions plus the grants on their account, minus its denies (PermissionGrants.h). A deny beats a grant; denies never apply to GM 9, and settings and permissions_manage stay GM 9 only. The account's grants are read with every request (like its GM level), so a change applies at once, and they are passed through every check: RouteUtils::Can, CanViewCharacter, the rank rules (self_* and manage_equal_rank), routes guarded by a permission, the templates' `can`, the API documentation, API access, API key scopes (a key never does more than its owner may now) and WebSocket subscriptions. New permission grants_manage (GM 9 by default) and the API to manage grants: GET /api/grants/catalog, GET /api/grants, POST /api/grants, POST /api/grants/:id/remove. Nobody grants or takes away what they don't hold themselves (a permission, every permission of a group, a command they may use, every command up to their own GM level), and only on accounts the rank rules let them manage (their own with self_moderation). Commands with a fixed level or a floor above GM 1 (/execute) can't be granted. Every change goes in the audit log (grant_permission, deny_permission, remove_grant). Also: the Showcase gate and the traffic subscription now check their permission by name. Check: grant a GM 2 account accounts_ban (it can ban, and the Ban button shows); deny a GM 8 account accounts_view (the accounts list is refused); give an expiry a minute ahead and see it stop; try to grant a permission your account doesn't have (refused); dWebTests PermissionGrantsTests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
16 KiB
C++
290 lines
16 KiB
C++
#include "ApiKeyRoutes.h"
|
|
|
|
#include <algorithm>
|
|
#include <ctime>
|
|
|
|
#include "AccountRules.h"
|
|
#include "ApiKeyScope.h"
|
|
#include "ApiKeyService.h"
|
|
#include "Database.h"
|
|
#include "eHTTPMethod.h"
|
|
#include "GeneralUtils.h"
|
|
#include "HTTPContext.h"
|
|
#include "Permissions.h"
|
|
#include "PermissionGrantsLoader.h"
|
|
#include "RequireAuthMiddleware.h"
|
|
#include "RouteUtils.h"
|
|
|
|
using namespace RouteUtils;
|
|
using AccountRules::eAccountAction;
|
|
|
|
namespace {
|
|
constexpr size_t MAX_NAME = 32;
|
|
constexpr size_t MAX_NOTE = 255;
|
|
constexpr size_t MAX_LIST = 512;
|
|
constexpr size_t MAX_ACTIVE_KEYS = 25;
|
|
constexpr int64_t MAX_DAYS = 3650;
|
|
|
|
int64_t Now() { return static_cast<int64_t>(std::time(nullptr)); }
|
|
|
|
bool FromSession(const HTTPContext& context) {
|
|
const auto source = context.userData.find("auth_source");
|
|
return !context.apiKey && source != context.userData.end() && source->second == "cookie";
|
|
}
|
|
|
|
std::string Trim(std::string text) {
|
|
text.erase(0, text.find_first_not_of(" \t\r\n"));
|
|
text.erase(text.find_last_not_of(" \t\r\n") + 1);
|
|
return text;
|
|
}
|
|
|
|
// A comma-separated list checked entry by entry; nullopt if an entry isn't allowed
|
|
std::optional<std::string> CleanList(const std::string& text, bool paths) {
|
|
std::string out;
|
|
for (auto entry : ApiKeys::SplitList(text)) {
|
|
if (paths) {
|
|
std::ranges::transform(entry, entry.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
if (!entry.starts_with('/') || entry.find_first_of(" ,\"'<>") != std::string::npos) return std::nullopt;
|
|
} else if (!std::ranges::all_of(entry, [](char c) { return std::isxdigit(static_cast<unsigned char>(c)) || c == '.' || c == ':'; })) {
|
|
return std::nullopt;
|
|
}
|
|
if (!out.empty()) out += ',';
|
|
out += entry;
|
|
}
|
|
if (out.size() > MAX_LIST) return std::nullopt;
|
|
return out;
|
|
}
|
|
|
|
std::string KeyStatus(const IApiKeys::ApiKey& key, int64_t sessionsValidAfter, int64_t now) {
|
|
if (key.revokedAt != 0) return "revoked";
|
|
if (key.expiresAt != 0 && key.expiresAt <= now) return "expired";
|
|
if (key.issuedAt < sessionsValidAfter) return "signed_out";
|
|
return "active";
|
|
}
|
|
|
|
nlohmann::json KeyJson(const IApiKeys::ApiKey& key, uint8_t ownerLevel, const PermissionGrants::Held* ownerGrants, int64_t sessionsValidAfter) {
|
|
const auto now = Now();
|
|
bool all = false;
|
|
std::set<std::string> permissions;
|
|
ApiKeys::ParsePermissions(key.permissions, all, permissions);
|
|
// What the key names that its owner can't do any more (a demotion or a changed permission): it doesn't work
|
|
nlohmann::json lost = nlohmann::json::array();
|
|
for (const auto& permission : permissions) if (!Permissions::Allowed(ownerLevel, permission, nullptr, ownerGrants)) lost.push_back(permission);
|
|
|
|
auto requests = key.requestCount;
|
|
auto lastUsed = key.lastUsedAt;
|
|
auto lastIp = key.lastIp;
|
|
const auto today = static_cast<int32_t>(now / 86400);
|
|
uint32_t todayCount = key.quotaDay == today ? key.dayCount : 0;
|
|
if (const auto pending = ApiKeyService::PendingUsage(key.id)) {
|
|
requests += pending->requests;
|
|
if (pending->lastUsedAt >= lastUsed) {
|
|
lastUsed = pending->lastUsedAt;
|
|
lastIp = pending->lastIp;
|
|
}
|
|
if (pending->quotaDay == today) todayCount = pending->dayCount;
|
|
}
|
|
return {
|
|
{"id", key.id}, {"accountId", key.accountId}, {"name", key.name}, {"note", key.note}, {"prefix", key.keyPrefix},
|
|
{"allPermissions", all}, {"permissions", permissions}, {"lostPermissions", lost}, {"readOnly", key.readOnly},
|
|
{"allowedIps", key.allowedIps}, {"allowedPaths", key.allowedPaths},
|
|
{"rateLimit", key.rateLimit}, {"effectiveRateLimit", key.rateLimit > 0 ? key.rateLimit : ApiKeyService::DefaultRateLimit()},
|
|
{"dailyQuota", key.dailyQuota}, {"todayCount", todayCount},
|
|
{"createdAt", key.createdAt}, {"createdBy", key.createdBy}, {"issuedAt", key.issuedAt}, {"expiresAt", key.expiresAt},
|
|
{"revokedAt", key.revokedAt}, {"revokedBy", key.revokedBy},
|
|
{"lastUsedAt", lastUsed}, {"lastIp", lastIp}, {"requestCount", requests},
|
|
{"status", KeyStatus(key, sessionsValidAfter, now)},
|
|
};
|
|
}
|
|
|
|
std::string Describe(const IApiKeys::ApiKey& key) {
|
|
std::string text = "'" + key.name + "' (" + key.keyPrefix + "...): ";
|
|
text += key.permissions == ApiKeys::ALL_PERMISSIONS ? "all of their permissions" : (key.permissions.empty() ? "no permissions" : key.permissions);
|
|
if (key.readOnly) text += "; read-only";
|
|
if (!key.allowedIps.empty()) text += "; from " + key.allowedIps;
|
|
if (!key.allowedPaths.empty()) text += "; paths " + key.allowedPaths;
|
|
text += "; " + (key.rateLimit > 0 ? std::to_string(key.rateLimit) : "default") + " requests/min";
|
|
if (key.dailyQuota > 0) text += "; " + std::to_string(key.dailyQuota) + " a day";
|
|
text += key.expiresAt > 0 ? "; expires " + std::to_string(key.expiresAt) : "; no expiry";
|
|
return text;
|
|
}
|
|
|
|
uint32_t ActiveKeyCount(uint32_t accountId, int64_t sessionsValidAfter) {
|
|
const auto now = Now();
|
|
const auto keys = Database::Get()->GetApiKeys(accountId);
|
|
return static_cast<uint32_t>(std::ranges::count_if(keys, [&](const auto& key) { return KeyStatus(key, sessionsValidAfter, now) == "active"; }));
|
|
}
|
|
|
|
// The key, if the signed-in person may act on it: their own, or (to see or revoke) another account's with
|
|
// api_keys_manage under the rank rules. Writes the error reply otherwise.
|
|
std::optional<IApiKeys::ApiKey> KeyForAction(const HTTPContext& context, HTTPReply& reply, bool ownOnly) {
|
|
const auto id = PathId<uint64_t>(context.path, 2);
|
|
const auto key = id ? Database::Get()->GetApiKey(*id) : std::nullopt;
|
|
if (!key) {
|
|
JsonError(reply, eHTTPStatusCode::NOT_FOUND, "API key not found");
|
|
return std::nullopt;
|
|
}
|
|
if (key->accountId == context.accountId) return key;
|
|
if (ownOnly || !Can(context, "api_keys_manage")) {
|
|
JsonError(reply, eHTTPStatusCode::NOT_FOUND, "API key not found");
|
|
return std::nullopt;
|
|
}
|
|
if (!AuthorizeAccountAction(context, key->accountId, reply, eAccountAction::TOOLS)) return std::nullopt;
|
|
return key;
|
|
}
|
|
|
|
IApiKeys::ApiKey NewKey(const HTTPContext& context, const ApiKeyService::NewSecret& secret) {
|
|
IApiKeys::ApiKey key;
|
|
key.accountId = context.accountId;
|
|
key.keyHash = secret.hash;
|
|
key.keyPrefix = secret.prefix;
|
|
key.createdAt = key.issuedAt = Now();
|
|
key.createdBy = context.authenticatedUser;
|
|
return key;
|
|
}
|
|
}
|
|
|
|
namespace ApiKeyRoutes {
|
|
std::string CreateFullKey(const HTTPContext& context, const std::string& name, int64_t days) {
|
|
const auto secret = ApiKeyService::GenerateSecret();
|
|
auto key = NewKey(context, secret);
|
|
key.name = name;
|
|
key.permissions = std::string(ApiKeys::ALL_PERMISSIONS);
|
|
key.expiresAt = days > 0 ? key.createdAt + std::clamp<int64_t>(days, 1, MAX_DAYS) * 86400 : 0;
|
|
key.id = Database::Get()->InsertApiKey(key);
|
|
Audit(context, "create_api_key", "Made API key " + Describe(key), AuditTarget::Account(context.accountId));
|
|
return secret.token;
|
|
}
|
|
|
|
void RegisterRoutes() {
|
|
// Keys only ever narrow their owner, so the caller's own permissions are what may be picked
|
|
Route(eHTTPMethod::GET, "/api/api_keys/permissions", 0,
|
|
"The permissions an API key can be given, grouped like the Permissions page; 'allowed' marks the ones you have (only those can be picked)",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
nlohmann::json permissions = nlohmann::json::array();
|
|
for (const auto& permission : Permissions::All()) {
|
|
permissions.push_back({ {"key", permission.key}, {"category", permission.category}, {"title", permission.title},
|
|
{"description", permission.description}, {"allowed", Permissions::Allowed(context.gmLevel, permission.key, nullptr, context.grants.get())} });
|
|
}
|
|
JsonSuccess(reply, { {"permissions", permissions}, {"defaultRateLimit", ApiKeyService::DefaultRateLimit()},
|
|
{"maxRateLimit", ApiKeyService::MAX_RATE_LIMIT}, {"maxDailyQuota", ApiKeyService::MAX_DAILY_QUOTA},
|
|
{"apiAccess", Permissions::Allowed(context.gmLevel, "api_access", nullptr, context.grants.get())} });
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/accounts/:id/api_keys", 0,
|
|
"An account's API keys, newest first (never the keys themselves). Your own, or anyone's you may manage with api_keys_manage",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto accountId = PathId<uint32_t>(context.path, 2);
|
|
if (!accountId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid account ID");
|
|
const bool own = *accountId == context.accountId;
|
|
if (!own) {
|
|
if (!Can(context, "api_keys_manage") || !Can(context, "accounts_view")) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Insufficient permissions");
|
|
if (!AuthorizeAccountAction(context, *accountId, reply, eAccountAction::TOOLS)) return;
|
|
}
|
|
const auto account = Database::Get()->GetAccountById(*accountId);
|
|
if (account.contains("error")) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Account not found");
|
|
const auto ownerLevel = static_cast<uint8_t>(account.value("gm_level", 0));
|
|
const auto validAfter = Database::Get()->GetSessionsValidAfter(*accountId);
|
|
nlohmann::json keys = nlohmann::json::array();
|
|
const auto ownerGrants = PermissionGrants::Load(*accountId);
|
|
for (const auto& key : Database::Get()->GetApiKeys(*accountId)) keys.push_back(KeyJson(key, ownerLevel, ownerGrants.get(), validAfter));
|
|
JsonSuccess(reply, { {"keys", keys}, {"own", own} });
|
|
});
|
|
|
|
Route(eHTTPMethod::POST, "/api/api_keys", 0,
|
|
"Make an API key (signed in with the browser only). Body: {name, note, permissions: [names] or \"*\" (all of yours), readOnly, "
|
|
"allowedIps, allowedPaths (comma-separated), rateLimit (a minute, 0: default), dailyQuota (0: none), expiresInDays (0: never)}. "
|
|
"Returns {key}, shown only this once",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!FromSession(context)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Make API keys from your account page while signed in");
|
|
if (!Can(context, "api_access")) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "API access isn't allowed for your account");
|
|
const auto body = ParseBody(context);
|
|
if (!body || !body->is_object()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
|
|
|
const auto secret = ApiKeyService::GenerateSecret();
|
|
auto key = NewKey(context, secret);
|
|
key.name = Trim(body->value("name", ""));
|
|
if (key.name.empty() || key.name.size() > MAX_NAME) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Give the key a name of up to 32 characters");
|
|
key.note = Trim(body->value("note", ""));
|
|
if (key.note.size() > MAX_NOTE) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The note is too long");
|
|
|
|
const auto& requested = (*body)["permissions"];
|
|
if (requested.is_string() && requested.get<std::string>() == ApiKeys::ALL_PERMISSIONS) {
|
|
key.permissions = std::string(ApiKeys::ALL_PERMISSIONS);
|
|
} else if (requested.is_array()) {
|
|
std::set<std::string> permissions;
|
|
for (const auto& permission : requested) if (permission.is_string()) permissions.insert(permission.get<std::string>());
|
|
if (permissions.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Pick at least one permission");
|
|
// Staff can't hand a key more than they have
|
|
const auto refused = Permissions::NotGrantable(context.gmLevel, permissions, context.grants.get());
|
|
if (!refused.empty()) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You can't give a key permissions you don't have: " + *refused.begin());
|
|
key.permissions = ApiKeys::JoinPermissions(false, permissions);
|
|
} else {
|
|
return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "permissions must be a list of permission names or \"*\"");
|
|
}
|
|
|
|
key.readOnly = body->value("readOnly", false);
|
|
const auto ips = CleanList(body->value("allowedIps", ""), false);
|
|
if (!ips) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Allowed addresses must be IP addresses or prefixes like 10.0.0., separated by commas");
|
|
const auto paths = CleanList(body->value("allowedPaths", ""), true);
|
|
if (!paths) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Allowed paths must start with / and be separated by commas");
|
|
key.allowedIps = *ips;
|
|
key.allowedPaths = *paths;
|
|
const auto rate = body->value("rateLimit", int64_t{ 0 });
|
|
const auto quota = body->value("dailyQuota", int64_t{ 0 });
|
|
const auto days = body->value("expiresInDays", int64_t{ 0 });
|
|
if (rate < 0 || rate > ApiKeyService::MAX_RATE_LIMIT) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The rate limit must be 0 to " + std::to_string(ApiKeyService::MAX_RATE_LIMIT) + " a minute");
|
|
if (quota < 0 || quota > ApiKeyService::MAX_DAILY_QUOTA) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The daily quota is out of range");
|
|
if (days < 0 || days > MAX_DAYS) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Expiry must be 0 (never) to 3650 days");
|
|
key.rateLimit = static_cast<uint32_t>(rate);
|
|
key.dailyQuota = static_cast<uint32_t>(quota);
|
|
key.expiresAt = days > 0 ? key.createdAt + days * 86400 : 0;
|
|
|
|
if (ActiveKeyCount(context.accountId, Database::Get()->GetSessionsValidAfter(context.accountId)) >= MAX_ACTIVE_KEYS) {
|
|
return JsonError(reply, eHTTPStatusCode::CONFLICT, "You have too many API keys; revoke some first");
|
|
}
|
|
key.id = Database::Get()->InsertApiKey(key);
|
|
Audit(context, "create_api_key", "Made API key " + Describe(key), AuditTarget::Account(context.accountId));
|
|
JsonSuccess(reply, { {"id", key.id}, {"key", secret.token}, {"message", "API key made - copy it now, it won't be shown again"} });
|
|
});
|
|
|
|
Route(eHTTPMethod::POST, "/api/api_keys/:id/revoke", 0,
|
|
"Revoke an API key: yours, or another account's with api_keys_manage (the rank rules apply)",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!FromSession(context)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Revoke API keys from the dashboard while signed in");
|
|
const auto key = KeyForAction(context, reply, false);
|
|
if (!key) return;
|
|
if (key->revokedAt != 0) return JsonError(reply, eHTTPStatusCode::CONFLICT, "This key is already revoked");
|
|
Database::Get()->RevokeApiKey(key->id, context.authenticatedUser, Now());
|
|
ApiKeyService::Forget(key->id);
|
|
Audit(context, "revoke_api_key", "Revoked API key '" + key->name + "' (" + key->keyPrefix + "...)", AuditTarget::Account(key->accountId));
|
|
JsonSuccess(reply, { {"message", "API key revoked"} });
|
|
});
|
|
|
|
Route(eHTTPMethod::POST, "/api/api_keys/:id/rotate", 0,
|
|
"Give one of your API keys a new secret, keeping its name, permissions and limits; the old secret stops working. Returns {key}, shown once",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!FromSession(context)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Rotate API keys from your account page while signed in");
|
|
if (!Can(context, "api_access")) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "API access isn't allowed for your account");
|
|
const auto key = KeyForAction(context, reply, true);
|
|
if (!key) return;
|
|
if (key->revokedAt != 0) return JsonError(reply, eHTTPStatusCode::CONFLICT, "A revoked key can't be rotated");
|
|
if (key->expiresAt != 0 && key->expiresAt <= Now()) return JsonError(reply, eHTTPStatusCode::CONFLICT, "An expired key can't be rotated; make a new one");
|
|
// A scope the owner has since lost stays in the key but keeps not working; they can't regain it by rotating
|
|
const auto secret = ApiKeyService::GenerateSecret();
|
|
Database::Get()->RotateApiKey(key->id, secret.hash, secret.prefix, Now());
|
|
ApiKeyService::Forget(key->id);
|
|
Audit(context, "rotate_api_key", "Rotated API key '" + key->name + "' (" + key->keyPrefix + "... is now " + secret.prefix + "...)",
|
|
AuditTarget::Account(key->accountId));
|
|
JsonSuccess(reply, { {"key", secret.token}, {"message", "New secret made - copy it now, it won't be shown again"} });
|
|
});
|
|
|
|
// Refusals of keys (their scope, read-only, addresses, paths, limits) go to the audit log, a minute apart at most
|
|
ApiKeyService::SetDeniedHook([](const HTTPContext& context, const std::string& reason) {
|
|
Audit(context, "api_key_denied", reason);
|
|
});
|
|
RequireAuthMiddleware::SetApiKeyDeniedHook([](const HTTPContext& context, const std::string& reason) { ApiKeyService::NoteDenied(context, reason); });
|
|
ApiKeyService::SetClientAddress([](const HTTPContext& context) { return ClientAddress(context); });
|
|
}
|
|
}
|