mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(database): dashboard_api_keys table
Hashed API keys with scope, restrictions, limits, expiry and batched usage counters, for MySQL and SQLite, with test stubs and parity tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,7 @@
|
|||||||
#include "IPropertyReputation.h"
|
#include "IPropertyReputation.h"
|
||||||
#include "IBbbAutosave.h"
|
#include "IBbbAutosave.h"
|
||||||
#include "IServerTraffic.h"
|
#include "IServerTraffic.h"
|
||||||
|
#include "IApiKeys.h"
|
||||||
|
|
||||||
#ifdef _DEBUG
|
#ifdef _DEBUG
|
||||||
# define DLU_SQL_TRY_CATCH_RETHROW(x) do { try { x; } catch (std::exception& ex) { LOG("SQL Error: %s", ex.what()); throw; } } while(0)
|
# define DLU_SQL_TRY_CATCH_RETHROW(x) do { try { x; } catch (std::exception& ex) { LOG("SQL Error: %s", ex.what()); throw; } } while(0)
|
||||||
@@ -65,7 +66,7 @@ class GameDatabase :
|
|||||||
public IPropertyContents, public IProperty, public IPetNames, public ICharXml,
|
public IPropertyContents, public IProperty, public IPetNames, public ICharXml,
|
||||||
public IMigrationHistory, public IUgc, public IFriends, public ICharInfo,
|
public IMigrationHistory, public IUgc, public IFriends, public ICharInfo,
|
||||||
public IAccounts, public IActivityLog, public IAccountsRewardCodes, public IIgnoreList,
|
public IAccounts, public IActivityLog, public IAccountsRewardCodes, public IIgnoreList,
|
||||||
public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures, public IContraband, public IPropertyRent, public IPropertyReputation, public IBbbAutosave, public IServerTraffic {
|
public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures, public IContraband, public IPropertyRent, public IPropertyReputation, public IBbbAutosave, public IServerTraffic, public IApiKeys {
|
||||||
public:
|
public:
|
||||||
virtual ~GameDatabase() = default;
|
virtual ~GameDatabase() = default;
|
||||||
// TODO: These should be made private.
|
// TODO: These should be made private.
|
||||||
|
|||||||
64
dDatabase/GameDatabase/ITables/IApiKeys.h
Normal file
64
dDatabase/GameDatabase/ITables/IApiKeys.h
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
#ifndef __IAPIKEYS__H__
|
||||||
|
#define __IAPIKEYS__H__
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard API keys (ApiKeyScope.h). Only a hash of each key is stored. The usage columns are written in batches
|
||||||
|
* (RecordApiKeyUsage) by the dashboard, not on every request.
|
||||||
|
*/
|
||||||
|
class IApiKeys {
|
||||||
|
public:
|
||||||
|
struct ApiKey {
|
||||||
|
uint64_t id{};
|
||||||
|
uint32_t accountId{};
|
||||||
|
std::string name;
|
||||||
|
std::string note;
|
||||||
|
std::string keyHash; // SHA-256 hex of the whole key
|
||||||
|
std::string keyPrefix; // the start of the key, to tell keys apart
|
||||||
|
std::string permissions; // "*" or comma-separated permission names
|
||||||
|
bool readOnly{};
|
||||||
|
std::string allowedIps; // comma-separated; empty: any
|
||||||
|
std::string allowedPaths; // comma-separated path prefixes; empty: any
|
||||||
|
uint32_t rateLimit{}; // requests a minute; 0: the server's default
|
||||||
|
uint32_t dailyQuota{}; // requests a UTC day; 0: none
|
||||||
|
int64_t createdAt{};
|
||||||
|
std::string createdBy;
|
||||||
|
int64_t issuedAt{}; // when the current secret was made
|
||||||
|
int64_t expiresAt{}; // 0: never
|
||||||
|
int64_t revokedAt{}; // 0: in use
|
||||||
|
std::string revokedBy;
|
||||||
|
int64_t lastUsedAt{};
|
||||||
|
std::string lastIp;
|
||||||
|
uint64_t requestCount{};
|
||||||
|
int32_t quotaDay{}; // the UTC day (days since 1970) dayCount counts
|
||||||
|
uint32_t dayCount{};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Usage since the last flush, for one key
|
||||||
|
struct ApiKeyUsage {
|
||||||
|
uint64_t id{};
|
||||||
|
uint64_t requests{}; // added to request_count
|
||||||
|
int64_t lastUsedAt{};
|
||||||
|
std::string lastIp;
|
||||||
|
int32_t quotaDay{};
|
||||||
|
uint32_t dayCount{}; // replaces day_count (the count for quotaDay so far)
|
||||||
|
};
|
||||||
|
|
||||||
|
virtual uint64_t InsertApiKey(const ApiKey& key) = 0;
|
||||||
|
virtual std::optional<ApiKey> GetApiKey(uint64_t id) = 0;
|
||||||
|
virtual std::optional<ApiKey> GetApiKeyByHash(const std::string& keyHash) = 0;
|
||||||
|
// Newest first, revoked ones included
|
||||||
|
virtual std::vector<ApiKey> GetApiKeys(uint32_t accountId) = 0;
|
||||||
|
virtual void RevokeApiKey(uint64_t id, const std::string& revokedBy, int64_t time) = 0;
|
||||||
|
// Every key of the account still in use; returns how many were revoked
|
||||||
|
virtual uint32_t RevokeAccountApiKeys(uint32_t accountId, const std::string& revokedBy, int64_t time) = 0;
|
||||||
|
// A new secret for the key (the old one stops working)
|
||||||
|
virtual void RotateApiKey(uint64_t id, const std::string& keyHash, const std::string& keyPrefix, int64_t issuedAt) = 0;
|
||||||
|
virtual void RecordApiKeyUsage(const std::vector<ApiKeyUsage>& usage) = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif //!__IAPIKEYS__H__
|
||||||
@@ -219,6 +219,16 @@ public:
|
|||||||
void InsertTrafficMinutes(const std::vector<TrafficMinute>& minutes) override;
|
void InsertTrafficMinutes(const std::vector<TrafficMinute>& minutes) override;
|
||||||
std::vector<TrafficMinute> GetTrafficMinutes(int64_t from, int64_t to, int64_t bucketSeconds) override;
|
std::vector<TrafficMinute> GetTrafficMinutes(int64_t from, int64_t to, int64_t bucketSeconds) override;
|
||||||
uint32_t PruneTrafficMinutes(int64_t beforeTime) override;
|
uint32_t PruneTrafficMinutes(int64_t beforeTime) override;
|
||||||
|
|
||||||
|
// IApiKeys
|
||||||
|
uint64_t InsertApiKey(const ApiKey& key) override;
|
||||||
|
std::optional<ApiKey> GetApiKey(uint64_t id) override;
|
||||||
|
std::optional<ApiKey> GetApiKeyByHash(const std::string& keyHash) override;
|
||||||
|
std::vector<ApiKey> GetApiKeys(uint32_t accountId) override;
|
||||||
|
void RevokeApiKey(uint64_t id, const std::string& revokedBy, int64_t time) override;
|
||||||
|
uint32_t RevokeAccountApiKeys(uint32_t accountId, const std::string& revokedBy, int64_t time) override;
|
||||||
|
void RotateApiKey(uint64_t id, const std::string& keyHash, const std::string& keyPrefix, int64_t issuedAt) override;
|
||||||
|
void RecordApiKeyUsage(const std::vector<ApiKeyUsage>& usage) override;
|
||||||
// IBbbAutosave
|
// IBbbAutosave
|
||||||
std::optional<IBbbAutosave::Info> GetBbbAutosave(const LWOOBJID characterId) override;
|
std::optional<IBbbAutosave::Info> GetBbbAutosave(const LWOOBJID characterId) override;
|
||||||
void SetBbbAutosave(const LWOOBJID characterId, const IBbbAutosave::Info& info) override;
|
void SetBbbAutosave(const LWOOBJID characterId, const IBbbAutosave::Info& info) override;
|
||||||
|
|||||||
81
dDatabase/GameDatabase/MySQL/Tables/ApiKeys.cpp
Normal file
81
dDatabase/GameDatabase/MySQL/Tables/ApiKeys.cpp
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
#include "MySQLDatabase.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
template<typename Result> IApiKeys::ApiKey ReadApiKey(Result& result) {
|
||||||
|
IApiKeys::ApiKey key;
|
||||||
|
key.id = result->getUInt64("id");
|
||||||
|
key.accountId = result->getUInt("account_id");
|
||||||
|
key.name = result->getString("name").c_str();
|
||||||
|
key.note = result->getString("note").c_str();
|
||||||
|
key.keyHash = result->getString("key_hash").c_str();
|
||||||
|
key.keyPrefix = result->getString("key_prefix").c_str();
|
||||||
|
key.permissions = result->getString("permissions").c_str();
|
||||||
|
key.readOnly = result->getInt("read_only") != 0;
|
||||||
|
key.allowedIps = result->getString("allowed_ips").c_str();
|
||||||
|
key.allowedPaths = result->getString("allowed_paths").c_str();
|
||||||
|
key.rateLimit = result->getUInt("rate_limit");
|
||||||
|
key.dailyQuota = result->getUInt("daily_quota");
|
||||||
|
key.createdAt = result->getInt64("created_at");
|
||||||
|
key.createdBy = result->getString("created_by").c_str();
|
||||||
|
key.issuedAt = result->getInt64("issued_at");
|
||||||
|
key.expiresAt = result->getInt64("expires_at");
|
||||||
|
key.revokedAt = result->getInt64("revoked_at");
|
||||||
|
key.revokedBy = result->getString("revoked_by").c_str();
|
||||||
|
key.lastUsedAt = result->getInt64("last_used_at");
|
||||||
|
key.lastIp = result->getString("last_ip").c_str();
|
||||||
|
key.requestCount = result->getUInt64("request_count");
|
||||||
|
key.quotaDay = result->getInt("quota_day");
|
||||||
|
key.dayCount = result->getUInt("day_count");
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t MySQLDatabase::InsertApiKey(const ApiKey& key) {
|
||||||
|
ExecuteInsert("INSERT INTO dashboard_api_keys (account_id, name, note, key_hash, key_prefix, permissions, read_only, allowed_ips, allowed_paths, "
|
||||||
|
"rate_limit, daily_quota, created_at, created_by, issued_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||||
|
key.accountId, key.name, key.note, key.keyHash, key.keyPrefix, key.permissions, key.readOnly, key.allowedIps, key.allowedPaths,
|
||||||
|
key.rateLimit, key.dailyQuota, key.createdAt, key.createdBy, key.issuedAt, key.expiresAt);
|
||||||
|
auto result = ExecuteSelect("SELECT LAST_INSERT_ID() AS id;");
|
||||||
|
return result->next() ? result->getUInt64("id") : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<IApiKeys::ApiKey> MySQLDatabase::GetApiKey(uint64_t id) {
|
||||||
|
auto result = ExecuteSelect("SELECT * FROM dashboard_api_keys WHERE id = ?;", id);
|
||||||
|
if (!result->next()) return std::nullopt;
|
||||||
|
return ReadApiKey(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<IApiKeys::ApiKey> MySQLDatabase::GetApiKeyByHash(const std::string& keyHash) {
|
||||||
|
auto result = ExecuteSelect("SELECT * FROM dashboard_api_keys WHERE key_hash = ?;", keyHash);
|
||||||
|
if (!result->next()) return std::nullopt;
|
||||||
|
return ReadApiKey(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<IApiKeys::ApiKey> MySQLDatabase::GetApiKeys(uint32_t accountId) {
|
||||||
|
std::vector<ApiKey> keys;
|
||||||
|
auto result = ExecuteSelect("SELECT * FROM dashboard_api_keys WHERE account_id = ? ORDER BY id DESC;", accountId);
|
||||||
|
while (result->next()) keys.push_back(ReadApiKey(result));
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MySQLDatabase::RevokeApiKey(uint64_t id, const std::string& revokedBy, int64_t time) {
|
||||||
|
ExecuteUpdate("UPDATE dashboard_api_keys SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at = 0;", time, revokedBy, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t MySQLDatabase::RevokeAccountApiKeys(uint32_t accountId, const std::string& revokedBy, int64_t time) {
|
||||||
|
return ExecuteUpdate("UPDATE dashboard_api_keys SET revoked_at = ?, revoked_by = ? WHERE account_id = ? AND revoked_at = 0;", time, revokedBy, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MySQLDatabase::RotateApiKey(uint64_t id, const std::string& keyHash, const std::string& keyPrefix, int64_t issuedAt) {
|
||||||
|
ExecuteUpdate("UPDATE dashboard_api_keys SET key_hash = ?, key_prefix = ?, issued_at = ? WHERE id = ? AND revoked_at = 0;", keyHash, keyPrefix, issuedAt, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MySQLDatabase::RecordApiKeyUsage(const std::vector<ApiKeyUsage>& usage) {
|
||||||
|
if (usage.empty()) return;
|
||||||
|
DatabaseTransaction transaction(*this);
|
||||||
|
for (const auto& entry : usage) {
|
||||||
|
ExecuteUpdate("UPDATE dashboard_api_keys SET request_count = request_count + ?, last_used_at = GREATEST(last_used_at, ?), last_ip = ?, quota_day = ?, day_count = ? WHERE id = ?;",
|
||||||
|
entry.requests, entry.lastUsedAt, entry.lastIp, entry.quotaDay, entry.dayCount, entry.id);
|
||||||
|
}
|
||||||
|
transaction.Commit();
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ set(DDATABASES_DATABASES_MYSQL_TABLES_SOURCES
|
|||||||
"CharacterSnapshots.cpp"
|
"CharacterSnapshots.cpp"
|
||||||
"AccountNotes.cpp"
|
"AccountNotes.cpp"
|
||||||
"AccountStrikes.cpp"
|
"AccountStrikes.cpp"
|
||||||
|
"ApiKeys.cpp"
|
||||||
"Moderation.cpp"
|
"Moderation.cpp"
|
||||||
"ServerHealth.cpp"
|
"ServerHealth.cpp"
|
||||||
"PlayerPositions.cpp"
|
"PlayerPositions.cpp"
|
||||||
|
|||||||
@@ -203,6 +203,16 @@ public:
|
|||||||
void InsertTrafficMinutes(const std::vector<TrafficMinute>& minutes) override;
|
void InsertTrafficMinutes(const std::vector<TrafficMinute>& minutes) override;
|
||||||
std::vector<TrafficMinute> GetTrafficMinutes(int64_t from, int64_t to, int64_t bucketSeconds) override;
|
std::vector<TrafficMinute> GetTrafficMinutes(int64_t from, int64_t to, int64_t bucketSeconds) override;
|
||||||
uint32_t PruneTrafficMinutes(int64_t beforeTime) override;
|
uint32_t PruneTrafficMinutes(int64_t beforeTime) override;
|
||||||
|
|
||||||
|
// IApiKeys
|
||||||
|
uint64_t InsertApiKey(const ApiKey& key) override;
|
||||||
|
std::optional<ApiKey> GetApiKey(uint64_t id) override;
|
||||||
|
std::optional<ApiKey> GetApiKeyByHash(const std::string& keyHash) override;
|
||||||
|
std::vector<ApiKey> GetApiKeys(uint32_t accountId) override;
|
||||||
|
void RevokeApiKey(uint64_t id, const std::string& revokedBy, int64_t time) override;
|
||||||
|
uint32_t RevokeAccountApiKeys(uint32_t accountId, const std::string& revokedBy, int64_t time) override;
|
||||||
|
void RotateApiKey(uint64_t id, const std::string& keyHash, const std::string& keyPrefix, int64_t issuedAt) override;
|
||||||
|
void RecordApiKeyUsage(const std::vector<ApiKeyUsage>& usage) override;
|
||||||
// IBbbAutosave
|
// IBbbAutosave
|
||||||
std::optional<IBbbAutosave::Info> GetBbbAutosave(const LWOOBJID characterId) override;
|
std::optional<IBbbAutosave::Info> GetBbbAutosave(const LWOOBJID characterId) override;
|
||||||
void SetBbbAutosave(const LWOOBJID characterId, const IBbbAutosave::Info& info) override;
|
void SetBbbAutosave(const LWOOBJID characterId, const IBbbAutosave::Info& info) override;
|
||||||
|
|||||||
82
dDatabase/GameDatabase/SQLite/Tables/ApiKeys.cpp
Normal file
82
dDatabase/GameDatabase/SQLite/Tables/ApiKeys.cpp
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#include "SQLiteDatabase.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
IApiKeys::ApiKey ReadApiKey(CppSQLite3Query& result) {
|
||||||
|
IApiKeys::ApiKey key;
|
||||||
|
key.id = static_cast<uint64_t>(result.getInt64Field("id"));
|
||||||
|
key.accountId = static_cast<uint32_t>(result.getIntField("account_id"));
|
||||||
|
key.name = result.getStringField("name");
|
||||||
|
key.note = result.getStringField("note");
|
||||||
|
key.keyHash = result.getStringField("key_hash");
|
||||||
|
key.keyPrefix = result.getStringField("key_prefix");
|
||||||
|
key.permissions = result.getStringField("permissions");
|
||||||
|
key.readOnly = result.getIntField("read_only") != 0;
|
||||||
|
key.allowedIps = result.getStringField("allowed_ips");
|
||||||
|
key.allowedPaths = result.getStringField("allowed_paths");
|
||||||
|
key.rateLimit = static_cast<uint32_t>(result.getInt64Field("rate_limit"));
|
||||||
|
key.dailyQuota = static_cast<uint32_t>(result.getInt64Field("daily_quota"));
|
||||||
|
key.createdAt = result.getInt64Field("created_at");
|
||||||
|
key.createdBy = result.getStringField("created_by");
|
||||||
|
key.issuedAt = result.getInt64Field("issued_at");
|
||||||
|
key.expiresAt = result.getInt64Field("expires_at");
|
||||||
|
key.revokedAt = result.getInt64Field("revoked_at");
|
||||||
|
key.revokedBy = result.getStringField("revoked_by");
|
||||||
|
key.lastUsedAt = result.getInt64Field("last_used_at");
|
||||||
|
key.lastIp = result.getStringField("last_ip");
|
||||||
|
key.requestCount = static_cast<uint64_t>(result.getInt64Field("request_count"));
|
||||||
|
key.quotaDay = result.getIntField("quota_day");
|
||||||
|
key.dayCount = static_cast<uint32_t>(result.getInt64Field("day_count"));
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t SQLiteDatabase::InsertApiKey(const ApiKey& key) {
|
||||||
|
ExecuteInsert("INSERT INTO dashboard_api_keys (account_id, name, note, key_hash, key_prefix, permissions, read_only, allowed_ips, allowed_paths, "
|
||||||
|
"rate_limit, daily_quota, created_at, created_by, issued_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||||
|
key.accountId, key.name, key.note, key.keyHash, key.keyPrefix, key.permissions, key.readOnly, key.allowedIps, key.allowedPaths,
|
||||||
|
key.rateLimit, key.dailyQuota, key.createdAt, key.createdBy, key.issuedAt, key.expiresAt);
|
||||||
|
auto [_, result] = ExecuteSelect("SELECT last_insert_rowid() AS id;");
|
||||||
|
return result.eof() ? 0 : static_cast<uint64_t>(result.getInt64Field("id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<IApiKeys::ApiKey> SQLiteDatabase::GetApiKey(uint64_t id) {
|
||||||
|
auto [_, result] = ExecuteSelect("SELECT * FROM dashboard_api_keys WHERE id = ?;", static_cast<int64_t>(id));
|
||||||
|
if (result.eof()) return std::nullopt;
|
||||||
|
return ReadApiKey(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<IApiKeys::ApiKey> SQLiteDatabase::GetApiKeyByHash(const std::string& keyHash) {
|
||||||
|
auto [_, result] = ExecuteSelect("SELECT * FROM dashboard_api_keys WHERE key_hash = ?;", keyHash);
|
||||||
|
if (result.eof()) return std::nullopt;
|
||||||
|
return ReadApiKey(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<IApiKeys::ApiKey> SQLiteDatabase::GetApiKeys(uint32_t accountId) {
|
||||||
|
std::vector<ApiKey> keys;
|
||||||
|
auto [_, result] = ExecuteSelect("SELECT * FROM dashboard_api_keys WHERE account_id = ? ORDER BY id DESC;", accountId);
|
||||||
|
for (; !result.eof(); result.nextRow()) keys.push_back(ReadApiKey(result));
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SQLiteDatabase::RevokeApiKey(uint64_t id, const std::string& revokedBy, int64_t time) {
|
||||||
|
ExecuteUpdate("UPDATE dashboard_api_keys SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at = 0;", time, revokedBy, static_cast<int64_t>(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t SQLiteDatabase::RevokeAccountApiKeys(uint32_t accountId, const std::string& revokedBy, int64_t time) {
|
||||||
|
return ExecuteUpdate("UPDATE dashboard_api_keys SET revoked_at = ?, revoked_by = ? WHERE account_id = ? AND revoked_at = 0;", time, revokedBy, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SQLiteDatabase::RotateApiKey(uint64_t id, const std::string& keyHash, const std::string& keyPrefix, int64_t issuedAt) {
|
||||||
|
ExecuteUpdate("UPDATE dashboard_api_keys SET key_hash = ?, key_prefix = ?, issued_at = ? WHERE id = ? AND revoked_at = 0;", keyHash, keyPrefix, issuedAt, static_cast<int64_t>(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SQLiteDatabase::RecordApiKeyUsage(const std::vector<ApiKeyUsage>& usage) {
|
||||||
|
if (usage.empty()) return;
|
||||||
|
// One transaction for the batch: one write to disk rather than one per key
|
||||||
|
DatabaseTransaction transaction(*this);
|
||||||
|
for (const auto& entry : usage) {
|
||||||
|
ExecuteUpdate("UPDATE dashboard_api_keys SET request_count = request_count + ?, last_used_at = MAX(last_used_at, ?), last_ip = ?, quota_day = ?, day_count = ? WHERE id = ?;",
|
||||||
|
static_cast<int64_t>(entry.requests), entry.lastUsedAt, entry.lastIp, entry.quotaDay, entry.dayCount, static_cast<int64_t>(entry.id));
|
||||||
|
}
|
||||||
|
transaction.Commit();
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ set(DDATABASES_DATABASES_SQLITE_TABLES_SOURCES
|
|||||||
"CharacterSnapshots.cpp"
|
"CharacterSnapshots.cpp"
|
||||||
"AccountNotes.cpp"
|
"AccountNotes.cpp"
|
||||||
"AccountStrikes.cpp"
|
"AccountStrikes.cpp"
|
||||||
|
"ApiKeys.cpp"
|
||||||
"Moderation.cpp"
|
"Moderation.cpp"
|
||||||
"ServerHealth.cpp"
|
"ServerHealth.cpp"
|
||||||
"PlayerPositions.cpp"
|
"PlayerPositions.cpp"
|
||||||
|
|||||||
@@ -211,6 +211,14 @@ class TestSQLDatabase : public GameDatabase {
|
|||||||
std::vector<AccountNote> GetAccountNotes(uint32_t accountId) override { return {}; }
|
std::vector<AccountNote> GetAccountNotes(uint32_t accountId) override { return {}; }
|
||||||
std::optional<AccountNote> GetAccountNote(uint64_t id) override { return {}; }
|
std::optional<AccountNote> GetAccountNote(uint64_t id) override { return {}; }
|
||||||
void DeleteAccountNote(uint64_t id) override {}
|
void DeleteAccountNote(uint64_t id) override {}
|
||||||
|
uint64_t InsertApiKey(const ApiKey& key) override { return 0; }
|
||||||
|
std::optional<ApiKey> GetApiKey(uint64_t id) override { return {}; }
|
||||||
|
std::optional<ApiKey> GetApiKeyByHash(const std::string& keyHash) override { return {}; }
|
||||||
|
std::vector<ApiKey> GetApiKeys(uint32_t accountId) override { return {}; }
|
||||||
|
void RevokeApiKey(uint64_t id, const std::string& revokedBy, int64_t time) override {}
|
||||||
|
uint32_t RevokeAccountApiKeys(uint32_t accountId, const std::string& revokedBy, int64_t time) override { return 0; }
|
||||||
|
void RotateApiKey(uint64_t id, const std::string& keyHash, const std::string& keyPrefix, int64_t issuedAt) override {}
|
||||||
|
void RecordApiKeyUsage(const std::vector<ApiKeyUsage>& usage) override {}
|
||||||
uint64_t InsertStrike(const Strike& strike) override { return 0; }
|
uint64_t InsertStrike(const Strike& strike) override { return 0; }
|
||||||
std::vector<Strike> GetStrikes(uint32_t accountId) override { return {}; }
|
std::vector<Strike> GetStrikes(uint32_t accountId) override { return {}; }
|
||||||
std::optional<Strike> GetStrike(uint64_t id) override { return {}; }
|
std::optional<Strike> GetStrike(uint64_t id) override { return {}; }
|
||||||
|
|||||||
34
migrations/dlu/mysql/83_dashboard_api_keys.sql
Normal file
34
migrations/dlu/mysql/83_dashboard_api_keys.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/* Dashboard API keys. Only the SHA-256 of the key is kept (key_hash); key_prefix is its start, shown in the list so
|
||||||
|
people can tell their keys apart. permissions is '*' (all of the owner's permissions, whatever they are at the
|
||||||
|
time) or a comma-separated list of permission names; a key never does more than its owner can right now.
|
||||||
|
allowed_ips / allowed_paths: comma-separated, empty for any. rate_limit: requests a minute (0: the server's
|
||||||
|
default); daily_quota: requests a UTC day (0: no quota). The usage columns are written in batches, not per request:
|
||||||
|
quota_day is the UTC day (days since 1970) day_count counts. issued_at is when the current secret was made
|
||||||
|
(rotating replaces the secret). */
|
||||||
|
CREATE TABLE IF NOT EXISTS dashboard_api_keys (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
account_id INT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
note VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
key_hash CHAR(64) NOT NULL,
|
||||||
|
key_prefix VARCHAR(16) NOT NULL,
|
||||||
|
permissions TEXT NOT NULL,
|
||||||
|
read_only TINYINT NOT NULL DEFAULT 0,
|
||||||
|
allowed_ips VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
allowed_paths VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
rate_limit INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
daily_quota INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
created_by VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
issued_at BIGINT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL DEFAULT 0,
|
||||||
|
revoked_at BIGINT NOT NULL DEFAULT 0,
|
||||||
|
revoked_by VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
last_used_at BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
request_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
quota_day INT NOT NULL DEFAULT 0,
|
||||||
|
day_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE INDEX dashboard_api_keys_hash (key_hash),
|
||||||
|
INDEX dashboard_api_keys_account (account_id)
|
||||||
|
);
|
||||||
28
migrations/dlu/sqlite/66_dashboard_api_keys.sql
Normal file
28
migrations/dlu/sqlite/66_dashboard_api_keys.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/* Dashboard API keys. See the MySQL migration. */
|
||||||
|
CREATE TABLE IF NOT EXISTS dashboard_api_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
permissions TEXT NOT NULL DEFAULT '',
|
||||||
|
read_only INTEGER NOT NULL DEFAULT 0,
|
||||||
|
allowed_ips TEXT NOT NULL DEFAULT '',
|
||||||
|
allowed_paths TEXT NOT NULL DEFAULT '',
|
||||||
|
rate_limit INTEGER NOT NULL DEFAULT 0,
|
||||||
|
daily_quota INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL DEFAULT '',
|
||||||
|
issued_at BIGINT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL DEFAULT 0,
|
||||||
|
revoked_at BIGINT NOT NULL DEFAULT 0,
|
||||||
|
revoked_by TEXT NOT NULL DEFAULT '',
|
||||||
|
last_used_at BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_ip TEXT NOT NULL DEFAULT '',
|
||||||
|
request_count BIGINT NOT NULL DEFAULT 0,
|
||||||
|
quota_day INTEGER NOT NULL DEFAULT 0,
|
||||||
|
day_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS dashboard_api_keys_hash ON dashboard_api_keys (key_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS dashboard_api_keys_account ON dashboard_api_keys (account_id);
|
||||||
@@ -63,6 +63,7 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IAccountEmails::EmailInfo, email, confirmed);
|
|||||||
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IAccountEmails::AccountToken, accountId, data);
|
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IAccountEmails::AccountToken, accountId, data);
|
||||||
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IAccountNotes::AccountNote, id, accountId, kind, text, actor, createdAt);
|
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IAccountNotes::AccountNote, id, accountId, kind, text, actor, createdAt);
|
||||||
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IAccountStrikes::Strike, id, accountId, characterId, source, subject, reason, givenById, givenBy, createdAt, revokedAt, revokedBy, revokeReason);
|
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IAccountStrikes::Strike, id, accountId, characterId, source, subject, reason, givenById, givenBy, createdAt, revokedAt, revokedBy, revokeReason);
|
||||||
|
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IApiKeys::ApiKey, id, accountId, name, note, keyHash, keyPrefix, permissions, readOnly, allowedIps, allowedPaths, rateLimit, dailyQuota, createdAt, createdBy, issuedAt, expiresAt, revokedAt, revokedBy, lastUsedAt, lastIp, requestCount, quotaDay, dayCount);
|
||||||
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ICharacterSnapshots::CharacterSnapshot, id, characterId, takenAt, reason, actor, size, hash, compressed);
|
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ICharacterSnapshots::CharacterSnapshot, id, characterId, takenAt, reason, actor, size, hash, compressed);
|
||||||
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IChatLog::ChatMessage, id, time, channel, senderId, senderName, accountId, recipientId, recipientName, zoneId, instanceId, cloneId, message, blocked);
|
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IChatLog::ChatMessage, id, time, channel, senderId, senderName, accountId, recipientId, recipientName, zoneId, instanceId, cloneId, message, blocked);
|
||||||
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IDashboardAdmin::Webhook, id, name, url, format, events, secret, enabled, createdAt, lastSentAt, lastStatus, lastError);
|
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IDashboardAdmin::Webhook, id, name, url, format, events, secret, enabled, createdAt, lastSentAt, lastStatus, lastError);
|
||||||
@@ -1112,6 +1113,29 @@ TEST_F(ParitySeeded, AccountNotesAndStrikes) {
|
|||||||
Both("GetAppliedStrikeSteps", [](GameDatabase& db) { return json{ db.GetAppliedStrikeSteps(2, 0), db.GetAppliedStrikeSteps(2, 1700000050), db.GetAppliedStrikeSteps(1, 0) }; });
|
Both("GetAppliedStrikeSteps", [](GameDatabase& db) { return json{ db.GetAppliedStrikeSteps(2, 0), db.GetAppliedStrikeSteps(2, 1700000050), db.GetAppliedStrikeSteps(1, 0) }; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_F(ParitySeeded, ApiKeys) {
|
||||||
|
Both("InsertApiKey", [](GameDatabase& db) {
|
||||||
|
json ids = json::array();
|
||||||
|
ids.push_back(db.InsertApiKey({ 0, 2, "bot", "chat bridge", std::string(64, 'a'), "dlk_aaaa", "chat_view,players_view", true, "10.0.0.", "/api/chat", 60, 1000,
|
||||||
|
1700000000, "bob", 1700000000, 1800000000 }));
|
||||||
|
ids.push_back(db.InsertApiKey({ 0, 2, "all", "", std::string(64, 'b'), "dlk_bbbb", "*", false, "", "", 0, 0, 1700000100, "bob", 1700000100, 0 }));
|
||||||
|
ids.push_back(db.InsertApiKey({ 0, 1, "alice", "", std::string(64, 'c'), "dlk_cccc", "own_characters", false, "", "", 0, 0, 1700000200, "alice", 1700000200, 0 }));
|
||||||
|
return ids;
|
||||||
|
});
|
||||||
|
Both("GetApiKeys", [](GameDatabase& db) { return db.GetApiKeys(2); });
|
||||||
|
Both("GetApiKey", [](GameDatabase& db) { return json{ db.GetApiKey(1), db.GetApiKey(99) }; });
|
||||||
|
Both("GetApiKeyByHash", [](GameDatabase& db) { return json{ db.GetApiKeyByHash(std::string(64, 'b')), db.GetApiKeyByHash("nope") }; });
|
||||||
|
Both("RecordApiKeyUsage", [](GameDatabase& db) {
|
||||||
|
db.RecordApiKeyUsage({ { 1, 5, 1700000500, "10.0.0.2", 19675, 5 }, { 2, 1, 1700000600, "::1", 19675, 1 } });
|
||||||
|
db.RecordApiKeyUsage({ { 1, 3, 1700000400, "10.0.0.3", 19676, 3 } });
|
||||||
|
return json{ db.GetApiKey(1), db.GetApiKey(2) };
|
||||||
|
});
|
||||||
|
Both("RotateApiKey", [](GameDatabase& db) { db.RotateApiKey(1, std::string(64, 'd'), "dlk_dddd", 1700000700); return json{ db.GetApiKey(1), db.GetApiKeyByHash(std::string(64, 'a')) }; });
|
||||||
|
Both("RevokeApiKey", [](GameDatabase& db) { db.RevokeApiKey(1, "gm", 1700000800); db.RevokeApiKey(1, "late", 1700000900); return db.GetApiKey(1); });
|
||||||
|
Both("RotateApiKey revoked", [](GameDatabase& db) { db.RotateApiKey(1, std::string(64, 'e'), "dlk_eeee", 1700001000); return db.GetApiKey(1); });
|
||||||
|
Both("RevokeAccountApiKeys", [](GameDatabase& db) { return json{ db.RevokeAccountApiKeys(2, "bob", 1700001100), db.GetApiKeys(2), db.GetApiKeys(1) }; });
|
||||||
|
}
|
||||||
|
|
||||||
TEST_F(ParitySeeded, Moderation) {
|
TEST_F(ParitySeeded, Moderation) {
|
||||||
Both("InsertPlayerReport", [](GameDatabase& db) {
|
Both("InsertPlayerReport", [](GameDatabase& db) {
|
||||||
IModeration::PlayerReport report;
|
IModeration::PlayerReport report;
|
||||||
|
|||||||
Reference in New Issue
Block a user