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 +}