From 821b7c8767f055adfb0af627bf8c2d9adebf504d Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 01:04:30 -0500 Subject: [PATCH] feat(dashboard): permission grants count in every dashboard permission check 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 --- dCommon/AccountRules.h | 6 +- dCommon/CMakeLists.txt | 1 + dCommon/PermissionGrants.cpp | 151 +++++++++ dCommon/PermissionGrants.h | 110 ++++++ dCommon/Permissions.cpp | 32 +- dCommon/Permissions.h | 12 +- dDashboardServer/DashboardServer.cpp | 11 +- dDashboardServer/auth/AuthTokenHandler.cpp | 4 + .../auth/RequireAuthMiddleware.cpp | 11 +- dDashboardServer/auth/RequireAuthMiddleware.h | 6 +- dDashboardServer/routes/APIRoutes.cpp | 6 +- dDashboardServer/routes/ApiKeyRoutes.cpp | 14 +- dDashboardServer/routes/AuthRoutes.cpp | 2 +- dDashboardServer/routes/CMakeLists.txt | 1 + dDashboardServer/routes/GrantRoutes.cpp | 317 ++++++++++++++++++ dDashboardServer/routes/GrantRoutes.h | 10 + dDashboardServer/routes/PrometheusMetrics.cpp | 4 +- dDashboardServer/routes/ReportViews.cpp | 3 +- dDashboardServer/routes/RouteUtils.cpp | 12 +- dDashboardServer/routes/SettingsRoutes.cpp | 23 +- dDashboardServer/routes/SettingsRoutes.h | 14 + dDashboardServer/routes/Showcase.cpp | 9 +- dDashboardServer/routes/Traffic.cpp | 2 +- .../GameDatabase/PermissionGrantsLoader.h | 19 ++ dWeb/HTTPContext.h | 5 + dWeb/Web.cpp | 19 +- dWeb/Web.h | 10 +- tests/dWebTests/CMakeLists.txt | 1 + tests/dWebTests/PermissionGrantsTests.cpp | 204 +++++++++++ 29 files changed, 957 insertions(+), 62 deletions(-) create mode 100644 dCommon/PermissionGrants.cpp create mode 100644 dCommon/PermissionGrants.h create mode 100644 dDashboardServer/routes/GrantRoutes.cpp create mode 100644 dDashboardServer/routes/GrantRoutes.h create mode 100644 dDatabase/GameDatabase/PermissionGrantsLoader.h create mode 100644 tests/dWebTests/PermissionGrantsTests.cpp diff --git a/dCommon/AccountRules.h b/dCommon/AccountRules.h index f8b968697..3bd2bb9bd 100644 --- a/dCommon/AccountRules.h +++ b/dCommon/AccountRules.h @@ -4,6 +4,7 @@ #include namespace ApiKeys { struct Scope; } +namespace PermissionGrants { struct Held; } /** * Who staff may use their tools on: the self and rank rules shared by the dashboard (RouteUtils) and the in-game slash @@ -109,6 +110,7 @@ namespace AccountRules { return eManageDenial::NONE; } - // ManageDenialNow for a request made with an API key (scope nullptr: a browser session, the plain rules) - eManageDenial ManageDenialNow(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, eAccountAction action, const ApiKeys::Scope* scope); + // ManageDenialNow for a request made with an API key (scope nullptr: a browser session, the plain rules). grants: the + // actor's permission grants (PermissionGrants.h), which count for self_* and manage_equal_rank; nullptr: the level alone. + eManageDenial ManageDenialNow(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, eAccountAction action, const ApiKeys::Scope* scope, const PermissionGrants::Held* grants = nullptr); } diff --git a/dCommon/CMakeLists.txt b/dCommon/CMakeLists.txt index d419525cd..e106d1f76 100644 --- a/dCommon/CMakeLists.txt +++ b/dCommon/CMakeLists.txt @@ -33,6 +33,7 @@ set(DCOMMON_SOURCES "ZoneScenes.cpp" "Process.cpp" "Permissions.cpp" + "PermissionGrants.cpp" "NifFile.cpp" ) diff --git a/dCommon/PermissionGrants.cpp b/dCommon/PermissionGrants.cpp new file mode 100644 index 000000000..bf3f604d5 --- /dev/null +++ b/dCommon/PermissionGrants.cpp @@ -0,0 +1,151 @@ +#include "PermissionGrants.h" + +#include + +#include "GeneralUtils.h" +#include "Permissions.h" + +namespace { + using PermissionGrants::eKind; + using PermissionGrants::eMatch; + + constexpr uint8_t STAFF_FLOOR = 1; // commands with a higher floor are never granted (SlashCommandLevels::STAFF_MIN_LEVEL) + + // A deny anywhere beats every allow + eMatch Combine(eMatch a, eMatch b) { + if (a == eMatch::DENY || b == eMatch::DENY) return eMatch::DENY; + if (a == eMatch::ALLOW || b == eMatch::ALLOW) return eMatch::ALLOW; + return eMatch::NONE; + } + + eMatch Of(const PermissionGrants::Rule& rule) { + return rule.deny ? eMatch::DENY : eMatch::ALLOW; + } +} + +namespace PermissionGrants { + std::optional ParseKind(std::string_view name) { + if (name == "permission") return eKind::PERMISSION; + if (name == "command") return eKind::COMMAND; + if (name == "permission_group") return eKind::PERMISSION_GROUP; + if (name == "command_group") return eKind::COMMAND_GROUP; + return std::nullopt; + } + + std::string_view KindName(eKind kind) { + switch (kind) { + case eKind::PERMISSION: return "permission"; + case eKind::COMMAND: return "command"; + case eKind::PERMISSION_GROUP: return "permission_group"; + default: return "command_group"; + } + } + + void Held::Add(std::string_view kind, std::string name, bool deny, int64_t expiresAt) { + const auto parsed = ParseKind(kind); + if (parsed) rules.push_back({ *parsed, std::move(name), deny, expiresAt }); + } + + eMatch ForPermission(const Held& held, std::string_view key, int64_t now) { + const auto* permission = Permissions::Find(std::string(key)); + if (!permission || permission->locked) return eMatch::NONE; + eMatch match = eMatch::NONE; + for (const auto& rule : held.rules) { + if (!InForce(rule, now)) continue; + if ((rule.kind == eKind::PERMISSION && rule.name == key) || (rule.kind == eKind::PERMISSION_GROUP && rule.name == permission->category)) { + match = Combine(match, Of(rule)); + } + } + return match; + } + + eMatch ForCommand(const Held& held, const Command& command, int64_t now) { + eMatch match = command.permission.empty() ? eMatch::NONE : ForPermission(held, command.permission, now); + for (const auto& rule : held.rules) { + if (!InForce(rule, now)) continue; + if (rule.kind == eKind::COMMAND && rule.name == command.name) match = Combine(match, Of(rule)); + else if (rule.kind == eKind::COMMAND_GROUP) { + const auto level = CommandGroupLevel(rule.name); + if (level && command.level <= *level) match = Combine(match, Of(rule)); + } + } + return match; + } + + bool MayUseCommand(uint8_t playerLevel, uint8_t accountLevel, const Command& command, const Held* held, int64_t now) { + const bool byLevel = playerLevel >= command.level; + if (!held || command.fixed) return byLevel; + auto match = ForCommand(*held, command, now); + // A floor above GM 1 is a safety limit of the code (e.g. /execute): grants don't take anyone below it + if (match == eMatch::ALLOW && command.minLevel > STAFF_FLOOR && playerLevel < command.minLevel) match = eMatch::NONE; + return Decide(byLevel, match, accountLevel); + } + + std::vector PermissionGroups() { + std::vector groups; + for (const auto& permission : Permissions::All()) { + if (std::ranges::find(groups, permission.category) == groups.end()) groups.push_back(permission.category); + } + return groups; + } + + std::vector GroupPermissions(std::string_view category) { + std::vector keys; + for (const auto& permission : Permissions::All()) { + if (permission.category == category && !permission.locked) keys.push_back(permission.key); + } + return keys; + } + + std::optional CommandGroupLevel(std::string_view name) { + if (name.size() != 1 || name[0] < '1' || name[0] > '9') return std::nullopt; + return static_cast(name[0] - '0'); + } + + std::string Refusal(eKind kind, const std::string& name, uint8_t grantorLevel, + const std::function& holdsPermission, + const std::function(const std::string&)>& findCommand, + const std::function& holdsCommand) { + switch (kind) { + case eKind::PERMISSION: { + const auto* permission = Permissions::Find(name); + if (!permission) return "Unknown permission " + name; + if (permission->locked) return permission->key + " is always GM 9 only and can't be granted"; + if (!holdsPermission(name)) return "You can't grant " + name + ": you don't have it yourself"; + return ""; + } + case eKind::PERMISSION_GROUP: { + const auto keys = GroupPermissions(name); + if (keys.empty()) return "Unknown permission group " + name; + for (const auto& key : keys) { + if (!holdsPermission(key)) return "You can't grant every " + name + " permission: you don't have " + key + " yourself"; + } + return ""; + } + case eKind::COMMAND: { + const auto command = findCommand(name); + if (!command) return "Unknown command " + name + " (the world servers list their commands when they start)"; + if (command->fixed) return "/" + name + " has a fixed level; grants don't apply to it"; + if (command->minLevel > STAFF_FLOOR) return "/" + name + " never goes below GM " + std::to_string(command->minLevel) + "; grants can't change that"; + if (!holdsCommand(*command)) return "You can't grant /" + name + ": you may not use it yourself"; + return ""; + } + case eKind::COMMAND_GROUP: { + const auto level = CommandGroupLevel(name); + if (!level) return "A command group is a GM level from 1 to 9"; + if (grantorLevel < *level) return "You can't grant every command up to GM " + name + ": your GM level is " + std::to_string(grantorLevel); + return ""; + } + } + return "Unknown kind of grant"; + } + + std::string Describe(eKind kind, const std::string& name) { + switch (kind) { + case eKind::PERMISSION: return "the " + name + " permission"; + case eKind::COMMAND: return "/" + name; + case eKind::PERMISSION_GROUP: return "every " + name + " permission"; + default: return "every command up to GM " + name; + } + } +} diff --git a/dCommon/PermissionGrants.h b/dCommon/PermissionGrants.h new file mode 100644 index 000000000..2f68e0095 --- /dev/null +++ b/dCommon/PermissionGrants.h @@ -0,0 +1,110 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +/** + * Permissions and slash commands granted to, or denied from, one account or character on top of what its GM level + * allows (the permission_grants table). What someone may do: + * + * allowed = (their GM level allows it OR a grant allows it) AND no deny matches it + * + * Denies never apply to GM 9 accounts, so an operator can't be locked out; the locked permissions (settings, + * permissions_manage) stay GM 9 only and no grant or group covers them. A grant for a command whose floor is above GM 1 + * (e.g. /execute) never takes anyone below that floor. Grants that are removed or past their expiry count for nothing. + * The dashboard uses the account's grants; the world servers use the account's and the logged-in character's. + * Everything here is pure apart from reading the permission catalog and levels (Permissions.h). + */ +namespace PermissionGrants { + constexpr std::string_view ACCOUNT = "account"; + constexpr std::string_view CHARACTER = "character"; + + enum class eKind : uint8_t { + PERMISSION, // name: a dashboard permission key; in game it also covers the commands paired with it + COMMAND, // name: a slash command's settings name (SlashCommandLevels::SettingName) + PERMISSION_GROUP, // name: a permission category: every permission in it that isn't locked + COMMAND_GROUP, // name: a GM level "1".."9": every command needing that level or lower + }; + + std::optional ParseKind(std::string_view name); + std::string_view KindName(eKind kind); + + struct Rule { + eKind kind{}; + std::string name; + bool deny{}; + int64_t expiresAt{}; // 0: never + }; + + // The rules of one account (and in game its logged-in character), as loaded + struct Held { + std::vector rules; + // A row from the table; a kind this version doesn't know is skipped + void Add(std::string_view kind, std::string name, bool deny, int64_t expiresAt); + }; + + inline bool InForce(const Rule& rule, int64_t now) { + return rule.expiresAt == 0 || rule.expiresAt > now; + } + + enum class eMatch : uint8_t { NONE, ALLOW, DENY }; + + // What the rules say about a dashboard permission (its own rules and its category's). A deny beats an allow. + // Locked or unknown permissions never match. + eMatch ForPermission(const Held& held, std::string_view key, int64_t now); + + // A slash command as the grant rules see it + struct Command { + std::string name; // settings name + uint8_t level{}; // the level it needs now + uint8_t minLevel{}; // its floor + bool fixed{}; // its level can't change (the client acts on it by itself); grants don't apply + std::string permission; // the dashboard permission it is paired with, if any + }; + + // What the rules say about a command: its own rules, command groups at or above its level, and for a paired + // command its permission's rules. A deny beats an allow. + eMatch ForCommand(const Held& held, const Command& command, int64_t now); + + // Whether denies may apply to an account at this GM level (never to GM 9) + inline bool Deniable(uint8_t accountLevel) { + return accountLevel < 9; + } + + inline bool Decide(bool byLevel, eMatch match, uint8_t accountLevel) { + if (match == eMatch::DENY && Deniable(accountLevel)) return false; + return byLevel || match == eMatch::ALLOW; + } + + /** + * Whether someone may use a command. playerLevel: the character's current GM level (what the level check uses); + * accountLevel: the account's (decides whether denies apply). held nullptr: the level alone. + */ + bool MayUseCommand(uint8_t playerLevel, uint8_t accountLevel, const Command& command, const Held* held, int64_t now); + + // The permission groups: the categories of the dashboard permissions, in catalog order + std::vector PermissionGroups(); + // The keys a permission group covers (never the locked permissions); empty: no such group + std::vector GroupPermissions(std::string_view category); + // A command group's GM level (1-9); nullopt: not a command group name + std::optional CommandGroupLevel(std::string_view name); + + /** + * Why someone may not grant or deny this (empty: they may). Nobody hands out more than they hold themselves: + * holdsPermission(key) is whether the grantor has a dashboard permission now; findCommand(name) looks a command up; + * holdsCommand(command) is whether the grantor may use it; grantorLevel is the grantor's GM level (command groups + * need at least their level). Locked permissions, fixed commands and commands with a floor above GM 1 can't be + * granted at all. + */ + std::string Refusal(eKind kind, const std::string& name, uint8_t grantorLevel, + const std::function& holdsPermission, + const std::function(const std::string&)>& findCommand, + const std::function& holdsCommand); + + // "the accounts_kick permission", "/spawn", "every Accounts permission", "every command up to GM 3" + std::string Describe(eKind kind, const std::string& name); +} diff --git a/dCommon/Permissions.cpp b/dCommon/Permissions.cpp index 34671b848..e70778cb7 100644 --- a/dCommon/Permissions.cpp +++ b/dCommon/Permissions.cpp @@ -1,6 +1,9 @@ #include "Permissions.h" #include "AccountRules.h" #include "ApiKeyScope.h" +#include "PermissionGrants.h" + +#include #include @@ -107,6 +110,7 @@ namespace { { "email_settings", "Server", "Email settings", "Connect the mail account and send test emails", 9 }, { "settings", "Server", "Server settings", "Change any server setting on the Settings page (always GM 9)", 9, true }, { "permissions_manage", "Server", "Permissions", "Change what each GM level may do (always GM 9)", 9, true }, + { "grants_manage", "Accounts", "Grant permissions", "Give or take away dashboard permissions and in-game commands for one account or character, with an optional expiry (only ones they have themselves, and only on accounts they may manage)", 9 }, { "dev_message_inspector", "Developer tools", "Game message inspector", "Capture the game messages an online player sends and receives, live (every capture is audited)", 8 }, { "dev_cdclient", "Developer tools", "CDClient browser", "Read the game's CDClient tables as they are: page, sort, search and filter any table", 8 }, @@ -153,38 +157,42 @@ namespace Permissions { return gmLevel >= Level(key); } - bool Allowed(uint8_t gmLevel, const std::string& key, const ApiKeys::Scope* scope) { - return Allowed(gmLevel, key) && (!scope || scope->Has(key)); + bool Allowed(uint8_t gmLevel, const std::string& key, const ApiKeys::Scope* scope, const PermissionGrants::Held* grants) { + bool allowed = Allowed(gmLevel, key); + if (grants && Find(key)) { + allowed = PermissionGrants::Decide(allowed, PermissionGrants::ForPermission(*grants, key, static_cast(std::time(nullptr))), gmLevel); + } + return allowed && (!scope || scope->Has(key)); } - std::set NotGrantable(uint8_t gmLevel, const std::set& requested) { + std::set NotGrantable(uint8_t gmLevel, const std::set& requested, const PermissionGrants::Held* grants) { std::set refused; for (const auto& permission : requested) { - if (!Find(permission) || !Allowed(gmLevel, permission)) refused.insert(permission); + if (!Find(permission) || !Allowed(gmLevel, permission, nullptr, grants)) refused.insert(permission); } return refused; } - bool CanViewCharacter(uint8_t gmLevel, uint32_t viewerAccountId, uint32_t ownerAccountId, const ApiKeys::Scope* scope) { + bool CanViewCharacter(uint8_t gmLevel, uint32_t viewerAccountId, uint32_t ownerAccountId, const ApiKeys::Scope* scope, const PermissionGrants::Held* grants) { const bool own = viewerAccountId != 0 && viewerAccountId == ownerAccountId; - return Allowed(gmLevel, "characters_view", scope) || (own && Allowed(gmLevel, "own_characters", scope)); + return Allowed(gmLevel, "characters_view", scope, grants) || (own && Allowed(gmLevel, "own_characters", scope, grants)); } - nlohmann::json ForLevel(uint8_t gmLevel, const ApiKeys::Scope* scope) { + nlohmann::json ForLevel(uint8_t gmLevel, const ApiKeys::Scope* scope, const PermissionGrants::Held* grants) { nlohmann::json can = nlohmann::json::object(); - for (const auto& permission : PERMISSIONS) can[permission.key] = Allowed(gmLevel, permission.key, scope); + for (const auto& permission : PERMISSIONS) can[permission.key] = Allowed(gmLevel, permission.key, scope, grants); return can; } } namespace AccountRules { eManageDenial ManageDenialNow(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, eAccountAction action) { - return ManageDenial(actorLevel, actorAccountId, targetLevel, targetAccountId, - Permissions::Allowed(actorLevel, SelfPermission(action)), Permissions::Allowed(actorLevel, EQUAL_RANK_PERMISSION)); + return ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, action, nullptr, nullptr); } - eManageDenial ManageDenialNow(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, eAccountAction action, const ApiKeys::Scope* scope) { - const auto denial = ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, action); + eManageDenial ManageDenialNow(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, eAccountAction action, const ApiKeys::Scope* scope, const PermissionGrants::Held* grants) { + const auto denial = ManageDenial(actorLevel, actorAccountId, targetLevel, targetAccountId, + Permissions::Allowed(actorLevel, SelfPermission(action), nullptr, grants), Permissions::Allowed(actorLevel, EQUAL_RANK_PERMISSION, nullptr, grants)); if (!scope) return denial; return ScopedManageDenial(denial, actorLevel, actorAccountId, targetLevel, targetAccountId, scope->Has(SelfPermission(action)), scope->Has(EQUAL_RANK_PERMISSION)); diff --git a/dCommon/Permissions.h b/dCommon/Permissions.h index 2c347efc0..20dc15051 100644 --- a/dCommon/Permissions.h +++ b/dCommon/Permissions.h @@ -10,6 +10,7 @@ #include "json.hpp" namespace ApiKeys { struct Scope; } +namespace PermissionGrants { struct Held; } /** * What each GM level may do on the dashboard, and in the game for the slash commands paired with a permission. Every @@ -54,18 +55,19 @@ namespace Permissions { bool Allowed(uint8_t gmLevel, const std::string& key); // For a request made with an API key: the owner's level must allow it AND the key's scope must name it. - // scope nullptr (a browser session) is the plain check. - bool Allowed(uint8_t gmLevel, const std::string& key, const ApiKeys::Scope* scope); + // scope nullptr (a browser session) is the plain check. grants: the account's permission grants (PermissionGrants.h), + // which can allow what the level doesn't or deny what it does; nullptr: the level alone. + bool Allowed(uint8_t gmLevel, const std::string& key, const ApiKeys::Scope* scope, const PermissionGrants::Held* grants = nullptr); // The permissions in a requested API key scope that a GM level may not give it (unknown ones, or ones it doesn't // have): a key can never be made with more than its maker has. Empty: all of them may be given. - std::set NotGrantable(uint8_t gmLevel, const std::set& requested); + std::set NotGrantable(uint8_t gmLevel, const std::set& requested, const PermissionGrants::Held* grants = nullptr); // characters_view for anyone's character, or own_characters for the viewer's own (account 0 owns nothing) - bool CanViewCharacter(uint8_t gmLevel, uint32_t viewerAccountId, uint32_t ownerAccountId, const ApiKeys::Scope* scope = nullptr); + bool CanViewCharacter(uint8_t gmLevel, uint32_t viewerAccountId, uint32_t ownerAccountId, const ApiKeys::Scope* scope = nullptr, const PermissionGrants::Held* grants = nullptr); // {key: bool} for every permission, for templates and scripts - nlohmann::json ForLevel(uint8_t gmLevel, const ApiKeys::Scope* scope = nullptr); + nlohmann::json ForLevel(uint8_t gmLevel, const ApiKeys::Scope* scope = nullptr, const PermissionGrants::Held* grants = nullptr); // Pure: the level a config value gives a permission (bad or out-of-range values fall back to the default) uint8_t Resolve(const Permission& permission, const std::string& configValue); diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index 8d4740a55..18ddb736e 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -100,6 +100,8 @@ #include "AuthTokenHandler.h" #include "ApiKeyService.h" #include "ApiKeyRoutes.h" +#include "GrantRoutes.h" +#include "PermissionGrantsLoader.h" #include "JWTUtils.h" #include "GeneralUtils.h" #include @@ -488,13 +490,13 @@ int main(int argc, char** argv) { const auto key = ApiKeyService::Verify(token); if (!key) return std::nullopt; if (key->needsTwoFactorSetup) return WSAuth{ 0, key->accountId, key->scope }; - return WSAuth{ key->gmLevel, key->accountId, key->scope }; + return WSAuth{ key->gmLevel, key->accountId, key->scope, PermissionGrants::Load(key->accountId) }; } const auto result = AuthTokenHandler::ValidateToken(token); if (!result.isValid) return std::nullopt; // Until required two-factor login is set up the session only reaches its own account page if (DashboardAuthService::NeedsTwoFactorSetup(result.accountId, result.gmLevel)) return WSAuth{ 0, result.accountId }; - return WSAuth{ result.gmLevel, result.accountId }; + return WSAuth{ result.gmLevel, result.accountId, nullptr, PermissionGrants::Load(result.accountId) }; }); if (!Totp::LoadKey()) LOG("Two-factor login is unavailable: no usable key"); @@ -533,11 +535,12 @@ int main(int argc, char** argv) { RegisterServerRoutes(); RegisterLeaderboardRoutes(); ApiKeyRoutes::RegisterRoutes(); - RequireAuthMiddleware::SetApiAccessCheck([](uint8_t gmLevel) { return Permissions::Allowed(gmLevel, "api_access"); }); + GrantRoutes::RegisterRoutes(); + RequireAuthMiddleware::SetApiAccessCheck([](const HTTPContext& context) { return Permissions::Allowed(context.gmLevel, "api_access", nullptr, context.grants.get()); }); RequireAuthMiddleware::SetForbiddenPage([](const HTTPContext& context, HTTPReply& reply) { RouteUtils::RenderError(reply, context, eHTTPStatusCode::FORBIDDEN, "You don't have permission to open this page."); }); - Game::web.SetWSApiAccessCallback([](uint8_t gmLevel) { return Permissions::Allowed(gmLevel, "api_access"); }); + Game::web.SetWSApiAccessCallback([](const WSAuth& auth) { return Permissions::Allowed(auth.level, "api_access", nullptr, auth.grants.get()); }); RegisterVanityRoutes(); RegisterChatRoutes(); RegisterStrikeRoutes(); diff --git a/dDashboardServer/auth/AuthTokenHandler.cpp b/dDashboardServer/auth/AuthTokenHandler.cpp index dacaee236..fe6118fc7 100644 --- a/dDashboardServer/auth/AuthTokenHandler.cpp +++ b/dDashboardServer/auth/AuthTokenHandler.cpp @@ -1,4 +1,5 @@ #include "AuthTokenHandler.h" +#include "PermissionGrantsLoader.h" #include "ApiKeyService.h" #include "DashboardAuthService.h" #include "Game.h" @@ -82,6 +83,7 @@ bool AuthTokenHandler::ProcessHTTPContext(HTTPContext& context, HTTPReply& reply if (source == eTokenSource::HEADER && ApiKeyService::LooksLikeKey(token)) { const auto keyResult = ApiKeyService::Authenticate(token, context, reply); if (keyResult == ApiKeyService::eResult::INVALID) LOG_DEBUG("API key validation failed from %s", context.clientIP.c_str()); + if (context.isAuthenticated) context.grants = PermissionGrants::Load(context.accountId); return keyResult != ApiKeyService::eResult::REFUSED; } @@ -95,6 +97,8 @@ bool AuthTokenHandler::ProcessHTTPContext(HTTPContext& context, HTTPReply& reply context.authenticatedUser = result.username; context.accountId = result.accountId; context.gmLevel = result.gmLevel; + // Read on every request like the GM level, so a grant given or taken away applies at once + context.grants = PermissionGrants::Load(result.accountId); context.userData["auth_source"] = source == eTokenSource::COOKIE ? "cookie" : "header"; if (DashboardAuthService::NeedsTwoFactorSetup(result.accountId, result.gmLevel)) context.userData["needs_2fa"] = "1"; return true; diff --git a/dDashboardServer/auth/RequireAuthMiddleware.cpp b/dDashboardServer/auth/RequireAuthMiddleware.cpp index 8712952f9..509624d52 100644 --- a/dDashboardServer/auth/RequireAuthMiddleware.cpp +++ b/dDashboardServer/auth/RequireAuthMiddleware.cpp @@ -3,6 +3,7 @@ #include "Web.h" #include "Game.h" #include "Logger.h" +#include "Permissions.h" namespace { bool IsApiRequest(const HTTPContext& context) { @@ -17,7 +18,7 @@ namespace { path.starts_with("/js/") || path.starts_with("/css/") || path == "/favicon.ico"; } - std::function g_ApiAccessAllowed; + std::function g_ApiAccessAllowed; std::function g_ForbiddenPage; std::function g_ApiKeyDenied; @@ -34,7 +35,7 @@ namespace { } } -void RequireAuthMiddleware::SetApiAccessCheck(std::function check) { +void RequireAuthMiddleware::SetApiAccessCheck(std::function check) { g_ApiAccessAllowed = std::move(check); } @@ -83,7 +84,7 @@ bool RequireAuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) { } // A token in the Authorization header is API use, which a GM level may not be allowed - if (authSource != context.userData.end() && authSource->second == "header" && g_ApiAccessAllowed && !g_ApiAccessAllowed(context.gmLevel)) { + if (authSource != context.userData.end() && authSource->second == "header" && g_ApiAccessAllowed && !g_ApiAccessAllowed(context)) { reply.status = eHTTPStatusCode::FORBIDDEN; reply.message = "{\"success\":false,\"error\":\"API access isn't allowed for your account\"}"; reply.contentType = eContentType::APPLICATION_JSON; @@ -105,7 +106,9 @@ bool RequireAuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) { } const auto minGmLevel = requiredLevel(); - if (context.gmLevel < minGmLevel) { + // A route guarded by a permission: the level, or a grant for this account (a deny takes it away) + const bool allowed = permission.empty() ? context.gmLevel >= minGmLevel : Permissions::Allowed(context.gmLevel, permission, nullptr, context.grants.get()); + if (!allowed) { LOG_DEBUG("Forbidden access attempt by user %s (GM level %d < %d required) to %s from %s", context.authenticatedUser.c_str(), context.gmLevel, minGmLevel, context.path.c_str(), context.clientIP.c_str()); diff --git a/dDashboardServer/auth/RequireAuthMiddleware.h b/dDashboardServer/auth/RequireAuthMiddleware.h index dab1d96b6..dd836c6e5 100644 --- a/dDashboardServer/auth/RequireAuthMiddleware.h +++ b/dDashboardServer/auth/RequireAuthMiddleware.h @@ -30,9 +30,9 @@ public: bool Process(HTTPContext& context, HTTPReply& reply) override; - // Whether a GM level may use the API (requests signed in with a token in the Authorization header rather than - // the browser's cookie). Set by the dashboard from its api_access permission; unset allows everyone. - static void SetApiAccessCheck(std::function check); + // Whether a signed-in account may use the API (requests signed in with a token in the Authorization header rather + // than the browser's cookie). Set by the dashboard from its api_access permission; unset allows everyone. + static void SetApiAccessCheck(std::function check); // Renders the page a signed-in account gets when it may not open a page (not /api/); unset replies with JSON static void SetForbiddenPage(std::function render); diff --git a/dDashboardServer/routes/APIRoutes.cpp b/dDashboardServer/routes/APIRoutes.cpp index 9501ea5fa..65e858e12 100644 --- a/dDashboardServer/routes/APIRoutes.cpp +++ b/dDashboardServer/routes/APIRoutes.cpp @@ -482,7 +482,8 @@ namespace { nlohmann::json routes = nlohmann::json::array(); for (const auto& doc : GetRouteDocs()) { const int16_t level = doc.permission.empty() ? doc.minGmLevel : Permissions::Level(doc.permission); - if (level > context.gmLevel || !doc.path.starts_with("/api/") || !KeyMayUse(context, doc)) continue; + const bool allowed = doc.permission.empty() ? level <= context.gmLevel : Permissions::Allowed(context.gmLevel, doc.permission, nullptr, context.grants.get()); + if (!allowed || !doc.path.starts_with("/api/") || !KeyMayUse(context, doc)) continue; routes.push_back({ {"method", doc.method}, {"path", doc.path}, {"minGmLevel", level}, {"permission", doc.permission}, {"description", doc.description} }); } JsonReply(reply, eHTTPStatusCode::OK, { @@ -497,7 +498,8 @@ namespace { std::vector routes; for (const auto& doc : GetRouteDocs()) { const int16_t level = doc.permission.empty() ? doc.minGmLevel : Permissions::Level(doc.permission); - if (level > context.gmLevel || !doc.path.starts_with("/api/") || !KeyMayUse(context, doc)) continue; + const bool allowed = doc.permission.empty() ? level <= context.gmLevel : Permissions::Allowed(context.gmLevel, doc.permission, nullptr, context.grants.get()); + if (!allowed || !doc.path.starts_with("/api/") || !KeyMayUse(context, doc)) continue; routes.push_back({ doc.method, doc.path, doc.description, level, doc.permission }); } JsonReply(reply, eHTTPStatusCode::OK, OpenApi::Build(routes, "DarkflameServer dashboard")); diff --git a/dDashboardServer/routes/ApiKeyRoutes.cpp b/dDashboardServer/routes/ApiKeyRoutes.cpp index d2ee652a9..9aceb2fc0 100644 --- a/dDashboardServer/routes/ApiKeyRoutes.cpp +++ b/dDashboardServer/routes/ApiKeyRoutes.cpp @@ -11,6 +11,7 @@ #include "GeneralUtils.h" #include "HTTPContext.h" #include "Permissions.h" +#include "PermissionGrantsLoader.h" #include "RequireAuthMiddleware.h" #include "RouteUtils.h" @@ -61,14 +62,14 @@ namespace { return "active"; } - nlohmann::json KeyJson(const IApiKeys::ApiKey& key, uint8_t ownerLevel, int64_t sessionsValidAfter) { + 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 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)) lost.push_back(permission); + for (const auto& permission : permissions) if (!Permissions::Allowed(ownerLevel, permission, nullptr, ownerGrants)) lost.push_back(permission); auto requests = key.requestCount; auto lastUsed = key.lastUsedAt; @@ -163,11 +164,11 @@ namespace ApiKeyRoutes { 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)} }); + {"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")} }); + {"apiAccess", Permissions::Allowed(context.gmLevel, "api_access", nullptr, context.grants.get())} }); }); Route(eHTTPMethod::GET, "/api/accounts/:id/api_keys", 0, @@ -185,7 +186,8 @@ namespace ApiKeyRoutes { const auto ownerLevel = static_cast(account.value("gm_level", 0)); const auto validAfter = Database::Get()->GetSessionsValidAfter(*accountId); nlohmann::json keys = nlohmann::json::array(); - for (const auto& key : Database::Get()->GetApiKeys(*accountId)) keys.push_back(KeyJson(key, ownerLevel, validAfter)); + 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} }); }); @@ -214,7 +216,7 @@ namespace ApiKeyRoutes { for (const auto& permission : requested) if (permission.is_string()) permissions.insert(permission.get()); 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); + 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 { diff --git a/dDashboardServer/routes/AuthRoutes.cpp b/dDashboardServer/routes/AuthRoutes.cpp index 0c9c30046..1303ac0b7 100644 --- a/dDashboardServer/routes/AuthRoutes.cpp +++ b/dDashboardServer/routes/AuthRoutes.cpp @@ -225,7 +225,7 @@ void RegisterAuthRoutes() { .method = eHTTPMethod::POST, .middleware = { std::make_shared(0) }, .handle = [](HTTPReply& reply, const HTTPContext& context) { - if (!Permissions::Allowed(context.gmLevel, "api_access")) return RouteUtils::JsonError(reply, eHTTPStatusCode::FORBIDDEN, "API access isn't allowed for your account"); + if (!Permissions::Allowed(context.gmLevel, "api_access", nullptr, context.grants.get())) return RouteUtils::JsonError(reply, eHTTPStatusCode::FORBIDDEN, "API access isn't allowed for your account"); // Only a signed-in browser session may make tokens: a leaked token must not be able to renew itself for a year const auto source = context.userData.find("auth_source"); if (source == context.userData.end() || source->second != "cookie") { diff --git a/dDashboardServer/routes/CMakeLists.txt b/dDashboardServer/routes/CMakeLists.txt index a11e08ae7..e83d8a0b3 100644 --- a/dDashboardServer/routes/CMakeLists.txt +++ b/dDashboardServer/routes/CMakeLists.txt @@ -5,6 +5,7 @@ set(DASHBOARDROUTES_SOURCES "WSRoutes.cpp" "AuthRoutes.cpp" "ApiKeyRoutes.cpp" + "GrantRoutes.cpp" "RouteUtils.cpp" "PlayerActions.cpp" "AccountRoutes.cpp" diff --git a/dDashboardServer/routes/GrantRoutes.cpp b/dDashboardServer/routes/GrantRoutes.cpp new file mode 100644 index 000000000..666351a32 --- /dev/null +++ b/dDashboardServer/routes/GrantRoutes.cpp @@ -0,0 +1,317 @@ +#include "GrantRoutes.h" + +#include +#include + +#include "AccountRules.h" +#include "Database.h" +#include "eHTTPMethod.h" +#include "Game.h" +#include "GeneralUtils.h" +#include "HTTPContext.h" +#include "PermissionGrants.h" +#include "Permissions.h" +#include "RouteUtils.h" +#include "SettingsRoutes.h" +#include "Web.h" +#include "WSRoutes.h" + +using namespace RouteUtils; +using AccountRules::eAccountAction; +using PermissionGrants::eKind; + +namespace { + constexpr size_t MAX_NOTE = 255; + constexpr size_t MAX_NAME = 64; + constexpr uint32_t MAX_LIST = 500; + constexpr uint32_t HISTORY_LENGTH = 200; + constexpr const char* MANAGE = "grants_manage"; + + int64_t Now() { return static_cast(std::time(nullptr)); } + + 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; + } + + std::string UtcTime(int64_t time) { + const auto seconds = static_cast(time); + std::tm tm{}; + gmtime_r(&seconds, &tm); + char text[32]; + std::strftime(text, sizeof(text), "%Y-%m-%d %H:%M UTC", &tm); + return text; + } + + // Who a grant is for + struct Target { + std::string type; // PermissionGrants::ACCOUNT or CHARACTER + int64_t id{}; // account ID or charinfo ID + uint32_t accountId{}; // the account (the character's owner) + std::string name; + + std::string Describe() const { + return (type == PermissionGrants::ACCOUNT ? "account " : "character ") + name + " (" + std::to_string(id) + ")"; + } + AuditTarget Audit() const { + return type == PermissionGrants::ACCOUNT ? AuditTarget::Account(accountId) : AuditTarget{ accountId, id }; + } + }; + + std::optional AccountTarget(uint32_t accountId) { + const auto account = Database::Get()->GetAccountById(accountId); + if (account.contains("error")) return std::nullopt; + return Target{ std::string(PermissionGrants::ACCOUNT), accountId, accountId, account.value("name", std::string{}) }; + } + + std::optional CharacterTarget(LWOOBJID characterId) { + const auto info = Database::Get()->GetCharacterInfo(characterId); + if (!info) return std::nullopt; + return Target{ std::string(PermissionGrants::CHARACTER), characterId, info->accountId, info->name }; + } + + // {targetType, target}: an account's ID or name, or a character's ID or name + std::optional ResolveTarget(const std::string& type, const nlohmann::json& value) { + std::string text = value.is_string() ? Trim(value.get()) : value.is_number_integer() ? std::to_string(value.get()) : ""; + if (text.empty()) return std::nullopt; + if (type == PermissionGrants::ACCOUNT) { + if (const auto id = GeneralUtils::TryParse(text)) return AccountTarget(*id); + const auto info = Database::Get()->GetAccountInfo(text); + return info ? AccountTarget(info->id) : std::nullopt; + } + if (type == PermissionGrants::CHARACTER) { + const auto id = ResolveCharacter(text); + return id ? CharacterTarget(*id) : std::nullopt; + } + return std::nullopt; + } + + std::optional TargetOf(const IPermissionGrants::Grant& grant) { + if (grant.targetType == PermissionGrants::ACCOUNT) return AccountTarget(static_cast(grant.targetId)); + return CharacterTarget(grant.targetId); + } + + std::optional FindCommand(const std::vector& commands, const std::string& name) { + for (const auto& command : commands) if (command.rules.name == name) return command.rules; + return std::nullopt; + } + + // Why the signed-in user may not give or take away this (empty: they may): only what they hold themselves + std::string Refusal(const HTTPContext& context, eKind kind, const std::string& name, const std::vector& commands) { + const auto now = Now(); + return PermissionGrants::Refusal(kind, name, context.gmLevel, + [&context](const std::string& key) { return Can(context, key); }, + [&commands](const std::string& command) { return FindCommand(commands, command); }, + [&context, now](const PermissionGrants::Command& command) { + return PermissionGrants::MayUseCommand(context.gmLevel, context.gmLevel, command, context.grants.get(), now); + }); + } + + std::string Status(const IPermissionGrants::Grant& grant, int64_t now) { + if (grant.revokedAt != 0) return "removed"; + if (grant.expiresAt != 0 && grant.expiresAt <= now) return "expired"; + return "active"; + } + + // Rows as JSON, with who they are for and whether the signed-in user may remove them + class Lister { + public: + Lister(const HTTPContext& context) : context(context), commands(CurrentSlashCommands()), now(Now()) {} + + nlohmann::json Row(const IPermissionGrants::Grant& grant) { + const auto kind = PermissionGrants::ParseKind(grant.kind); + const auto status = Status(grant, now); + const auto& target = Find(grant); + bool canRemove = false; + if (kind && status == "active" && target && Can(context, MANAGE)) { + canRemove = MayManage(target->accountId) && Refusal(context, *kind, grant.name, commands).empty(); + } + return { + {"id", grant.id}, {"targetType", grant.targetType}, {"targetId", std::to_string(grant.targetId)}, + {"targetName", target ? target->name : ""}, {"accountId", target ? target->accountId : 0}, + {"kind", grant.kind}, {"name", grant.name}, {"label", kind ? PermissionGrants::Describe(*kind, grant.name) : grant.kind + " " + grant.name}, + {"deny", grant.deny}, {"expiresAt", grant.expiresAt}, {"note", grant.note}, {"grantedAt", grant.grantedAt}, {"grantedBy", grant.grantedBy}, + {"revokedAt", grant.revokedAt}, {"revokedBy", grant.revokedBy}, {"status", status}, {"canRemove", canRemove} + }; + } + + nlohmann::json Rows(const std::vector& grants) { + nlohmann::json rows = nlohmann::json::array(); + for (const auto& grant : grants) rows.push_back(Row(grant)); + return rows; + } + + private: + const std::optional& Find(const IPermissionGrants::Grant& grant) { + const auto key = grant.targetType + ":" + std::to_string(grant.targetId); + auto it = targets.find(key); + if (it == targets.end()) it = targets.emplace(key, TargetOf(grant)).first; + return it->second; + } + + bool MayManage(uint32_t accountId) { + auto it = manageable.find(accountId); + if (it == manageable.end()) { + const auto account = Database::Get()->GetAccountById(accountId); + const bool may = !account.contains("error") && + CanManageAccount(context, static_cast(account.value("gm_level", 0)), accountId, eAccountAction::MODERATION); + it = manageable.emplace(accountId, may).first; + } + return it->second; + } + + const HTTPContext& context; + std::vector commands; + int64_t now; + std::map> targets; + std::map manageable; + }; + + // Grants apply at once: the dashboard's open pages get their account's rights again + void Applied(const Target& target) { + Game::web.RecheckWebSockets(target.accountId); + BroadcastTableChanged("grants", std::to_string(target.accountId)); + } +} + +void GrantRoutes::RegisterRoutes() { + Route(eHTTPMethod::GET, "/api/grants/catalog", Perm(MANAGE), + "What can be granted: the permissions, permission groups (categories), slash commands and command groups (GM levels), each with " + "whether you may grant it ('grantable') and why not ('reason'): only what you hold yourself", + [](HTTPReply& reply, const HTTPContext& context) { + const auto commands = CurrentSlashCommands(); + nlohmann::json permissions = nlohmann::json::array(); + for (const auto& permission : Permissions::All()) { + const auto reason = Refusal(context, eKind::PERMISSION, permission.key, commands); + permissions.push_back({ {"key", permission.key}, {"title", permission.title}, {"category", permission.category}, + {"description", permission.description}, {"level", Permissions::Level(permission.key)}, {"grantable", reason.empty()}, {"reason", reason} }); + } + nlohmann::json groups = nlohmann::json::array(); + for (const auto& group : PermissionGrants::PermissionGroups()) { + const auto reason = Refusal(context, eKind::PERMISSION_GROUP, group, commands); + groups.push_back({ {"name", group}, {"permissions", PermissionGrants::GroupPermissions(group)}, {"grantable", reason.empty()}, {"reason", reason} }); + } + nlohmann::json commandList = nlohmann::json::array(); + for (const auto& command : commands) { + if (command.clientHandled) continue; + const auto reason = Refusal(context, eKind::COMMAND, command.rules.name, commands); + commandList.push_back({ {"name", command.rules.name}, {"aliases", command.aliases}, {"help", command.help}, {"level", command.rules.level}, + {"minLevel", command.rules.minLevel}, {"permission", command.rules.permission}, {"grantable", reason.empty()}, {"reason", reason} }); + } + nlohmann::json commandGroups = nlohmann::json::array(); + for (uint8_t level = 1; level <= Permissions::MAX_LEVEL; level++) { + const auto name = std::to_string(level); + const auto reason = Refusal(context, eKind::COMMAND_GROUP, name, commands); + commandGroups.push_back({ {"name", name}, {"grantable", reason.empty()}, {"reason", reason} }); + } + JsonSuccess(reply, { {"permissions", permissions}, {"permissionGroups", groups}, {"commands", commandList}, {"commandGroups", commandGroups} }); + }); + + Route(eHTTPMethod::GET, "/api/grants", 0, + "Permission grants. ?account=ID or ?character=ID: every grant of that account or character, removed and expired ones too, newest first " + "(your own without grants_manage). Neither: {active, history} for every account and character (grants_manage)", + [](HTTPReply& reply, const HTTPContext& context) { + const auto accountText = QueryValue(context.queryString, "account"); + const auto characterText = QueryValue(context.queryString, "character"); + std::optional target; + if (!accountText.empty()) { + const auto id = GeneralUtils::TryParse(accountText); + if (id) target = AccountTarget(*id); + } else if (!characterText.empty()) { + const auto id = GeneralUtils::TryParse(characterText); + if (id) target = CharacterTarget(*id); + } else { + if (!Can(context, MANAGE)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Insufficient permissions"); + Lister lister(context); + const auto now = Now(); + return JsonSuccess(reply, { {"active", lister.Rows(Database::Get()->GetRecentPermissionGrants(true, now, MAX_LIST))}, + {"history", lister.Rows(Database::Get()->GetRecentPermissionGrants(false, now, HISTORY_LENGTH))} }); + } + if (!target) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Account or character not found"); + const bool own = context.accountId != 0 && target->accountId == context.accountId; + if (!own && !Can(context, MANAGE)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Insufficient permissions"); + Lister lister(context); + JsonSuccess(reply, { {"target", { {"type", target->type}, {"id", std::to_string(target->id)}, {"accountId", target->accountId}, {"name", target->name} }}, + {"grants", lister.Rows(Database::Get()->GetPermissionGrants(target->type, target->id))}, {"canManage", Can(context, MANAGE)} }); + }); + + Route(eHTTPMethod::POST, "/api/grants", Perm(MANAGE), + "Grant (or with deny: true, take away) something for one account or character. Body: {targetType: account|character, target: ID or name, " + "kind: permission|command|permission_group|command_group, name, deny, expiresAt (Unix seconds; 0 or missing: never), note}. Only what you hold " + "yourself, on accounts you may manage (self_moderation for your own). Online players get it at once", + [](HTTPReply& reply, const HTTPContext& context) { + const auto body = ParseBody(context); + if (!body || !body->is_object()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); + const auto target = ResolveTarget(body->value("targetType", ""), body->contains("target") ? (*body)["target"] : nlohmann::json()); + if (!target) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such account or character"); + const auto kind = PermissionGrants::ParseKind(body->value("kind", "")); + if (!kind) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "kind must be permission, command, permission_group or command_group"); + const auto name = Trim(body->value("name", "")); + if (name.empty() || name.size() > MAX_NAME) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Pick what to grant"); + const bool deny = body->value("deny", false); + const auto note = Trim(body->value("note", "")); + if (note.size() > MAX_NOTE) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The note is too long"); + const auto& expiry = body->contains("expiresAt") ? (*body)["expiresAt"] : nlohmann::json(); + if (!expiry.is_null() && !expiry.is_number_integer()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "expiresAt must be Unix seconds"); + const int64_t expiresAt = expiry.is_null() ? 0 : expiry.get(); + const auto now = Now(); + if (expiresAt < 0 || (expiresAt != 0 && expiresAt <= now)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The expiry must be in the future"); + + // The rank rules, like every account tool: never a higher GM level, your own only with self_moderation + if (!AuthorizeAccountAction(context, target->accountId, reply, eAccountAction::MODERATION)) return; + const auto commands = CurrentSlashCommands(); + const auto refusal = Refusal(context, *kind, name, commands); + if (!refusal.empty()) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, refusal); + + for (const auto& existing : Database::Get()->GetPermissionGrants(target->type, target->id)) { + if (Status(existing, now) == "active" && existing.kind == PermissionGrants::KindName(*kind) && existing.name == name && existing.deny == deny) { + return JsonError(reply, eHTTPStatusCode::CONFLICT, "This " + target->type + " already has that; remove it first to change it"); + } + } + + IPermissionGrants::Grant grant; + grant.targetType = target->type; + grant.targetId = target->id; + grant.kind = std::string(PermissionGrants::KindName(*kind)); + grant.name = name; + grant.deny = deny; + grant.expiresAt = expiresAt; + grant.note = note; + grant.grantedAt = now; + grant.grantedById = context.accountId; + grant.grantedBy = context.authenticatedUser; + grant.id = Database::Get()->InsertPermissionGrant(grant); + + const auto what = PermissionGrants::Describe(*kind, name); + const auto description = std::string(deny ? "Took away " : "Granted ") + what + (deny ? " from " : " to ") + target->Describe() + + (expiresAt ? " until " + UtcTime(expiresAt) : "") + (note.empty() ? "" : ": " + note) + OwnAccountNote(context.accountId, target->accountId); + Audit(context, deny ? "deny_permission" : "grant_permission", description, target->Audit()); + Applied(*target); + JsonSuccess(reply, { {"id", grant.id}, {"message", std::string(deny ? "Took away " : "Granted ") + what} }); + }); + + Route(eHTTPMethod::POST, "/api/grants/:id/remove", Perm(MANAGE), + "Remove a grant (or deny) that is in force: only one for something you hold yourself, on an account you may manage. Online players lose it at once", + [](HTTPReply& reply, const HTTPContext& context) { + const auto id = PathId(context.path, 2); + if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid grant ID"); + const auto grant = Database::Get()->GetPermissionGrant(*id); + if (!grant) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Grant not found"); + if (Status(*grant, Now()) != "active") return JsonError(reply, eHTTPStatusCode::CONFLICT, "This grant isn't in force any more"); + const auto kind = PermissionGrants::ParseKind(grant->kind); + const auto target = TargetOf(*grant); + if (!kind || !target) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "The grant's account or character no longer exists"); + if (!AuthorizeAccountAction(context, target->accountId, reply, eAccountAction::MODERATION)) return; + const auto refusal = Refusal(context, *kind, grant->name, CurrentSlashCommands()); + if (!refusal.empty()) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, refusal); + if (!Database::Get()->RevokePermissionGrant(grant->id, context.authenticatedUser, Now())) { + return JsonError(reply, eHTTPStatusCode::CONFLICT, "This grant was already removed"); + } + const auto what = PermissionGrants::Describe(*kind, grant->name); + Audit(context, "remove_grant", std::string("Removed the ") + (grant->deny ? "deny of " : "grant of ") + what + (grant->deny ? " from " : " to ") + + target->Describe() + " (given by " + grant->grantedBy + ")" + OwnAccountNote(context.accountId, target->accountId), target->Audit()); + Applied(*target); + JsonSuccess(reply, { {"message", std::string("Removed the ") + (grant->deny ? "deny of " : "grant of ") + what} }); + }); +} diff --git a/dDashboardServer/routes/GrantRoutes.h b/dDashboardServer/routes/GrantRoutes.h new file mode 100644 index 000000000..48788f718 --- /dev/null +++ b/dDashboardServer/routes/GrantRoutes.h @@ -0,0 +1,10 @@ +#pragma once + +/** + * Permission grants (PermissionGrants.h): dashboard permissions and in-game commands given to, or taken from, one + * account or character. Needs grants_manage; nobody grants or takes away what they don't hold themselves, and only on + * accounts the rank rules let them manage. Every change is audited. + */ +namespace GrantRoutes { + void RegisterRoutes(); +} diff --git a/dDashboardServer/routes/PrometheusMetrics.cpp b/dDashboardServer/routes/PrometheusMetrics.cpp index 27ea651ae..6978f1a02 100644 --- a/dDashboardServer/routes/PrometheusMetrics.cpp +++ b/dDashboardServer/routes/PrometheusMetrics.cpp @@ -319,8 +319,8 @@ namespace { bool AccountAllowed(const HTTPContext& context) { if (!context.isAuthenticated || context.userData.contains("needs_2fa")) return false; const auto source = context.userData.find("auth_source"); - if (source != context.userData.end() && source->second == "header" && !Permissions::Allowed(context.gmLevel, "api_access")) return false; - return Permissions::Allowed(context.gmLevel, "metrics_view", context.apiKey.get()); + if (source != context.userData.end() && source->second == "header" && !Permissions::Allowed(context.gmLevel, "api_access", nullptr, context.grants.get())) return false; + return Permissions::Allowed(context.gmLevel, "metrics_view", context.apiKey.get(), context.grants.get()); } } diff --git a/dDashboardServer/routes/ReportViews.cpp b/dDashboardServer/routes/ReportViews.cpp index 9c056fb08..ce8d9acee 100644 --- a/dDashboardServer/routes/ReportViews.cpp +++ b/dDashboardServer/routes/ReportViews.cpp @@ -6,6 +6,7 @@ #include "RouteUtils.h" #include "Permissions.h" +#include "PermissionGrantsLoader.h" #include "Scheduler.h" #include "Background.h" #include "EmailService.h" @@ -185,7 +186,7 @@ namespace { std::vector deliveries; for (const auto accountId : Subscribers()) { const auto account = Database::Get()->GetAccountById(accountId); - if (account.contains("error") || account.value("banned", 0) || !Permissions::Allowed(static_cast(account.value("gm_level", 0)), "reports_view")) { + if (account.contains("error") || account.value("banned", 0) || !Permissions::Allowed(static_cast(account.value("gm_level", 0)), "reports_view", nullptr, PermissionGrants::Load(accountId).get())) { run->Log("Skipping account " + std::to_string(accountId) + ": no longer allowed to see reports"); continue; } diff --git a/dDashboardServer/routes/RouteUtils.cpp b/dDashboardServer/routes/RouteUtils.cpp index f5f613240..b565ba64b 100644 --- a/dDashboardServer/routes/RouteUtils.cpp +++ b/dDashboardServer/routes/RouteUtils.cpp @@ -81,7 +81,7 @@ namespace RouteUtils { } bool Can(const HTTPContext& context, const std::string& permission) { - return context.isAuthenticated && Permissions::Allowed(context.gmLevel, permission, context.apiKey.get()); + return context.isAuthenticated && Permissions::Allowed(context.gmLevel, permission, context.apiKey.get(), context.grants.get()); } std::optional ResolveCharacter(std::string_view text) { @@ -95,7 +95,7 @@ namespace RouteUtils { } bool CanViewCharacter(const HTTPContext& context, uint32_t ownerAccountId) { - return context.isAuthenticated && Permissions::CanViewCharacter(context.gmLevel, context.accountId, ownerAccountId, context.apiKey.get()); + return context.isAuthenticated && Permissions::CanViewCharacter(context.gmLevel, context.accountId, ownerAccountId, context.apiKey.get(), context.grants.get()); } const std::vector& GetRouteDocs() { @@ -243,7 +243,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, context.apiKey.get()) == eManageDenial::NONE; + return context.isAuthenticated && AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action, context.apiKey.get(), context.grants.get()) == eManageDenial::NONE; } nlohmann::json ManageJson(const HTTPContext& context, uint8_t targetLevel, uint32_t targetAccountId) { @@ -261,10 +261,10 @@ 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, context.apiKey.get()); + const auto denial = AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action, context.apiKey.get(), context.grants.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) { + if (context.apiKey && AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action, nullptr, context.grants.get()) == 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; @@ -288,7 +288,7 @@ namespace RouteUtils { try { data.merge_patch(context.GetUserDataJson()); data["current_page"] = page; - data["can"] = Permissions::ForLevel(context.isAuthenticated ? context.gmLevel : 0, context.apiKey.get()); + data["can"] = Permissions::ForLevel(context.isAuthenticated ? context.gmLevel : 0, context.apiKey.get(), context.isAuthenticated ? context.grants.get() : nullptr); // The account's view choices, on 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(); diff --git a/dDashboardServer/routes/SettingsRoutes.cpp b/dDashboardServer/routes/SettingsRoutes.cpp index b6aaed475..38b0f3728 100644 --- a/dDashboardServer/routes/SettingsRoutes.cpp +++ b/dDashboardServer/routes/SettingsRoutes.cpp @@ -322,6 +322,27 @@ namespace { } } +std::vector CurrentSlashCommands() { + std::vector commands; + std::vector rows; + try { + rows = Database::Get()->GetSlashCommands(); + } catch (const std::exception&) { + return commands; // no slash_commands table yet: no world has started + } + const auto levels = CommandLevelRows(); + for (auto& row : rows) { + SlashCommandNow command; + command.rules = { row.name, ResolveCommandLevel(row, levels).level, row.minLevel, row.fixed, + Permissions::Find(row.dashboardPermission) ? row.dashboardPermission : "" }; + command.aliases = std::move(row.aliases); + command.help = std::move(row.help); + command.clientHandled = row.clientHandled; + commands.push_back(std::move(command)); + } + return commands; +} + std::optional SaveSetting(const HTTPContext& context, const nlohmann::json& body, uint64_t revertOf) { std::string error; const auto change = ParseChange(body, error); @@ -455,7 +476,7 @@ void RegisterSettingsRoutes() { Route(eHTTPMethod::GET, "/api/account/permissions", 0, "What you may do: {permissions: {name: bool}}", [](HTTPReply& reply, const HTTPContext& context) { - JsonSuccess(reply, { {"gmLevel", context.gmLevel}, {"permissions", Permissions::ForLevel(context.gmLevel, context.apiKey.get())} }); + JsonSuccess(reply, { {"gmLevel", context.gmLevel}, {"permissions", Permissions::ForLevel(context.gmLevel, context.apiKey.get(), context.grants.get())} }); }); Route(eHTTPMethod::GET, "/api/permissions", Perm("permissions_manage"), "Every permission with its default and current minimum GM level, and where that comes from", diff --git a/dDashboardServer/routes/SettingsRoutes.h b/dDashboardServer/routes/SettingsRoutes.h index 3b6e69915..059d9a5e8 100644 --- a/dDashboardServer/routes/SettingsRoutes.h +++ b/dDashboardServer/routes/SettingsRoutes.h @@ -3,6 +3,9 @@ #include #include #include +#include + +#include "PermissionGrants.h" #include "json.hpp" @@ -17,3 +20,14 @@ void RegisterSettingsRoutes(); * this undoes. Returns why it was refused, if it was. */ std::optional SaveSetting(const HTTPContext& context, const nlohmann::json& body, uint64_t revertOf = 0); + +// A slash command the world servers registered, with the level it needs now (as the Permissions page shows it) +struct SlashCommandNow { + PermissionGrants::Command rules; // name, level now, floor, fixed, paired permission + std::vector aliases; + std::string help; + bool clientHandled{}; +}; + +// Every slash command the world servers registered (empty until a world has started once) +std::vector CurrentSlashCommands(); diff --git a/dDashboardServer/routes/Showcase.cpp b/dDashboardServer/routes/Showcase.cpp index c4afc300b..93bb457f9 100644 --- a/dDashboardServer/routes/Showcase.cpp +++ b/dDashboardServer/routes/Showcase.cpp @@ -60,9 +60,14 @@ namespace { JsonError(reply, eHTTPStatusCode::TOO_MANY_REQUESTS, "Too many requests, try again in a minute"); return false; } - static RequireAuthMiddleware gate(std::function([] { return Permissions::Level("showcase_view"); })); + // Signed in (and the checks every route makes), then the permission: its level or a grant + static RequireAuthMiddleware gate(0); auto copy = context; - return gate.Process(copy, reply); + if (!gate.Process(copy, reply)) return false; + if (Permissions::Allowed(context.gmLevel, "showcase_view", nullptr, context.grants.get())) return true; + if (context.path.starts_with("/api/")) JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Insufficient permissions"); + else RenderError(reply, context, eHTTPStatusCode::FORBIDDEN, "You don't have permission to open this page."); + return false; } bool Showable(const IProperty::Info& info) { diff --git a/dDashboardServer/routes/Traffic.cpp b/dDashboardServer/routes/Traffic.cpp index 3d53f39c7..f3e12fb57 100644 --- a/dDashboardServer/routes/Traffic.cpp +++ b/dDashboardServer/routes/Traffic.cpp @@ -393,7 +393,7 @@ namespace Traffic { } void RegisterRoutes() { - Game::web.RegisterWSSubscription(TOPIC, std::function([] { return Permissions::Level(PERMISSION); })); + Game::web.RegisterWSSubscription(TOPIC, std::function([] { return Permissions::Level(PERMISSION); }), PERMISSION); // The dashboard's own report stays here; the worker pool is what its deferred requests wait for Game::server->SetTrafficSink([](ServerTraffic& report) { Ingest(report); }); diff --git a/dDatabase/GameDatabase/PermissionGrantsLoader.h b/dDatabase/GameDatabase/PermissionGrantsLoader.h new file mode 100644 index 000000000..f0982a282 --- /dev/null +++ b/dDatabase/GameDatabase/PermissionGrantsLoader.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +#include "Database.h" +#include "PermissionGrants.h" + +namespace PermissionGrants { + // The grants in force now of an account and, when characterId isn't 0, one of its characters (charinfo ID) + inline std::shared_ptr Load(uint32_t accountId, int64_t characterId = 0) { + auto held = std::make_shared(); + if (accountId == 0) return held; + for (auto& row : Database::Get()->GetActivePermissionGrants(accountId, characterId, static_cast(std::time(nullptr)))) { + held->Add(row.kind, std::move(row.name), row.deny, row.expiresAt); + } + return held; + } +} diff --git a/dWeb/HTTPContext.h b/dWeb/HTTPContext.h index 7fcf99545..ad57cb4f6 100644 --- a/dWeb/HTTPContext.h +++ b/dWeb/HTTPContext.h @@ -8,6 +8,8 @@ #include "json.hpp" #include "ApiKeyScope.h" +namespace PermissionGrants { struct Held; } + /** * HTTP Request Context * @@ -38,6 +40,9 @@ struct HTTPContext { // Set when an API key authenticated the request: the key's scope, on top of what the account may do (gmLevel). // Every permission check must honour it (RouteUtils::Can and friends do). std::shared_ptr apiKey{}; + // The account's permission grants in force (PermissionGrants.h), loaded with the sign-in; every permission check + // passes them on (RouteUtils::Can and friends do). nullptr: none were loaded, the GM level alone counts. + std::shared_ptr grants{}; // Custom data for middleware to communicate std::map userData{}; diff --git a/dWeb/Web.cpp b/dWeb/Web.cpp index a58a16bab..a469202de 100644 --- a/dWeb/Web.cpp +++ b/dWeb/Web.cpp @@ -8,6 +8,7 @@ #include "JSONUtils.h" #include "HTTPContext.h" #include "IHTTPMiddleware.h" +#include "Permissions.h" #include #include #include @@ -40,17 +41,22 @@ namespace { bool apiToken{}; // connected with Authorization: Bearer (subject to the API access rule) std::chrono::steady_clock::time_point nextCheck; std::shared_ptr apiKey{}; // connected with an API key: its scope + std::shared_ptr grants{}; // the account's permission grants }; + constexpr uint8_t INTERNAL_WS_LEVEL = UINT8_MAX; + // Whether a connection may subscribe to (and receive) a subscription bool MayReceive(const WSClient& client, size_t index, uint8_t minLevel) { - if (client.level < minLevel) return false; - if (!client.apiKey) return true; const auto& permission = g_WSSubscriptionPermissions[index]; + // Guarded by a permission: its level or a grant (internal connections have every level) + const bool allowed = permission.empty() || client.level == INTERNAL_WS_LEVEL ? client.level >= minLevel + : Permissions::Allowed(client.level, permission, nullptr, client.grants.get()); + if (!allowed) return false; + if (!client.apiKey) return true; return permission.empty() ? (minLevel == 0 || client.apiKey->allPermissions) : client.apiKey->Has(permission); } std::map g_AuthenticatedWSConnections; - constexpr uint8_t INTERNAL_WS_LEVEL = UINT8_MAX; constexpr auto WS_RECHECK_INTERVAL = std::chrono::seconds(60); // Close a WebSocket whose session is no longer valid (logged out everywhere, banned, demoted below dashboard access) @@ -71,13 +77,14 @@ namespace { if (client.token.empty() || client.nextCheck > now) continue; client.nextCheck = now + WS_RECHECK_INTERVAL; auto auth = callback(client.token); - if (auth && client.apiToken && Game::web.GetWSApiAccessCallback() && !Game::web.GetWSApiAccessCallback()(auth->level)) auth.reset(); + if (auth && client.apiToken && Game::web.GetWSApiAccessCallback() && !Game::web.GetWSApiAccessCallback()(*auth)) auth.reset(); if (!auth || auth->accountId != client.accountId) { expired.push_back(connection); continue; } client.level = auth->level; client.apiKey = auth->apiKey; + client.grants = auth->grants; } for (auto* connection : expired) { LOG_DEBUG("Closing a WebSocket whose session is no longer valid"); @@ -344,7 +351,7 @@ void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_ms // Bots and scripts: an API token, like the REST API takes it (and subject to the same API access rule) const std::string token(authHeader->buf + 7, authHeader->len - 7); level = Game::web.GetWSAuthCallback()(token); - if (level && Game::web.GetWSApiAccessCallback() && !Game::web.GetWSApiAccessCallback()(level->level)) level.reset(); + if (level && Game::web.GetWSApiAccessCallback() && !Game::web.GetWSApiAccessCallback()(*level)) level.reset(); connectToken = token; apiToken = true; } else { @@ -376,7 +383,7 @@ void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_ms if (level) { mg_ws_upgrade(connection, const_cast(http_msg), NULL); g_AuthenticatedWSConnections[connection] = { level->level, level->accountId, connectToken, apiToken, - std::chrono::steady_clock::now() + WS_RECHECK_INTERVAL, level->apiKey }; + std::chrono::steady_clock::now() + WS_RECHECK_INTERVAL, level->apiKey, level->grants }; const char* connType = isInternal ? "internal" : "external"; LOG_DEBUG("Upgraded %s connection to websocket: %d.%d.%d.%d:%i", connType, MG_IPADDR_PARTS(&connection->rem.ip), connection->rem.port); } else { diff --git a/dWeb/Web.h b/dWeb/Web.h index b2bf47c33..5f5bccc5e 100644 --- a/dWeb/Web.h +++ b/dWeb/Web.h @@ -58,6 +58,8 @@ struct WSAuth { uint32_t accountId{}; // Connected with an API key: subscriptions also need their permission in its scope std::shared_ptr apiKey{}; + // The account's permission grants (PermissionGrants.h): subscriptions guarded by a permission follow them too + std::shared_ptr grants{}; }; // WebSocket authentication callback function type @@ -102,8 +104,8 @@ public: void AddGlobalMiddleware(MiddlewarePtr middleware); // Set WebSocket authentication callback for token validation void SetWSAuthCallback(WSAuthCallback callback) { wsAuthCallback = callback; } - // Whether a GM level may connect with an API token (Authorization: Bearer) rather than the browser's cookie - void SetWSApiAccessCallback(std::function callback) { wsApiAccessCallback = std::move(callback); } + // Whether an account may connect with an API token (Authorization: Bearer) rather than the browser's cookie + void SetWSApiAccessCallback(std::function callback) { wsApiAccessCallback = std::move(callback); } // Returns if the web server is enabled bool IsEnabled() const { return enabled; }; /** @@ -126,7 +128,7 @@ public: mg_mgr& GetManager() { return mgr; }; // Get WebSocket auth callback (used during WebSocket upgrade) WSAuthCallback GetWSAuthCallback() const { return wsAuthCallback; } - const std::function& GetWSApiAccessCallback() const { return wsApiAccessCallback; } + const std::function& GetWSApiAccessCallback() const { return wsApiAccessCallback; } private: // Send the answers of deferred requests that have arrived void SendDeferredReplies(); @@ -138,7 +140,7 @@ private: bool managerFreed = false; // WebSocket authentication callback WSAuthCallback wsAuthCallback = nullptr; - std::function wsApiAccessCallback = nullptr; + std::function wsApiAccessCallback = nullptr; std::vector defaultHeaders{}; }; diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt index d1be1a2ac..62707f553 100644 --- a/tests/dWebTests/CMakeLists.txt +++ b/tests/dWebTests/CMakeLists.txt @@ -14,6 +14,7 @@ set(DWEBTESTS_SOURCES "ItemTraceTests.cpp" "CronTests.cpp" "PermissionsTests.cpp" + "PermissionGrantsTests.cpp" "ApiKeyTests.cpp" "SettingsCatalogTests.cpp" "BehaviorXmlTests.cpp" diff --git a/tests/dWebTests/PermissionGrantsTests.cpp b/tests/dWebTests/PermissionGrantsTests.cpp new file mode 100644 index 000000000..fc05b3c58 --- /dev/null +++ b/tests/dWebTests/PermissionGrantsTests.cpp @@ -0,0 +1,204 @@ +#include + +#include +#include + +#include "AccountRules.h" +#include "ApiKeyScope.h" +#include "PermissionGrants.h" +#include "Permissions.h" + +using PermissionGrants::eKind; +using PermissionGrants::Held; + +namespace { + constexpr int64_t NOW = 1800000000; + + int64_t Now() { return static_cast(std::time(nullptr)); } + + Held With(std::initializer_list rules) { + Held held; + held.rules = rules; + return held; + } + + PermissionGrants::Rule Allow(eKind kind, std::string name, int64_t expiresAt = 0) { return { kind, std::move(name), false, expiresAt }; } + PermissionGrants::Rule Deny(eKind kind, std::string name, int64_t expiresAt = 0) { return { kind, std::move(name), true, expiresAt }; } + + // Slash commands as the world servers describe them (levels as in the code by default) + const PermissionGrants::Command SPAWN{ "spawn", 8, 1, false, "" }; + const PermissionGrants::Command KICK{ "kick", 2, 1, false, "accounts_kick" }; + const PermissionGrants::Command EXECUTE{ "execute", 8, 8, false, "" }; + const PermissionGrants::Command PVP{ "pvp", 0, 0, false, "" }; + const PermissionGrants::Command EMOTE{ "dance", 0, 0, true, "" }; +} + +TEST(PermissionGrantsTests, KindNamesRoundTrip) { + for (const auto kind : { eKind::PERMISSION, eKind::COMMAND, eKind::PERMISSION_GROUP, eKind::COMMAND_GROUP }) { + EXPECT_EQ(PermissionGrants::ParseKind(PermissionGrants::KindName(kind)), kind); + } + EXPECT_FALSE(PermissionGrants::ParseKind("role").has_value()); + Held held; + held.Add("permission", "accounts_ban", false, 0); + held.Add("role", "admin", false, 0); // a kind this version doesn't know counts for nothing + ASSERT_EQ(held.rules.size(), 1u); + EXPECT_EQ(held.rules[0].kind, eKind::PERMISSION); +} + +TEST(PermissionGrantsTests, GrantAllowsWhatTheLevelDoesNot) { + const auto held = With({ Allow(eKind::PERMISSION, "accounts_ban") }); + EXPECT_FALSE(Permissions::Allowed(2, "accounts_ban", nullptr)); + EXPECT_TRUE(Permissions::Allowed(2, "accounts_ban", nullptr, &held)); + EXPECT_TRUE(Permissions::Allowed(0, "accounts_ban", nullptr, &held)); // even a player: it's given to them by name + EXPECT_FALSE(Permissions::Allowed(2, "accounts_delete", nullptr, &held)); + // An API key still only does what its scope names + ApiKeys::Scope scope; + scope.permissions = { "accounts_kick" }; + EXPECT_FALSE(Permissions::Allowed(2, "accounts_ban", &scope, &held)); +} + +TEST(PermissionGrantsTests, DenyTakesAwayWhatTheLevelAllowsExceptFromOperators) { + const auto held = With({ Deny(eKind::PERMISSION, "accounts_kick") }); + EXPECT_TRUE(Permissions::Allowed(5, "accounts_kick", nullptr)); + EXPECT_FALSE(Permissions::Allowed(5, "accounts_kick", nullptr, &held)); + EXPECT_FALSE(Permissions::Allowed(8, "accounts_kick", nullptr, &held)); + // GM 9 can't be locked out + EXPECT_TRUE(Permissions::Allowed(9, "accounts_kick", nullptr, &held)); + // A deny beats a grant of the same thing + const auto both = With({ Allow(eKind::PERMISSION, "accounts_ban"), Deny(eKind::PERMISSION_GROUP, "Accounts") }); + EXPECT_FALSE(Permissions::Allowed(2, "accounts_ban", nullptr, &both)); + EXPECT_FALSE(Permissions::Allowed(5, "accounts_kick", nullptr, &both)); + EXPECT_TRUE(Permissions::Allowed(5, "moderate_names", nullptr, &both)); // not in the group +} + +TEST(PermissionGrantsTests, ExpiredGrantsCountForNothing) { + const auto now = Now(); + const auto expired = With({ Allow(eKind::PERMISSION, "accounts_ban", now - 1), Deny(eKind::PERMISSION, "accounts_kick", now - 1) }); + EXPECT_FALSE(Permissions::Allowed(2, "accounts_ban", nullptr, &expired)); + EXPECT_TRUE(Permissions::Allowed(2, "accounts_kick", nullptr, &expired)); + const auto running = With({ Allow(eKind::PERMISSION, "accounts_ban", now + 3600) }); + EXPECT_TRUE(Permissions::Allowed(2, "accounts_ban", nullptr, &running)); + EXPECT_TRUE(PermissionGrants::InForce({ eKind::PERMISSION, "x", false, 0 }, NOW)); + EXPECT_TRUE(PermissionGrants::InForce({ eKind::PERMISSION, "x", false, NOW + 1 }, NOW)); + EXPECT_FALSE(PermissionGrants::InForce({ eKind::PERMISSION, "x", false, NOW }, NOW)); +} + +TEST(PermissionGrantsTests, GroupsCoverTheirCategoryButNeverLockedPermissions) { + const auto accounts = With({ Allow(eKind::PERMISSION_GROUP, "Accounts") }); + EXPECT_TRUE(Permissions::Allowed(1, "accounts_ban", nullptr, &accounts)); + EXPECT_TRUE(Permissions::Allowed(1, "accounts_delete", nullptr, &accounts)); + EXPECT_FALSE(Permissions::Allowed(1, "backups", nullptr, &accounts)); + // settings and permissions_manage stay GM 9 only, whatever is granted + const auto server = With({ Allow(eKind::PERMISSION_GROUP, "Server"), Allow(eKind::PERMISSION, "permissions_manage"), Allow(eKind::PERMISSION, "settings") }); + EXPECT_TRUE(Permissions::Allowed(3, "backups", nullptr, &server)); + EXPECT_FALSE(Permissions::Allowed(8, "permissions_manage", nullptr, &server)); + EXPECT_FALSE(Permissions::Allowed(8, "settings", nullptr, &server)); + const auto keys = PermissionGrants::GroupPermissions("Server"); + EXPECT_EQ(std::ranges::count(keys, "permissions_manage"), 0); + EXPECT_EQ(std::ranges::count(keys, "backups"), 1); + EXPECT_TRUE(PermissionGrants::GroupPermissions("No such category").empty()); +} + +TEST(PermissionGrantsTests, ForLevelAndSelfRulesFollowGrants) { + const auto held = With({ Allow(eKind::PERMISSION, "accounts_ban"), Deny(eKind::PERMISSION, "accounts_view") }); + const auto can = Permissions::ForLevel(3, nullptr, &held); + EXPECT_TRUE(can["accounts_ban"].get()); + EXPECT_FALSE(can["accounts_view"].get()); + EXPECT_TRUE(can["accounts_kick"].get()); + // self_items (GM 9 by default) granted to a GM 8 lets them use item tools on their own characters + using AccountRules::eAccountAction; + using AccountRules::eManageDenial; + const auto selfItems = With({ Allow(eKind::PERMISSION, "self_items") }); + EXPECT_EQ(AccountRules::ManageDenialNow(8, 7, 8, 7, eAccountAction::ITEMS), eManageDenial::SELF); + EXPECT_EQ(AccountRules::ManageDenialNow(8, 7, 8, 7, eAccountAction::ITEMS, nullptr, &selfItems), eManageDenial::NONE); + // A grant never lets anyone act on a higher GM level + EXPECT_EQ(AccountRules::ManageDenialNow(8, 7, 9, 6, eAccountAction::ITEMS, nullptr, &selfItems), eManageDenial::HIGHER_RANK); +} + +TEST(PermissionGrantsTests, CommandsFollowTheirGrantsGroupsAndPairedPermission) { + using PermissionGrants::MayUseCommand; + EXPECT_FALSE(MayUseCommand(3, 3, SPAWN, nullptr, NOW)); + EXPECT_TRUE(MayUseCommand(8, 8, SPAWN, nullptr, NOW)); + + const auto spawn = With({ Allow(eKind::COMMAND, "spawn") }); + EXPECT_TRUE(MayUseCommand(3, 3, SPAWN, &spawn, NOW)); + EXPECT_TRUE(MayUseCommand(0, 0, SPAWN, &spawn, NOW)); + EXPECT_FALSE(MayUseCommand(1, 1, KICK, &spawn, NOW)); // a grant of one command gives only that one + EXPECT_TRUE(MayUseCommand(3, 3, KICK, nullptr, NOW)); + + // Every command up to GM 8 + const auto group = With({ Allow(eKind::COMMAND_GROUP, "8") }); + EXPECT_TRUE(MayUseCommand(1, 1, SPAWN, &group, NOW)); + const auto lowGroup = With({ Allow(eKind::COMMAND_GROUP, "5") }); + EXPECT_FALSE(MayUseCommand(1, 1, SPAWN, &lowGroup, NOW)); + EXPECT_TRUE(MayUseCommand(1, 1, KICK, &lowGroup, NOW)); + + // A paired command follows its permission's grants and denies too + const auto kickPermission = With({ Allow(eKind::PERMISSION, "accounts_kick") }); + EXPECT_TRUE(MayUseCommand(0, 0, KICK, &kickPermission, NOW)); + const auto noKick = With({ Deny(eKind::PERMISSION_GROUP, "Accounts") }); + EXPECT_FALSE(MayUseCommand(5, 5, KICK, &noKick, NOW)); + EXPECT_TRUE(MayUseCommand(5, 9, KICK, &noKick, NOW)); // a GM 9 account playing at GM 5 +} + +TEST(PermissionGrantsTests, CommandDeniesExpiryFloorsAndFixedCommands) { + using PermissionGrants::MayUseCommand; + // A deny keeps a player from a player command, and a staff member from one their level allows + const auto noPvp = With({ Deny(eKind::COMMAND, "pvp"), Deny(eKind::COMMAND, "spawn") }); + EXPECT_FALSE(MayUseCommand(0, 0, PVP, &noPvp, NOW)); + EXPECT_FALSE(MayUseCommand(8, 8, SPAWN, &noPvp, NOW)); + EXPECT_TRUE(MayUseCommand(9, 9, SPAWN, &noPvp, NOW)); + // A deny beats a grant + const auto both = With({ Allow(eKind::COMMAND_GROUP, "9"), Deny(eKind::COMMAND, "spawn") }); + EXPECT_FALSE(MayUseCommand(1, 1, SPAWN, &both, NOW)); + // Past its expiry a grant does nothing + const auto expired = With({ Allow(eKind::COMMAND, "spawn", NOW - 10) }); + EXPECT_FALSE(MayUseCommand(3, 3, SPAWN, &expired, NOW)); + const auto running = With({ Allow(eKind::COMMAND, "spawn", NOW + 10) }); + EXPECT_TRUE(MayUseCommand(3, 3, SPAWN, &running, NOW)); + // /execute never goes below GM 8, grants or not + const auto execute = With({ Allow(eKind::COMMAND, "execute"), Allow(eKind::COMMAND_GROUP, "9") }); + EXPECT_FALSE(MayUseCommand(7, 7, EXECUTE, &execute, NOW)); + EXPECT_TRUE(MayUseCommand(8, 8, EXECUTE, &execute, NOW)); + // Commands the client handles keep their fixed level + const auto emote = With({ Deny(eKind::COMMAND, "dance") }); + EXPECT_TRUE(MayUseCommand(0, 0, EMOTE, &emote, NOW)); +} + +TEST(PermissionGrantsTests, NobodyGrantsWhatTheyDoNotHold) { + const auto commands = std::vector{ SPAWN, KICK, EXECUTE, EMOTE }; + const auto find = [&commands](const std::string& name) -> std::optional { + for (const auto& command : commands) if (command.name == name) return command; + return std::nullopt; + }; + // A GM 5 moderator with grants_manage: holds what GM 5 allows + const uint8_t level = 5; + const auto holds = [](const std::string& key) { return Permissions::Allowed(5, key, nullptr); }; + const auto uses = [](const PermissionGrants::Command& command) { return PermissionGrants::MayUseCommand(5, 5, command, nullptr, NOW); }; + const auto refusal = [&](eKind kind, const std::string& name) { return PermissionGrants::Refusal(kind, name, level, holds, find, uses); }; + + EXPECT_EQ(refusal(eKind::PERMISSION, "accounts_kick"), ""); + EXPECT_NE(refusal(eKind::PERMISSION, "accounts_delete"), ""); // GM 9 + EXPECT_NE(refusal(eKind::PERMISSION, "permissions_manage"), ""); // locked + EXPECT_NE(refusal(eKind::PERMISSION, "no_such_permission"), ""); + EXPECT_EQ(refusal(eKind::PERMISSION_GROUP, "Players"), ""); + EXPECT_NE(refusal(eKind::PERMISSION_GROUP, "Accounts"), ""); // has accounts_delete + EXPECT_NE(refusal(eKind::PERMISSION_GROUP, "Nothing"), ""); + EXPECT_EQ(refusal(eKind::COMMAND, "kick"), ""); + EXPECT_NE(refusal(eKind::COMMAND, "spawn"), ""); // GM 8 + EXPECT_NE(refusal(eKind::COMMAND, "execute"), ""); // floor above GM 1 + EXPECT_NE(refusal(eKind::COMMAND, "dance"), ""); // fixed + EXPECT_NE(refusal(eKind::COMMAND, "nothing"), ""); + EXPECT_EQ(refusal(eKind::COMMAND_GROUP, "5"), ""); + EXPECT_NE(refusal(eKind::COMMAND_GROUP, "6"), ""); + EXPECT_NE(refusal(eKind::COMMAND_GROUP, "0"), ""); + EXPECT_NE(refusal(eKind::COMMAND_GROUP, "10"), ""); + + // What a grant gave the grantor counts as held (and a deny takes it away) + const auto spawn = With({ Allow(eKind::COMMAND, "spawn"), Deny(eKind::PERMISSION, "accounts_kick") }); + const auto holdsWithGrants = [&spawn](const std::string& key) { return Permissions::Allowed(5, key, nullptr, &spawn); }; + const auto usesWithGrants = [&spawn](const PermissionGrants::Command& command) { return PermissionGrants::MayUseCommand(5, 5, command, &spawn, NOW); }; + EXPECT_EQ(PermissionGrants::Refusal(eKind::COMMAND, "spawn", level, holdsWithGrants, find, usesWithGrants), ""); + EXPECT_NE(PermissionGrants::Refusal(eKind::PERMISSION, "accounts_kick", level, holdsWithGrants, find, usesWithGrants), ""); + EXPECT_NE(PermissionGrants::Refusal(eKind::COMMAND, "kick", level, holdsWithGrants, find, usesWithGrants), ""); // paired: denied too +}