mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -5,6 +5,7 @@ set(DASHBOARDROUTES_SOURCES
|
||||
"WSRoutes.cpp"
|
||||
"AuthRoutes.cpp"
|
||||
"ApiKeyRoutes.cpp"
|
||||
"GrantRoutes.cpp"
|
||||
"RouteUtils.cpp"
|
||||
"PlayerActions.cpp"
|
||||
"AccountRoutes.cpp"
|
||||
|
||||
317
dDashboardServer/routes/GrantRoutes.cpp
Normal file
317
dDashboardServer/routes/GrantRoutes.cpp
Normal 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} });
|
||||
});
|
||||
}
|
||||
10
dDashboardServer/routes/GrantRoutes.h
Normal file
10
dDashboardServer/routes/GrantRoutes.h
Normal 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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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); });
|
||||
|
||||
Reference in New Issue
Block a user