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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 01:04:30 -05:00
parent c575eba4e7
commit 821b7c8767
29 changed files with 957 additions and 62 deletions

View File

@@ -4,6 +4,7 @@
#include <string>
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);
}

View File

@@ -33,6 +33,7 @@ set(DCOMMON_SOURCES
"ZoneScenes.cpp"
"Process.cpp"
"Permissions.cpp"
"PermissionGrants.cpp"
"NifFile.cpp"
)

View File

@@ -0,0 +1,151 @@
#include "PermissionGrants.h"
#include <algorithm>
#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<eKind> 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<std::string> PermissionGroups() {
std::vector<std::string> 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<std::string> GroupPermissions(std::string_view category) {
std::vector<std::string> keys;
for (const auto& permission : Permissions::All()) {
if (permission.category == category && !permission.locked) keys.push_back(permission.key);
}
return keys;
}
std::optional<uint8_t> CommandGroupLevel(std::string_view name) {
if (name.size() != 1 || name[0] < '1' || name[0] > '9') return std::nullopt;
return static_cast<uint8_t>(name[0] - '0');
}
std::string Refusal(eKind kind, const std::string& name, uint8_t grantorLevel,
const std::function<bool(const std::string&)>& holdsPermission,
const std::function<std::optional<Command>(const std::string&)>& findCommand,
const std::function<bool(const Command&)>& 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;
}
}
}

110
dCommon/PermissionGrants.h Normal file
View File

@@ -0,0 +1,110 @@
#pragma once
#include <cstdint>
#include <functional>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
/**
* 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<eKind> 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<Rule> 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<std::string> PermissionGroups();
// The keys a permission group covers (never the locked permissions); empty: no such group
std::vector<std::string> GroupPermissions(std::string_view category);
// A command group's GM level (1-9); nullopt: not a command group name
std::optional<uint8_t> 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<bool(const std::string&)>& holdsPermission,
const std::function<std::optional<Command>(const std::string&)>& findCommand,
const std::function<bool(const Command&)>& holdsCommand);
// "the accounts_kick permission", "/spawn", "every Accounts permission", "every command up to GM 3"
std::string Describe(eKind kind, const std::string& name);
}

View File

@@ -1,6 +1,9 @@
#include "Permissions.h"
#include "AccountRules.h"
#include "ApiKeyScope.h"
#include "PermissionGrants.h"
#include <ctime>
#include <map>
@@ -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<int64_t>(std::time(nullptr))), gmLevel);
}
return allowed && (!scope || scope->Has(key));
}
std::set<std::string> NotGrantable(uint8_t gmLevel, const std::set<std::string>& requested) {
std::set<std::string> NotGrantable(uint8_t gmLevel, const std::set<std::string>& requested, const PermissionGrants::Held* grants) {
std::set<std::string> 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));

View File

@@ -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<std::string> NotGrantable(uint8_t gmLevel, const std::set<std::string>& requested);
std::set<std::string> NotGrantable(uint8_t gmLevel, const std::set<std::string>& 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);

View File

@@ -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 <fstream>
@@ -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();

View File

@@ -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;

View File

@@ -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<bool(uint8_t)> g_ApiAccessAllowed;
std::function<bool(const HTTPContext&)> g_ApiAccessAllowed;
std::function<void(const HTTPContext&, HTTPReply&)> g_ForbiddenPage;
std::function<void(const HTTPContext&, const std::string&)> g_ApiKeyDenied;
@@ -34,7 +35,7 @@ namespace {
}
}
void RequireAuthMiddleware::SetApiAccessCheck(std::function<bool(uint8_t gmLevel)> check) {
void RequireAuthMiddleware::SetApiAccessCheck(std::function<bool(const HTTPContext& context)> 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());

View File

@@ -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<bool(uint8_t gmLevel)> 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<bool(const HTTPContext& context)> 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<void(const HTTPContext& context, HTTPReply& reply)> render);

View File

@@ -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<OpenApi::Route> 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"));

View File

@@ -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<std::string> permissions;
ApiKeys::ParsePermissions(key.permissions, all, permissions);
// What the key names that its owner can't do any more (a demotion or a changed permission): it doesn't work
nlohmann::json lost = nlohmann::json::array();
for (const auto& permission : permissions) if (!Permissions::Allowed(ownerLevel, permission)) 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<uint8_t>(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<std::string>());
if (permissions.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Pick at least one permission");
// Staff can't hand a key more than they have
const auto refused = Permissions::NotGrantable(context.gmLevel, permissions);
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 {

View File

@@ -225,7 +225,7 @@ void RegisterAuthRoutes() {
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(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") {

View File

@@ -5,6 +5,7 @@ set(DASHBOARDROUTES_SOURCES
"WSRoutes.cpp"
"AuthRoutes.cpp"
"ApiKeyRoutes.cpp"
"GrantRoutes.cpp"
"RouteUtils.cpp"
"PlayerActions.cpp"
"AccountRoutes.cpp"

View File

@@ -0,0 +1,317 @@
#include "GrantRoutes.h"
#include <ctime>
#include <map>
#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<int64_t>(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<std::time_t>(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<Target> 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<Target> 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<Target> ResolveTarget(const std::string& type, const nlohmann::json& value) {
std::string text = value.is_string() ? Trim(value.get<std::string>()) : value.is_number_integer() ? std::to_string(value.get<int64_t>()) : "";
if (text.empty()) return std::nullopt;
if (type == PermissionGrants::ACCOUNT) {
if (const auto id = GeneralUtils::TryParse<uint32_t>(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<Target> TargetOf(const IPermissionGrants::Grant& grant) {
if (grant.targetType == PermissionGrants::ACCOUNT) return AccountTarget(static_cast<uint32_t>(grant.targetId));
return CharacterTarget(grant.targetId);
}
std::optional<PermissionGrants::Command> FindCommand(const std::vector<SlashCommandNow>& 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<SlashCommandNow>& 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<IPermissionGrants::Grant>& grants) {
nlohmann::json rows = nlohmann::json::array();
for (const auto& grant : grants) rows.push_back(Row(grant));
return rows;
}
private:
const std::optional<Target>& 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<uint8_t>(account.value("gm_level", 0)), accountId, eAccountAction::MODERATION);
it = manageable.emplace(accountId, may).first;
}
return it->second;
}
const HTTPContext& context;
std::vector<SlashCommandNow> commands;
int64_t now;
std::map<std::string, std::optional<Target>> targets;
std::map<uint32_t, bool> 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> target;
if (!accountText.empty()) {
const auto id = GeneralUtils::TryParse<uint32_t>(accountText);
if (id) target = AccountTarget(*id);
} else if (!characterText.empty()) {
const auto id = GeneralUtils::TryParse<LWOOBJID>(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<int64_t>();
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<uint64_t>(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} });
});
}

View File

@@ -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();
}

View File

@@ -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());
}
}

View File

@@ -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<Delivery> 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<uint8_t>(account.value("gm_level", 0)), "reports_view")) {
if (account.contains("error") || account.value("banned", 0) || !Permissions::Allowed(static_cast<uint8_t>(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;
}

View File

@@ -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<LWOOBJID> 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<RouteDoc>& 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 <body> 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();

View File

@@ -322,6 +322,27 @@ namespace {
}
}
std::vector<SlashCommandNow> CurrentSlashCommands() {
std::vector<SlashCommandNow> commands;
std::vector<ISlashCommands::SlashCommand> 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<std::string> 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",

View File

@@ -3,6 +3,9 @@
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "PermissionGrants.h"
#include "json.hpp"
@@ -17,3 +20,14 @@ void RegisterSettingsRoutes();
* this undoes. Returns why it was refused, if it was.
*/
std::optional<std::string> 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<std::string> aliases;
std::string help;
bool clientHandled{};
};
// Every slash command the world servers registered (empty until a world has started once)
std::vector<SlashCommandNow> CurrentSlashCommands();

View File

@@ -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<uint8_t()>([] { 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) {

View File

@@ -393,7 +393,7 @@ namespace Traffic {
}
void RegisterRoutes() {
Game::web.RegisterWSSubscription(TOPIC, std::function<uint8_t()>([] { return Permissions::Level(PERMISSION); }));
Game::web.RegisterWSSubscription(TOPIC, std::function<uint8_t()>([] { 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); });

View File

@@ -0,0 +1,19 @@
#pragma once
#include <ctime>
#include <memory>
#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<const Held> Load(uint32_t accountId, int64_t characterId = 0) {
auto held = std::make_shared<Held>();
if (accountId == 0) return held;
for (auto& row : Database::Get()->GetActivePermissionGrants(accountId, characterId, static_cast<int64_t>(std::time(nullptr)))) {
held->Add(row.kind, std::move(row.name), row.deny, row.expiresAt);
}
return held;
}
}

View File

@@ -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<const ApiKeys::Scope> 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<const PermissionGrants::Held> grants{};
// Custom data for middleware to communicate
std::map<std::string, std::string> userData{};

View File

@@ -8,6 +8,7 @@
#include "JSONUtils.h"
#include "HTTPContext.h"
#include "IHTTPMiddleware.h"
#include "Permissions.h"
#include <ranges>
#include <set>
#include <vector>
@@ -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<const ApiKeys::Scope> apiKey{}; // connected with an API key: its scope
std::shared_ptr<const PermissionGrants::Held> 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<mg_connection*, WSClient> 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<mg_http_message*>(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 {

View File

@@ -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<const ApiKeys::Scope> apiKey{};
// The account's permission grants (PermissionGrants.h): subscriptions guarded by a permission follow them too
std::shared_ptr<const PermissionGrants::Held> 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<bool(uint8_t)> 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<bool(const WSAuth&)> 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<bool(uint8_t)>& GetWSApiAccessCallback() const { return wsApiAccessCallback; }
const std::function<bool(const WSAuth&)>& 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<bool(uint8_t)> wsApiAccessCallback = nullptr;
std::function<bool(const WSAuth&)> wsApiAccessCallback = nullptr;
std::vector<std::string> defaultHeaders{};
};

View File

@@ -14,6 +14,7 @@ set(DWEBTESTS_SOURCES
"ItemTraceTests.cpp"
"CronTests.cpp"
"PermissionsTests.cpp"
"PermissionGrantsTests.cpp"
"ApiKeyTests.cpp"
"SettingsCatalogTests.cpp"
"BehaviorXmlTests.cpp"

View File

@@ -0,0 +1,204 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <ctime>
#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<int64_t>(std::time(nullptr)); }
Held With(std::initializer_list<PermissionGrants::Rule> 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<bool>());
EXPECT_FALSE(can["accounts_view"].get<bool>());
EXPECT_TRUE(can["accounts_kick"].get<bool>());
// 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<PermissionGrants::Command>{ SPAWN, KICK, EXECUTE, EMOTE };
const auto find = [&commands](const std::string& name) -> std::optional<PermissionGrants::Command> {
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
}