From e00d22152e2916caa4608591196fcb952564effa Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 01:11:17 -0500 Subject: [PATCH] feat(game): slash commands accept permission grants A command may be used when the character's GM level allows it or a grant does: a grant of the command, of every command up to a GM level, or of the dashboard permission the command follows (so accounts_kick covers /kick). A deny of any of those takes it away even when the level allows it, except from GM 9 accounts (also while they play at a lower level). Grants never take anyone below a command's floor above GM 1 (/execute), and commands the client handles keep their fixed level. Expired grants count for nothing. The grants are the account's and the logged-in character's, read from the database the first time a command is used and kept on the User. /help lists the commands a player may use this way. The self and rank rules for commands that act on another player count grants of self_* and manage_equal_rank too. A denied command says it was taken away. Check: grant a GM 0 account /spawn (or "every command up to GM 8") on the dashboard, relog, and /spawn works and shows in /help; deny /spawn from a GM 8 character: "it was taken away"; grant accounts_kick and /kick works (the rank rules still apply to whom). dGameTests SlashCommandGrantsTest. Co-Authored-By: Claude Opus 5.5 --- dGame/User.h | 11 ++ dGame/dUtilities/SlashCommandHandler.cpp | 53 ++++++-- dGame/dUtilities/SlashCommandHandler.h | 18 ++- .../SlashCommandPermissionTests.cpp | 114 ++++++++++++++++++ 4 files changed, 186 insertions(+), 10 deletions(-) diff --git a/dGame/User.h b/dGame/User.h index 4575d2961..6edc5a09d 100644 --- a/dGame/User.h +++ b/dGame/User.h @@ -8,10 +8,12 @@ #include "dCommonVars.h" #include "eFunnessTypes.h" +#include #include class Character; enum class eGameMasterLevel : uint8_t; +namespace PermissionGrants { struct Held; } struct BehaviorParams { uint32_t behavior; @@ -59,6 +61,13 @@ public: void UserOutOfSync(const CaughtFunness& funness); + // The permission grants of this account and of the character they were loaded for (SlashCommandHandler loads them + // when first needed; nullptr until then, and again after ForgetGrants when the dashboard changed them) + const std::shared_ptr& GetGrants() const { return m_Grants; } + LWOOBJID GetGrantsCharacter() const { return m_GrantsCharacter; } + void SetGrants(std::shared_ptr grants, LWOOBJID characterId) { m_Grants = std::move(grants); m_GrantsCharacter = characterId; } + void ForgetGrants() { m_Grants.reset(); } + private: uint32_t m_AccountID; std::string m_Username; @@ -78,6 +87,8 @@ private: uint64_t m_MuteExpire; std::chrono::steady_clock::time_point m_LastMuteCheck{}; std::vector m_CaughtFunness{}; + std::shared_ptr m_Grants{}; + LWOOBJID m_GrantsCharacter{}; }; #endif // USER_H diff --git a/dGame/dUtilities/SlashCommandHandler.cpp b/dGame/dUtilities/SlashCommandHandler.cpp index c451a6db6..1d261f567 100644 --- a/dGame/dUtilities/SlashCommandHandler.cpp +++ b/dGame/dUtilities/SlashCommandHandler.cpp @@ -10,6 +10,8 @@ #include "ChatPackets.h" #include "WorldMigration.h" +#include +#include #include #include #include @@ -26,6 +28,8 @@ #include "dConfig.h" #include "SlashCommandLevels.h" #include "Permissions.h" +#include "PermissionGrants.h" +#include "PermissionGrantsLoader.h" #include "Character.h" #include "ChatPackets.h" #include "PlayerManager.h" @@ -123,19 +127,47 @@ std::optional SlashCommandHandler::FindTarget(const std::string& return target; } -AccountRules::eManageDenial SlashCommandHandler::TargetDenial(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, SlashCommandLevels::eTargetRule rule) { +AccountRules::eManageDenial SlashCommandHandler::TargetDenial(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, SlashCommandLevels::eTargetRule rule, + const PermissionGrants::Held* actorGrants) { using SlashCommandLevels::eTargetRule; + using AccountRules::eAccountAction; + const auto denial = [&](eAccountAction action) { return AccountRules::ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, action, nullptr, actorGrants); }; switch (rule) { case eTargetRule::NONE: return AccountRules::eManageDenial::NONE; case eTargetRule::OTHERS: if (actorAccountId != 0 && actorAccountId == targetAccountId) return AccountRules::eManageDenial::NONE; - return AccountRules::ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, AccountRules::eAccountAction::TOOLS); - case eTargetRule::ITEMS: return AccountRules::ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, AccountRules::eAccountAction::ITEMS); - case eTargetRule::MODERATION: return AccountRules::ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, AccountRules::eAccountAction::MODERATION); - default: return AccountRules::ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, AccountRules::eAccountAction::TOOLS); + return denial(eAccountAction::TOOLS); + case eTargetRule::ITEMS: return denial(eAccountAction::ITEMS); + case eTargetRule::MODERATION: return denial(eAccountAction::MODERATION); + default: return denial(eAccountAction::TOOLS); } } +const PermissionGrants::Held* SlashCommandHandler::GrantsOf(Entity* player) { + auto* character = player ? player->GetCharacter() : nullptr; + auto* user = character ? character->GetParentUser() : nullptr; + if (!user) return nullptr; + if (!user->GetGrants() || user->GetGrantsCharacter() != character->GetID()) { + user->SetGrants(PermissionGrants::Load(user->GetAccountID(), character->GetID()), character->GetID()); + } + return user->GetGrants().get(); +} + +PermissionGrants::Command SlashCommandHandler::GrantRules(const Command& command) { + return { command.name, static_cast(GetRequiredLevel(command)), static_cast(command.minLevel.value_or(eGameMasterLevel::CIVILIAN)), + command.fixedLevel, Permissions::Find(command.dashboardPermission) ? command.dashboardPermission : "" }; +} + +bool SlashCommandHandler::MayUse(Entity* player, const Command& command) { + if (!player) return false; + auto* character = player->GetCharacter(); + auto* user = character ? character->GetParentUser() : nullptr; + const auto playerLevel = static_cast(player->GetGMLevel()); + // Denies never apply to a GM 9 account, even while it plays at a lower level + const auto accountLevel = std::max(playerLevel, static_cast(user ? user->GetMaxGMLevel() : eGameMasterLevel::CIVILIAN)); + return PermissionGrants::MayUseCommand(playerLevel, accountLevel, GrantRules(command), GrantsOf(player), static_cast(std::time(nullptr))); +} + std::string SlashCommandHandler::TargetRefusal(AccountRules::eManageDenial denial, SlashCommandLevels::eTargetRule rule, std::string_view command) { if (denial == AccountRules::eManageDenial::NONE) return ""; using SlashCommandLevels::eTargetRule; @@ -146,7 +178,7 @@ std::string SlashCommandHandler::TargetRefusal(AccountRules::eManageDenial denia bool SlashCommandHandler::MayActOn(Entity* actor, const SystemAddress& sysAddr, const CommandTarget& target, SlashCommandLevels::eTargetRule rule, std::string_view command) { if (!actor) return false; - const auto denial = TargetDenial(static_cast(actor->GetGMLevel()), AccountOf(actor), target.gmLevel, target.accountId, rule); + const auto denial = TargetDenial(static_cast(actor->GetGMLevel()), AccountOf(actor), target.gmLevel, target.accountId, rule, GrantsOf(actor)); if (denial == AccountRules::eManageDenial::NONE) return true; ChatPackets::SendSystemMessage(sysAddr, GeneralUtils::UTF8ToUTF16(TargetRefusal(denial, rule, command))); return false; @@ -249,9 +281,12 @@ void SlashCommandHandler::HandleChatCommand(const std::u16string& chat, Entity* if (commandItr != RegisteredCommands.end()) { auto& [alias, commandHandle] = *commandItr; const auto requiredLevel = GetRequiredLevel(commandHandle); - if (entity->GetGMLevel() >= requiredLevel) { + if (MayUse(entity, commandHandle)) { if (requiredLevel > eGameMasterLevel::CIVILIAN) Database::Get()->InsertSlashCommandUsage(entity->GetObjectID(), input); commandHandle.handle(entity, sysAddr, args); + } else if (entity->GetGMLevel() >= requiredLevel) { + // The level allows it, but a deny on the dashboard took it away + error = "You may not use \"" + command + "\": it was taken away from you"; } else if (entity->GetGMLevel() != eGameMasterLevel::CIVILIAN) { error = "You are not high enough GM level to use \"" + command + "\""; } @@ -282,7 +317,7 @@ void GMZeroCommands::Help(Entity* entity, const SystemAddress& sysAddr, const st std::map accessibleCommands; for (const auto& [commandName, command] : CommandInfos) { - if (SlashCommandHandler::GetRequiredLevel(command) <= entity->GetGMLevel()) { + if (SlashCommandHandler::MayUse(entity, command)) { accessibleCommands.emplace(commandName, command); } } @@ -312,7 +347,7 @@ void GMZeroCommands::Help(Entity* entity, const SystemAddress& sysAddr, const st } const auto it = RegisteredCommands.find(trimmedArgs); - if (it != RegisteredCommands.end() && entity->GetGMLevel() >= SlashCommandHandler::GetRequiredLevel(it->second)) { + if (it != RegisteredCommands.end() && SlashCommandHandler::MayUse(entity, it->second)) { const auto& command = it->second; feedback << "----- " << it->first << " Info -----\n"; feedback << command.info << "\n"; diff --git a/dGame/dUtilities/SlashCommandHandler.h b/dGame/dUtilities/SlashCommandHandler.h index 36570aa07..2ffdd6020 100644 --- a/dGame/dUtilities/SlashCommandHandler.h +++ b/dGame/dUtilities/SlashCommandHandler.h @@ -11,6 +11,7 @@ #include "dCommonVars.h" #include "AccountRules.h" #include "SlashCommandLevels.h" +#include "PermissionGrants.h" #include #include #include @@ -80,7 +81,8 @@ namespace SlashCommandHandler { * their own level only with manage_equal_rank, and on themselves only with the self_* permission for the rule * (OTHERS: on themselves always, as before). */ - AccountRules::eManageDenial TargetDenial(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, SlashCommandLevels::eTargetRule rule); + AccountRules::eManageDenial TargetDenial(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, SlashCommandLevels::eTargetRule rule, + const PermissionGrants::Held* actorGrants = nullptr); // Why a command may not be used on someone, for the chat; empty when it may std::string TargetRefusal(AccountRules::eManageDenial denial, SlashCommandLevels::eTargetRule rule, std::string_view command); @@ -93,6 +95,20 @@ namespace SlashCommandHandler { // The account of the player using a command (0 if unknown) uint32_t AccountOf(Entity* player); + + /** + * The permission grants of a player's account and logged-in character (PermissionGrants.h), loaded from the database + * the first time they are needed and kept on the User until the dashboard changes them (DashboardActions). nullptr: + * not a player. + */ + const PermissionGrants::Held* GrantsOf(Entity* player); + + // Whether a player may use a command now: its level, or a grant for it (the command, a command group, or the + // dashboard permission it follows), unless a deny takes it away (PermissionGrants::MayUseCommand) + bool MayUse(Entity* player, const Command& command); + + // The command as the grant rules see it: its name, level now, floor, and the dashboard permission it follows + PermissionGrants::Command GrantRules(const Command& command); }; namespace GMZeroCommands { diff --git a/tests/dGameTests/SlashCommandPermissionTests.cpp b/tests/dGameTests/SlashCommandPermissionTests.cpp index 2747cc2f8..9e3f57940 100644 --- a/tests/dGameTests/SlashCommandPermissionTests.cpp +++ b/tests/dGameTests/SlashCommandPermissionTests.cpp @@ -267,3 +267,117 @@ TEST_F(SlashCommandCatalogTest, NewServerFollowsThePermissions) { Game::config->SetDatabaseValues({}, {}); EXPECT_EQ(static_cast(SlashCommandHandler::GetRequiredLevel(*SlashCommandHandler::FindCommand("mute"))), Permissions::Level("accounts_mute")); } + +#include "Character.h" +#include "Entity.h" +#include "PermissionGrants.h" +#include "User.h" + +// Grants and denies from the dashboard (PermissionGrants.h), as a player using commands sees them +class SlashCommandGrantsTest : public GameDependenciesTest { +protected: + static constexpr LWOOBJID CHARACTER_ID = 42; + + void SetUp() override { + SetUpDependencies(); + static bool started = false; + if (!started) SlashCommandHandler::Startup(); + started = true; + user = std::make_unique(UNASSIGNED_SYSTEM_ADDRESS, "tester", "key"); + character = std::make_unique(CHARACTER_ID, user.get()); + entity = std::make_unique(1, info); + entity->SetCharacter(character.get()); + character->SetEntity(entity.get()); + } + + void TearDown() override { + entity->SetCharacter(nullptr); + entity.reset(); + character.reset(); + user.reset(); + TearDownDependencies(); + } + + // Play at a GM level (the account's is at least that) with these grants loaded for the character + void Play(eGameMasterLevel level, std::vector rules, eGameMasterLevel accountLevel = eGameMasterLevel::CIVILIAN) { + user->SetMaxGMLevel(std::max(level, accountLevel)); + entity->SetGMLevel(level); + auto held = std::make_shared(); + held->rules = std::move(rules); + user->SetGrants(held, CHARACTER_ID); + } + + bool MayUse(const std::string& alias) { + const auto* command = SlashCommandHandler::FindCommand(alias); + EXPECT_NE(command, nullptr) << alias; + return command && SlashCommandHandler::MayUse(entity.get(), *command); + } + + std::unique_ptr user; + std::unique_ptr character; + std::unique_ptr entity; +}; + +TEST_F(SlashCommandGrantsTest, GrantsLetAPlayerUseACommand) { + using PermissionGrants::eKind; + Play(eGameMasterLevel::MODERATOR, {}); + EXPECT_FALSE(MayUse("spawn")); + EXPECT_TRUE(MayUse("kick")); // accounts_kick: GM 2 + + Play(eGameMasterLevel::MODERATOR, { { eKind::COMMAND, "spawn", false, 0 } }); + EXPECT_TRUE(MayUse("spawn")); + EXPECT_FALSE(MayUse("gmadditem")); + + // A grant of a dashboard permission covers the commands that follow it + Play(eGameMasterLevel::CIVILIAN, { { eKind::PERMISSION, "accounts_kick", false, 0 } }); + EXPECT_TRUE(MayUse("kick")); + // Every command up to a GM level + Play(eGameMasterLevel::CIVILIAN, { { eKind::COMMAND_GROUP, "8", false, 0 } }); + EXPECT_TRUE(MayUse("spawn")); + EXPECT_TRUE(MayUse("gmadditem")); + // ...but never /execute below its floor + Play(eGameMasterLevel::MODERATOR, { { eKind::COMMAND_GROUP, "9", false, 0 }, { eKind::COMMAND, "execute", false, 0 } }); + EXPECT_FALSE(MayUse("execute")); + // Past its expiry a grant does nothing + Play(eGameMasterLevel::MODERATOR, { { eKind::COMMAND, "spawn", false, 1 } }); + EXPECT_FALSE(MayUse("spawn")); +} + +TEST_F(SlashCommandGrantsTest, DeniesTakeCommandsAwayExceptFromOperators) { + using PermissionGrants::eKind; + Play(eGameMasterLevel::DEVELOPER, { { eKind::COMMAND, "spawn", true, 0 }, { eKind::PERMISSION, "accounts_kick", true, 0 } }); + EXPECT_FALSE(MayUse("spawn")); + EXPECT_FALSE(MayUse("kick")); + EXPECT_TRUE(MayUse("gmadditem")); + // A GM 9 account playing at GM 8 keeps everything + Play(eGameMasterLevel::DEVELOPER, { { eKind::COMMAND, "spawn", true, 0 } }, eGameMasterLevel::OPERATOR); + EXPECT_TRUE(MayUse("spawn")); + // A player command can be taken from a player + Play(eGameMasterLevel::CIVILIAN, { { eKind::COMMAND, "pvp", true, 0 } }); + EXPECT_FALSE(MayUse("pvp")); +} + +TEST_F(SlashCommandGrantsTest, ForgottenGrantsAreReadAgain) { + using PermissionGrants::eKind; + Play(eGameMasterLevel::MODERATOR, { { eKind::COMMAND, "spawn", false, 0 } }); + EXPECT_TRUE(MayUse("spawn")); + // The dashboard changed them: read from the database again (none there) + user->ForgetGrants(); + EXPECT_FALSE(MayUse("spawn")); + ASSERT_NE(user->GetGrants(), nullptr); + EXPECT_EQ(user->GetGrantsCharacter(), CHARACTER_ID); +} + +TEST(SlashCommandPermissionTests, TargetRulesFollowGrants) { + using SlashCommandLevels::eTargetRule; + using AccountRules::eManageDenial; + using PermissionGrants::eKind; + ScopedConfig config({}); + PermissionGrants::Held held; + held.rules = { { eKind::PERMISSION, "self_moderation", false, 0 }, { eKind::PERMISSION, "manage_equal_rank", false, 0 } }; + EXPECT_EQ(SlashCommandHandler::TargetDenial(8, 1, 8, 1, eTargetRule::MODERATION), eManageDenial::SELF); + EXPECT_EQ(SlashCommandHandler::TargetDenial(8, 1, 8, 1, eTargetRule::MODERATION, &held), eManageDenial::NONE); + EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 4, 2, eTargetRule::MODERATION, &held), eManageDenial::NONE); + // Never a higher GM level + EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 5, 2, eTargetRule::MODERATION, &held), eManageDenial::HIGHER_RANK); +}