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