diff --git a/dDatabase/GameDatabase/GameDatabase.h b/dDatabase/GameDatabase/GameDatabase.h index 940e0339d..a5f623ba2 100644 --- a/dDatabase/GameDatabase/GameDatabase.h +++ b/dDatabase/GameDatabase/GameDatabase.h @@ -52,6 +52,7 @@ #include "IPropertyReputation.h" #include "IBbbAutosave.h" #include "IServerTraffic.h" +#include "IApiKeys.h" #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) @@ -65,7 +66,7 @@ class GameDatabase : public IPropertyContents, public IProperty, public IPetNames, public ICharXml, public IMigrationHistory, public IUgc, public IFriends, public ICharInfo, 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: virtual ~GameDatabase() = default; // TODO: These should be made private. diff --git a/dDatabase/GameDatabase/ITables/IApiKeys.h b/dDatabase/GameDatabase/ITables/IApiKeys.h new file mode 100644 index 000000000..2db98ab8e --- /dev/null +++ b/dDatabase/GameDatabase/ITables/IApiKeys.h @@ -0,0 +1,64 @@ +#ifndef __IAPIKEYS__H__ +#define __IAPIKEYS__H__ + +#include +#include +#include +#include + +/** + * 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 GetApiKey(uint64_t id) = 0; + virtual std::optional GetApiKeyByHash(const std::string& keyHash) = 0; + // Newest first, revoked ones included + virtual std::vector 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& usage) = 0; +}; + +#endif //!__IAPIKEYS__H__ diff --git a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h index ac21abb1b..a8ec4544d 100644 --- a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h +++ b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h @@ -219,6 +219,16 @@ public: void InsertTrafficMinutes(const std::vector& minutes) override; std::vector GetTrafficMinutes(int64_t from, int64_t to, int64_t bucketSeconds) override; uint32_t PruneTrafficMinutes(int64_t beforeTime) override; + + // IApiKeys + uint64_t InsertApiKey(const ApiKey& key) override; + std::optional GetApiKey(uint64_t id) override; + std::optional GetApiKeyByHash(const std::string& keyHash) override; + std::vector 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& usage) override; // IBbbAutosave std::optional GetBbbAutosave(const LWOOBJID characterId) override; void SetBbbAutosave(const LWOOBJID characterId, const IBbbAutosave::Info& info) override; diff --git a/dDatabase/GameDatabase/MySQL/Tables/ApiKeys.cpp b/dDatabase/GameDatabase/MySQL/Tables/ApiKeys.cpp new file mode 100644 index 000000000..71d0dc8e8 --- /dev/null +++ b/dDatabase/GameDatabase/MySQL/Tables/ApiKeys.cpp @@ -0,0 +1,81 @@ +#include "MySQLDatabase.h" + +namespace { + template 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 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 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 MySQLDatabase::GetApiKeys(uint32_t accountId) { + std::vector 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& 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(); +} diff --git a/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt b/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt index fd6a524c9..1d950500c 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt +++ b/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt @@ -11,6 +11,7 @@ set(DDATABASES_DATABASES_MYSQL_TABLES_SOURCES "CharacterSnapshots.cpp" "AccountNotes.cpp" "AccountStrikes.cpp" + "ApiKeys.cpp" "Moderation.cpp" "ServerHealth.cpp" "PlayerPositions.cpp" diff --git a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h index 22f4ca685..356d26d30 100644 --- a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h +++ b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h @@ -203,6 +203,16 @@ public: void InsertTrafficMinutes(const std::vector& minutes) override; std::vector GetTrafficMinutes(int64_t from, int64_t to, int64_t bucketSeconds) override; uint32_t PruneTrafficMinutes(int64_t beforeTime) override; + + // IApiKeys + uint64_t InsertApiKey(const ApiKey& key) override; + std::optional GetApiKey(uint64_t id) override; + std::optional GetApiKeyByHash(const std::string& keyHash) override; + std::vector 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& usage) override; // IBbbAutosave std::optional GetBbbAutosave(const LWOOBJID characterId) override; void SetBbbAutosave(const LWOOBJID characterId, const IBbbAutosave::Info& info) override; diff --git a/dDatabase/GameDatabase/SQLite/Tables/ApiKeys.cpp b/dDatabase/GameDatabase/SQLite/Tables/ApiKeys.cpp new file mode 100644 index 000000000..862c9f62b --- /dev/null +++ b/dDatabase/GameDatabase/SQLite/Tables/ApiKeys.cpp @@ -0,0 +1,82 @@ +#include "SQLiteDatabase.h" + +namespace { + IApiKeys::ApiKey ReadApiKey(CppSQLite3Query& result) { + IApiKeys::ApiKey key; + key.id = static_cast(result.getInt64Field("id")); + key.accountId = static_cast(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(result.getInt64Field("rate_limit")); + key.dailyQuota = static_cast(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(result.getInt64Field("request_count")); + key.quotaDay = result.getIntField("quota_day"); + key.dayCount = static_cast(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(result.getInt64Field("id")); +} + +std::optional SQLiteDatabase::GetApiKey(uint64_t id) { + auto [_, result] = ExecuteSelect("SELECT * FROM dashboard_api_keys WHERE id = ?;", static_cast(id)); + if (result.eof()) return std::nullopt; + return ReadApiKey(result); +} + +std::optional 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 SQLiteDatabase::GetApiKeys(uint32_t accountId) { + std::vector 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(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(id)); +} + +void SQLiteDatabase::RecordApiKeyUsage(const std::vector& 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(entry.requests), entry.lastUsedAt, entry.lastIp, entry.quotaDay, entry.dayCount, static_cast(entry.id)); + } + transaction.Commit(); +} diff --git a/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt b/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt index 52409b22b..345f5ff89 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt +++ b/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt @@ -11,6 +11,7 @@ set(DDATABASES_DATABASES_SQLITE_TABLES_SOURCES "CharacterSnapshots.cpp" "AccountNotes.cpp" "AccountStrikes.cpp" + "ApiKeys.cpp" "Moderation.cpp" "ServerHealth.cpp" "PlayerPositions.cpp" diff --git a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h index d394a781b..d5a8f4114 100644 --- a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h +++ b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h @@ -211,6 +211,14 @@ class TestSQLDatabase : public GameDatabase { std::vector GetAccountNotes(uint32_t accountId) override { return {}; } std::optional GetAccountNote(uint64_t id) override { return {}; } void DeleteAccountNote(uint64_t id) override {} + uint64_t InsertApiKey(const ApiKey& key) override { return 0; } + std::optional GetApiKey(uint64_t id) override { return {}; } + std::optional GetApiKeyByHash(const std::string& keyHash) override { return {}; } + std::vector 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& usage) override {} uint64_t InsertStrike(const Strike& strike) override { return 0; } std::vector GetStrikes(uint32_t accountId) override { return {}; } std::optional GetStrike(uint64_t id) override { return {}; } diff --git a/migrations/dlu/mysql/83_dashboard_api_keys.sql b/migrations/dlu/mysql/83_dashboard_api_keys.sql new file mode 100644 index 000000000..438fc9143 --- /dev/null +++ b/migrations/dlu/mysql/83_dashboard_api_keys.sql @@ -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) +); diff --git a/migrations/dlu/sqlite/66_dashboard_api_keys.sql b/migrations/dlu/sqlite/66_dashboard_api_keys.sql new file mode 100644 index 000000000..d6eb2bd2d --- /dev/null +++ b/migrations/dlu/sqlite/66_dashboard_api_keys.sql @@ -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); diff --git a/tests/dDatabaseTests/DatabaseParityTests.cpp b/tests/dDatabaseTests/DatabaseParityTests.cpp index 8d128ec3c..cee5d4d8d 100644 --- a/tests/dDatabaseTests/DatabaseParityTests.cpp +++ b/tests/dDatabaseTests/DatabaseParityTests.cpp @@ -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(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(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(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); @@ -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) }; }); } +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) { Both("InsertPlayerReport", [](GameDatabase& db) { IModeration::PlayerReport report;