mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(permissions): API key scopes that narrow permission and rank checks
A key's effective permission is its scope AND its owner's current permission. Scoped variants of Allowed, CanViewCharacter, ForLevel and ManageDenialNow; keys need self_* and manage_equal_rank in their scope to act on their owner or equal ranks, even for GM 9 owners. Adds the api_keys_manage permission and NotGrantable for key creation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace ApiKeys { struct Scope; }
|
||||
|
||||
/**
|
||||
* Who staff may use their tools on: the self and rank rules shared by the dashboard (RouteUtils) and the in-game slash
|
||||
* commands (SlashCommandHandler). The functions here are pure; Permissions.h decides the self_* and manage_equal_rank
|
||||
@@ -93,4 +95,20 @@ namespace AccountRules {
|
||||
* Used by the dashboard and the world servers alike.
|
||||
*/
|
||||
eManageDenial ManageDenialNow(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, eAccountAction action);
|
||||
|
||||
/**
|
||||
* The self and rank rules for an API key, on top of what its owner may do (ownerDenial, from ManageDenial). The key
|
||||
* needs the self_* permission in its scope to act on its owner's own account, and manage_equal_rank to act on an
|
||||
* account at (or, for GM 9, also at) the owner's level - even when the owner is GM 9, who needs neither.
|
||||
*/
|
||||
inline eManageDenial ScopedManageDenial(eManageDenial ownerDenial, uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId,
|
||||
bool scopeSelf, bool scopeEqual) {
|
||||
if (ownerDenial != eManageDenial::NONE) return ownerDenial;
|
||||
if (actorAccountId != 0 && actorAccountId == targetAccountId) return scopeSelf ? eManageDenial::NONE : eManageDenial::SELF;
|
||||
if (targetLevel >= actorLevel && !scopeEqual) return eManageDenial::EQUAL_RANK;
|
||||
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);
|
||||
}
|
||||
|
||||
90
dCommon/ApiKeyScope.h
Normal file
90
dCommon/ApiKeyScope.h
Normal file
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* What a dashboard API key may do. A key never does more than the account that owns it: every check is the owner's
|
||||
* permission right now (their GM level, the self and rank rules) AND the key's scope. Demoting or banning the owner
|
||||
* narrows or stops their keys at once, since the owner is looked up on every request.
|
||||
*/
|
||||
namespace ApiKeys {
|
||||
// Keys are "dlk_" followed by 64 hex characters; the dashboard's other bearer tokens (JWTs) start with "eyJ"
|
||||
constexpr std::string_view TOKEN_PREFIX = "dlk_";
|
||||
// Stored in place of a permission list: every permission the owner has, whatever they are at the time
|
||||
constexpr std::string_view ALL_PERMISSIONS = "*";
|
||||
|
||||
struct Scope {
|
||||
uint64_t keyId{};
|
||||
std::string name;
|
||||
bool allPermissions{};
|
||||
std::set<std::string> permissions;
|
||||
bool readOnly{}; // GET, HEAD and OPTIONS only
|
||||
|
||||
// Whether the key's scope names a permission (the owner must still have it)
|
||||
bool Has(const std::string& permission) const { return allPermissions || permissions.contains(permission); }
|
||||
};
|
||||
|
||||
// "*" or a comma-separated list, as stored in the database
|
||||
inline std::string JoinPermissions(bool all, const std::set<std::string>& permissions) {
|
||||
if (all) return std::string(ALL_PERMISSIONS);
|
||||
std::string out;
|
||||
for (const auto& permission : permissions) {
|
||||
if (!out.empty()) out += ',';
|
||||
out += permission;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
inline void ParsePermissions(std::string_view text, bool& all, std::set<std::string>& permissions) {
|
||||
all = text == ALL_PERMISSIONS;
|
||||
permissions.clear();
|
||||
if (all) return;
|
||||
size_t start = 0;
|
||||
while (start <= text.size()) {
|
||||
const auto end = std::min(text.find(',', start), text.size());
|
||||
if (end > start) permissions.emplace(text.substr(start, end - start));
|
||||
start = end + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Comma-separated values (IP addresses, path prefixes), trimmed, empty entries dropped
|
||||
inline std::vector<std::string> SplitList(std::string_view text) {
|
||||
std::vector<std::string> out;
|
||||
size_t start = 0;
|
||||
while (start <= text.size()) {
|
||||
const auto end = std::min(text.find(',', start), text.size());
|
||||
auto item = text.substr(start, end - start);
|
||||
while (!item.empty() && (item.front() == ' ' || item.front() == '\t')) item.remove_prefix(1);
|
||||
while (!item.empty() && (item.back() == ' ' || item.back() == '\t')) item.remove_suffix(1);
|
||||
if (!item.empty()) out.emplace_back(item);
|
||||
start = end + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a client address matches a key's allowed list: an exact address, or a prefix ending in '.' or ':'
|
||||
* ("10.0.0." matches 10.0.0.x). An empty list allows every address.
|
||||
*/
|
||||
inline bool AddressAllowed(const std::vector<std::string>& allowed, std::string_view address) {
|
||||
if (allowed.empty()) return true;
|
||||
for (const auto& entry : allowed) {
|
||||
if (entry == address) return true;
|
||||
if ((entry.back() == '.' || entry.back() == ':') && address.starts_with(entry)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Whether a (lowercased) request path is inside a key's allowed path prefixes. An empty list allows every path.
|
||||
inline bool PathAllowed(const std::vector<std::string>& prefixes, std::string_view path) {
|
||||
if (prefixes.empty()) return true;
|
||||
for (const auto& prefix : prefixes) {
|
||||
if (path.starts_with(prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "Permissions.h"
|
||||
#include "AccountRules.h"
|
||||
#include "ApiKeyScope.h"
|
||||
|
||||
#include <map>
|
||||
|
||||
@@ -18,7 +19,7 @@ namespace {
|
||||
{ "showcase_view", "Players", "Property showcase", "Browse other players' approved public properties and walk around them in 3D (setting showcase_public opens it to everyone)", 0, false, Permissions::PLAYER_LEVEL },
|
||||
{ "own_strikes", "Players", "Their strikes", "See the strikes on their own account and why they were given", 0, false, Permissions::PLAYER_LEVEL },
|
||||
{ "challenges_view", "Players", "Community challenges", "See the community challenges, how far along they are and what their own characters added", 0, false, Permissions::PLAYER_LEVEL },
|
||||
{ "api_access", "Players", "API access", "Make API tokens and use the dashboard's API with them. Each request still needs the permission for what it does", 0, false, Permissions::PLAYER_LEVEL },
|
||||
{ "api_access", "Players", "API access", "Make API keys and use the dashboard's API with them. A key only does what its owner may, narrowed to the permissions chosen for it", 0, false, Permissions::PLAYER_LEVEL },
|
||||
|
||||
{ "accounts_view", "Accounts", "View accounts", "The accounts list and other people's account pages", 1 },
|
||||
{ "accounts_notes", "Accounts", "Moderation history", "See and add notes and warnings on accounts (warnings can be sent to the player)", 2 },
|
||||
@@ -30,6 +31,7 @@ namespace {
|
||||
{ "accounts_manage", "Accounts", "Manage accounts", "Create accounts, change their email or password, reset their two-factor login", 8 },
|
||||
{ "accounts_gm_level", "Accounts", "Set GM levels", "Change GM levels (never to your own level or above, unless GM 9; their own only with self_moderation, and only lower)", 8 },
|
||||
{ "accounts_delete", "Accounts", "Delete accounts", "Permanently delete an account and its characters", 9 },
|
||||
{ "api_keys_manage", "Accounts", "Other accounts' API keys", "See and revoke other accounts' API keys (the rank rules apply; nobody can make keys for someone else)", 8 },
|
||||
// Who staff may use their tools on. Each needs the tool's own permission as well; GM 9 may always act on anyone,
|
||||
// and nobody below GM 9 can ever act on a higher GM level or raise their own
|
||||
{ "manage_equal_rank", "Accounts", "Act on their own rank", "Use their account and character tools on other accounts with the same GM level as their own (never on a higher one)", 9 },
|
||||
@@ -151,14 +153,26 @@ namespace Permissions {
|
||||
return gmLevel >= Level(key);
|
||||
}
|
||||
|
||||
bool CanViewCharacter(uint8_t gmLevel, uint32_t viewerAccountId, uint32_t ownerAccountId) {
|
||||
const bool own = viewerAccountId != 0 && viewerAccountId == ownerAccountId;
|
||||
return Allowed(gmLevel, "characters_view") || (own && Allowed(gmLevel, "own_characters"));
|
||||
bool Allowed(uint8_t gmLevel, const std::string& key, const ApiKeys::Scope* scope) {
|
||||
return Allowed(gmLevel, key) && (!scope || scope->Has(key));
|
||||
}
|
||||
|
||||
nlohmann::json ForLevel(uint8_t gmLevel) {
|
||||
std::set<std::string> NotGrantable(uint8_t gmLevel, const std::set<std::string>& requested) {
|
||||
std::set<std::string> refused;
|
||||
for (const auto& permission : requested) {
|
||||
if (!Find(permission) || !Allowed(gmLevel, permission)) refused.insert(permission);
|
||||
}
|
||||
return refused;
|
||||
}
|
||||
|
||||
bool CanViewCharacter(uint8_t gmLevel, uint32_t viewerAccountId, uint32_t ownerAccountId, const ApiKeys::Scope* scope) {
|
||||
const bool own = viewerAccountId != 0 && viewerAccountId == ownerAccountId;
|
||||
return Allowed(gmLevel, "characters_view", scope) || (own && Allowed(gmLevel, "own_characters", scope));
|
||||
}
|
||||
|
||||
nlohmann::json ForLevel(uint8_t gmLevel, const ApiKeys::Scope* scope) {
|
||||
nlohmann::json can = nlohmann::json::object();
|
||||
for (const auto& permission : PERMISSIONS) can[permission.key] = Allowed(gmLevel, permission.key);
|
||||
for (const auto& permission : PERMISSIONS) can[permission.key] = Allowed(gmLevel, permission.key, scope);
|
||||
return can;
|
||||
}
|
||||
}
|
||||
@@ -168,4 +182,11 @@ namespace AccountRules {
|
||||
return ManageDenial(actorLevel, actorAccountId, targetLevel, targetAccountId,
|
||||
Permissions::Allowed(actorLevel, SelfPermission(action)), Permissions::Allowed(actorLevel, EQUAL_RANK_PERMISSION));
|
||||
}
|
||||
|
||||
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);
|
||||
if (!scope) return denial;
|
||||
return ScopedManageDenial(denial, actorLevel, actorAccountId, targetLevel, targetAccountId,
|
||||
scope->Has(SelfPermission(action)), scope->Has(EQUAL_RANK_PERMISSION));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "json.hpp"
|
||||
|
||||
namespace ApiKeys { struct Scope; }
|
||||
|
||||
/**
|
||||
* What each GM level may do on the dashboard, and in the game for the slash commands paired with a permission. Every
|
||||
* permission has a default minimum GM level; server owners can change it without rebuilding, on the Permissions page
|
||||
@@ -50,11 +53,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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
bool CanViewCharacter(uint8_t gmLevel, uint32_t viewerAccountId, uint32_t ownerAccountId, const ApiKeys::Scope* scope = nullptr);
|
||||
|
||||
// {key: bool} for every permission, for templates and scripts
|
||||
nlohmann::json ForLevel(uint8_t gmLevel);
|
||||
nlohmann::json ForLevel(uint8_t gmLevel, const ApiKeys::Scope* scope = 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);
|
||||
|
||||
Reference in New Issue
Block a user