Use permission APIs for the /computercraft command

- Add a generic PermissionRegistry interface. This behaves similarly to
   our ShaderMod interface, searching all providers until it finds a
   compatible one.

   We could just make this part of the platform code instead, but this
   allows us to support multiple systems on Fabric, where things are
   less standardised.

   This interface behaves like a registry, rather than a straight
   `getPermission(node, player)` method, as Forge requires us to list
   our nodes up-front.

 - Add Forge (using the built-in system) and Fabric (using
   fabric-permissions-api) implementations of the above interface.

 - Register permission nodes for our commands, and use those
   instead. This does mean that the permissions check for the root
   /computercraft command now requires enumerating all child
   commands (and so potential does 7 permission lookups), but hopefully
   this isn't too bad in practice.

 - Remove UserLevel.OWNER - we never used this anywhere, and I can't
   imagine we'll want to in the future.
This commit is contained in:
Jonathan Coates 2023-08-27 12:15:55 +01:00
parent 5f8b1dd67f
commit b3738a7a63
No known key found for this signature in database
GPG Key ID: B9E431FF07C98D06
11 changed files with 249 additions and 69 deletions

View File

@ -66,6 +66,7 @@ repositories {
includeGroup("me.shedaniel")
includeGroup("mezz.jei")
includeModule("com.terraformersmc", "modmenu")
includeModule("me.lucko", "fabric-permissions-api")
}
}
}

View File

@ -34,6 +34,7 @@ slf4j = "1.7.36"
# Minecraft mods
emi = "1.0.8+1.19.4"
fabricPermissions = "0.2.20221016"
iris = "1.5.2+1.19.4"
jei = "13.1.0.11"
modmenu = "6.1.0-rc.1"
@ -93,6 +94,7 @@ slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
# Minecraft mods
fabric-api = { module = "net.fabricmc.fabric-api:fabric-api", version.ref = "fabric-api" }
fabric-loader = { module = "net.fabricmc:fabric-loader", version.ref = "fabric-loader" }
fabricPermissions = { module = "me.lucko:fabric-permissions-api", version.ref = "fabricPermissions" }
emi = { module = "dev.emi:emi-xplat-mojmap", version.ref = "emi" }
iris = { module = "maven.modrinth:iris", version.ref = "iris" }
jei-api = { module = "mezz.jei:jei-1.19.4-common-api", version.ref = "jei" }
@ -154,7 +156,7 @@ externalMods-common = ["jei-api", "nightConfig-core", "nightConfig-toml"]
externalMods-forge-compile = ["oculus", "jei-api"]
externalMods-forge-runtime = ["jei-forge"]
externalMods-fabric = ["nightConfig-core", "nightConfig-toml"]
externalMods-fabric-compile = ["iris", "jei-api", "rei-api", "rei-builtin"]
externalMods-fabric-compile = ["fabricPermissions", "iris", "jei-api", "rei-api", "rei-builtin"]
externalMods-fabric-runtime = ["jei-fabric", "modmenu"]
# Testing

View File

@ -15,6 +15,7 @@
import dan200.computercraft.core.util.Colour;
import dan200.computercraft.impl.PocketUpgrades;
import dan200.computercraft.impl.TurtleUpgrades;
import dan200.computercraft.shared.command.UserLevel;
import dan200.computercraft.shared.command.arguments.ComputerArgumentType;
import dan200.computercraft.shared.command.arguments.ComputersArgumentType;
import dan200.computercraft.shared.command.arguments.RepeatArgumentType;
@ -37,6 +38,7 @@
import dan200.computercraft.shared.data.PlayerCreativeLootCondition;
import dan200.computercraft.shared.details.BlockDetails;
import dan200.computercraft.shared.details.ItemDetails;
import dan200.computercraft.shared.integration.PermissionRegistry;
import dan200.computercraft.shared.media.items.DiskItem;
import dan200.computercraft.shared.media.items.PrintoutItem;
import dan200.computercraft.shared.media.items.RecordMedia;
@ -77,6 +79,7 @@
import dan200.computercraft.shared.turtle.upgrades.*;
import dan200.computercraft.shared.util.ImpostorRecipe;
import dan200.computercraft.shared.util.ImpostorShapelessRecipe;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.synchronization.ArgumentTypeInfo;
import net.minecraft.commands.synchronization.SingletonArgumentInfo;
import net.minecraft.core.BlockPos;
@ -98,6 +101,7 @@
import net.minecraft.world.level.storage.loot.predicates.LootItemConditionType;
import java.util.function.BiFunction;
import java.util.function.Predicate;
/**
* Registers ComputerCraft's registry entries and additional objects, such as {@link CauldronInteraction}s and
@ -366,6 +370,18 @@ private static <T extends CustomRecipe> RegistryEntry<SimpleCraftingRecipeSerial
public static final RegistryEntry<ImpostorShapelessRecipe.Serializer> IMPOSTOR_SHAPELESS = REGISTRY.register("impostor_shapeless", ImpostorShapelessRecipe.Serializer::new);
}
public static class Permissions {
static final PermissionRegistry REGISTRY = PermissionRegistry.create();
public static final Predicate<CommandSourceStack> PERMISSION_DUMP = REGISTRY.registerCommand("dump", UserLevel.OWNER_OP);
public static final Predicate<CommandSourceStack> PERMISSION_SHUTDOWN = REGISTRY.registerCommand("shutdown", UserLevel.OWNER_OP);
public static final Predicate<CommandSourceStack> PERMISSION_TURN_ON = REGISTRY.registerCommand("turn_on", UserLevel.OWNER_OP);
public static final Predicate<CommandSourceStack> PERMISSION_TP = REGISTRY.registerCommand("tp", UserLevel.OP);
public static final Predicate<CommandSourceStack> PERMISSION_TRACK = REGISTRY.registerCommand("track", UserLevel.OWNER_OP);
public static final Predicate<CommandSourceStack> PERMISSION_QUEUE = REGISTRY.registerCommand("queue", UserLevel.ANYONE);
public static final Predicate<CommandSourceStack> PERMISSION_VIEW = REGISTRY.registerCommand("view", UserLevel.OP);
}
/**
* Register any objects which don't have to be done on the main thread.
*/
@ -379,6 +395,7 @@ public static void register() {
ArgumentTypes.REGISTRY.register();
LootItemConditionTypes.REGISTRY.register();
RecipeSerializers.REGISTRY.register();
Permissions.REGISTRY.register();
// Register bundled power providers
ComputerCraftAPI.registerBundledRedstoneProvider(new DefaultBundledRedstoneProvider());

View File

@ -11,6 +11,7 @@
import com.mojang.brigadier.suggestion.Suggestions;
import dan200.computercraft.core.computer.ComputerSide;
import dan200.computercraft.core.metrics.Metrics;
import dan200.computercraft.shared.ModRegistry;
import dan200.computercraft.shared.command.arguments.ComputersArgumentType;
import dan200.computercraft.shared.command.text.TableBuilder;
import dan200.computercraft.shared.computer.core.ComputerFamily;
@ -60,7 +61,7 @@ private CommandComputerCraft() {
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(choice("computercraft")
.then(literal("dump")
.requires(UserLevel.OWNER_OP)
.requires(ModRegistry.Permissions.PERMISSION_DUMP)
.executes(context -> {
var table = new TableBuilder("DumpAll", "Computer", "On", "Position");
@ -118,7 +119,7 @@ public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
})))
.then(command("shutdown")
.requires(UserLevel.OWNER_OP)
.requires(ModRegistry.Permissions.PERMISSION_SHUTDOWN)
.argManyValue("computers", manyComputers(), s -> ServerContext.get(s.getServer()).registry().getComputers())
.executes((context, computerSelectors) -> {
var shutdown = 0;
@ -132,7 +133,7 @@ public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
}))
.then(command("turn-on")
.requires(UserLevel.OWNER_OP)
.requires(ModRegistry.Permissions.PERMISSION_TURN_ON)
.argManyValue("computers", manyComputers(), s -> ServerContext.get(s.getServer()).registry().getComputers())
.executes((context, computerSelectors) -> {
var on = 0;
@ -146,7 +147,7 @@ public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
}))
.then(command("tp")
.requires(UserLevel.OP)
.requires(ModRegistry.Permissions.PERMISSION_TP)
.arg("computer", oneComputer())
.executes(context -> {
var computer = getComputerArgument(context, "computer");
@ -171,7 +172,7 @@ public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
}))
.then(command("queue")
.requires(UserLevel.ANYONE)
.requires(ModRegistry.Permissions.PERMISSION_QUEUE)
.arg(
RequiredArgumentBuilder.<CommandSourceStack, ComputersArgumentType.ComputersSupplier>argument("computer", manyComputers())
.suggests((context, builder) -> Suggestions.empty())
@ -193,7 +194,7 @@ public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
}))
.then(command("view")
.requires(UserLevel.OP)
.requires(ModRegistry.Permissions.PERMISSION_VIEW)
.arg("computer", oneComputer())
.executes(context -> {
var player = context.getSource().getPlayerOrException();
@ -213,8 +214,8 @@ public AbstractContainerMenu createMenu(int id, Inventory player, Player entity)
}))
.then(choice("track")
.requires(ModRegistry.Permissions.PERMISSION_TRACK)
.then(command("start")
.requires(UserLevel.OWNER_OP)
.executes(context -> {
getMetricsInstance(context.getSource()).start();
@ -227,7 +228,6 @@ public AbstractContainerMenu createMenu(int id, Inventory player, Player entity)
}))
.then(command("stop")
.requires(UserLevel.OWNER_OP)
.executes(context -> {
var timings = getMetricsInstance(context.getSource());
if (!timings.stop()) throw NOT_TRACKING_EXCEPTION.create();
@ -236,7 +236,6 @@ public AbstractContainerMenu createMenu(int id, Inventory player, Player entity)
}))
.then(command("dump")
.requires(UserLevel.OWNER_OP)
.argManyValue("fields", metric(), DEFAULT_FIELDS)
.executes((context, fields) -> {
AggregatedMetric sort;
@ -270,23 +269,25 @@ private static Component linkComputer(CommandSourceStack source, @Nullable Serve
out.append(" (id " + computerId + ")");
// And, if we're a player, some useful links
if (serverComputer != null && UserLevel.OP.test(source) && isPlayer(source)) {
out
.append(" ")
.append(link(
if (serverComputer != null && isPlayer(source)) {
if (ModRegistry.Permissions.PERMISSION_TP.test(source)) {
out.append(" ").append(link(
text("\u261b"),
"/computercraft tp " + serverComputer.getInstanceID(),
Component.translatable("commands.computercraft.tp.action")
))
.append(" ")
.append(link(
));
}
if (ModRegistry.Permissions.PERMISSION_VIEW.test(source)) {
out.append(" ").append(link(
text("\u20e2"),
"/computercraft view " + serverComputer.getInstanceID(),
Component.translatable("commands.computercraft.view.action")
));
}
}
if (UserLevel.OWNER.test(source) && isPlayer(source)) {
if (isPlayer(source) && UserLevel.isOwner(source)) {
var linkPath = linkStorage(source, computerId);
if (linkPath != null) out.append(" ").append(linkPath);
}
@ -295,7 +296,7 @@ private static Component linkComputer(CommandSourceStack source, @Nullable Serve
}
private static Component linkPosition(CommandSourceStack context, ServerComputer computer) {
if (UserLevel.OP.test(context)) {
if (ModRegistry.Permissions.PERMISSION_TP.test(context)) {
return link(
position(computer.getPosition()),
"/computercraft tp " + computer.getInstanceID(),

View File

@ -10,7 +10,6 @@
import dan200.computercraft.shared.platform.PlatformHelper;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.SharedSuggestionProvider;
import net.minecraft.server.level.ServerPlayer;
import java.util.Arrays;
import java.util.Locale;
@ -22,8 +21,8 @@ private CommandUtils() {
}
public static boolean isPlayer(CommandSourceStack output) {
var sender = output.getEntity();
return sender instanceof ServerPlayer player && !PlatformHelper.get().isFakePlayer(player);
var player = output.getPlayer();
return player != null && !PlatformHelper.get().isFakePlayer(player);
}
@SuppressWarnings("unchecked")

View File

@ -5,7 +5,7 @@
package dan200.computercraft.shared.command;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.world.entity.player.Player;
import net.minecraft.server.level.ServerPlayer;
import java.util.function.Predicate;
@ -13,11 +13,6 @@
* The level a user must be at in order to execute a command.
*/
public enum UserLevel implements Predicate<CommandSourceStack> {
/**
* Only can be used by the owner of the server: namely the server console or the player in SSP.
*/
OWNER,
/**
* Can only be used by ops.
*/
@ -35,7 +30,6 @@ public enum UserLevel implements Predicate<CommandSourceStack> {
public int toLevel() {
return switch (this) {
case OWNER -> 4;
case OP, OWNER_OP -> 2;
case ANYONE -> 0;
};
@ -44,39 +38,26 @@ public int toLevel() {
@Override
public boolean test(CommandSourceStack source) {
if (this == ANYONE) return true;
if (this == OWNER) return isOwner(source);
if (this == OWNER_OP && isOwner(source)) return true;
return source.hasPermission(toLevel());
}
/**
* Take the union of two {@link UserLevel}s.
* <p>
* This satisfies the property that for all sources {@code s}, {@code a.test(s) || b.test(s) == (a b).test(s)}.
*
* @param left The first user level to take the union of.
* @param right The second user level to take the union of.
* @return The union of two levels.
*/
public static UserLevel union(UserLevel left, UserLevel right) {
if (left == right) return left;
// x ANYONE = ANYONE
if (left == ANYONE || right == ANYONE) return ANYONE;
// x OWNER = OWNER
if (left == OWNER) return right;
if (right == OWNER) return left;
// At this point, we have x != y and x, y { OP, OWNER_OP }.
return OWNER_OP;
public boolean test(ServerPlayer source) {
if (this == ANYONE) return true;
if (this == OWNER_OP && isOwner(source)) return true;
return source.hasPermissions(toLevel());
}
private static boolean isOwner(CommandSourceStack source) {
public static boolean isOwner(CommandSourceStack source) {
var server = source.getServer();
var sender = source.getEntity();
var player = source.getPlayer();
return server.isDedicatedServer()
? source.getEntity() == null && source.hasPermission(4) && source.getTextName().equals("Server")
: sender instanceof Player player && server.isSingleplayerOwner(player.getGameProfile());
: player != null && server.isSingleplayerOwner(player.getGameProfile());
}
public static boolean isOwner(ServerPlayer player) {
var server = player.getServer();
return server != null && server.isSingleplayerOwner(player.getGameProfile());
}
}

View File

@ -44,7 +44,8 @@ public static CommandBuilder<CommandSourceStack> command(String literal) {
}
public CommandBuilder<S> requires(Predicate<S> predicate) {
requires = requires == null ? predicate : requires.and(predicate);
if (requires != null) throw new IllegalStateException("Requires already set");
requires = predicate;
return this;
}

View File

@ -10,7 +10,6 @@
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.tree.CommandNode;
import com.mojang.brigadier.tree.LiteralCommandNode;
import dan200.computercraft.shared.command.UserLevel;
import net.minecraft.ChatFormatting;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.network.chat.ClickEvent;
@ -31,6 +30,7 @@
*/
public final class HelpingArgumentBuilder extends LiteralArgumentBuilder<CommandSourceStack> {
private final Collection<HelpingArgumentBuilder> children = new ArrayList<>();
private @Nullable Predicate<CommandSourceStack> requirement;
private HelpingArgumentBuilder(String literal) {
super(literal);
@ -41,26 +41,20 @@ public static HelpingArgumentBuilder choice(String literal) {
}
@Override
public LiteralArgumentBuilder<CommandSourceStack> requires(Predicate<CommandSourceStack> requirement) {
throw new IllegalStateException("Cannot use requires on a HelpingArgumentBuilder");
public HelpingArgumentBuilder requires(Predicate<CommandSourceStack> requirement) {
this.requirement = requirement;
return this;
}
@Override
public Predicate<CommandSourceStack> getRequirement() {
// The requirement of this node is the union of all child's requirements.
if (requirement != null) return requirement;
var requirements = Stream.concat(
children.stream().map(ArgumentBuilder::getRequirement),
getArguments().stream().map(CommandNode::getRequirement)
).toList();
// If all requirements are a UserLevel, take the union of those instead.
var userLevel = UserLevel.OWNER;
for (var requirement : requirements) {
if (!(requirement instanceof UserLevel level)) return x -> requirements.stream().anyMatch(y -> y.test(x));
userLevel = UserLevel.union(userLevel, level);
}
return userLevel;
return x -> requirements.stream().anyMatch(y -> y.test(x));
}
@Override
@ -181,7 +175,7 @@ private static Component getHelp(CommandContext<CommandSourceStack> context, Com
.append(Component.translatable("commands." + id + ".desc"));
for (var child : node.getChildren()) {
if (!child.getRequirement().test(context.getSource()) || !(child instanceof LiteralCommandNode)) {
if (!child.canUse(context.getSource()) || !(child instanceof LiteralCommandNode)) {
continue;
}

View File

@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.integration;
import com.mojang.brigadier.builder.ArgumentBuilder;
import dan200.computercraft.shared.command.CommandComputerCraft;
import dan200.computercraft.shared.command.UserLevel;
import dan200.computercraft.shared.platform.RegistrationHelper;
import net.minecraft.commands.CommandSourceStack;
import javax.annotation.OverridingMethodsMustInvokeSuper;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.function.Predicate;
/**
* A registry of nodes in a permission system.
* <p>
* This acts as an abstraction layer over permission systems such Forge's built-in permissions API, or Fabric's
* unofficial <a href="https://github.com/lucko/fabric-permissions-api">fabric-permissions-api-v0</a>.
* <p>
* This behaves similarly to {@link RegistrationHelper} (aka Forge's deferred registry), in that you {@linkplain #create()
* create a registry}, {@linkplain #registerCommand(String, UserLevel) add nodes to it} and then finally {@linkplain
* #register()} all created nodes.
*
* @see dan200.computercraft.shared.ModRegistry.Permissions
*/
public abstract class PermissionRegistry {
private boolean frozen = false;
/**
* Register a permission node for a command. The registered node should be of the form {@code "command." + command}.
*
* @param command The name of the command. This should be one of the subcommands under the {@code /computercraft}
* subcommand, and not something general.
* @param fallback The default/fallback permission check.
* @return The resulting predicate which should be passed to {@link ArgumentBuilder#requires(Predicate)}.
* @see CommandComputerCraft
*/
public abstract Predicate<CommandSourceStack> registerCommand(String command, UserLevel fallback);
/**
* Check that the registry has not been frozen (namely {@link #register()} has been called). This should be called
* before registering each node.
*/
protected void checkNotFrozen() {
if (frozen) throw new IllegalStateException("Permission registry has been frozen.");
}
/**
* Freeze the permissions registry and register the underlying nodes.
*/
@OverridingMethodsMustInvokeSuper
public void register() {
frozen = true;
}
public interface Provider {
Optional<PermissionRegistry> get();
}
public static PermissionRegistry create() {
return ServiceLoader.load(Provider.class)
.stream()
.flatMap(x -> x.get().get().stream())
.findFirst()
.orElseGet(DefaultPermissionRegistry::new);
}
private static class DefaultPermissionRegistry extends PermissionRegistry {
@Override
public Predicate<CommandSourceStack> registerCommand(String command, UserLevel fallback) {
checkNotFrozen();
return fallback;
}
}
}

View File

@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.integration;
import com.google.auto.service.AutoService;
import dan200.computercraft.api.ComputerCraftAPI;
import dan200.computercraft.shared.command.UserLevel;
import me.lucko.fabric.api.permissions.v0.Permissions;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.commands.CommandSourceStack;
import java.util.Optional;
import java.util.function.Predicate;
/**
* An implementation of {@link PermissionRegistry} using Fabric's unofficial {@linkplain Permissions permissions api}.
*/
public final class FabricPermissionRegistry extends PermissionRegistry {
private FabricPermissionRegistry() {
}
@Override
public Predicate<CommandSourceStack> registerCommand(String command, UserLevel fallback) {
checkNotFrozen();
var name = ComputerCraftAPI.MOD_ID + ".command." + command;
return source -> Permissions.getPermissionValue(source, name).orElseGet(() -> fallback.test(source));
}
@AutoService(PermissionRegistry.Provider.class)
public static final class Provider implements PermissionRegistry.Provider {
@Override
public Optional<PermissionRegistry> get() {
return FabricLoader.getInstance().isModLoaded("fabric-permissions-api-v0")
? Optional.of(new FabricPermissionRegistry())
: Optional.empty();
}
}
}

View File

@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.integration;
import com.google.auto.service.AutoService;
import dan200.computercraft.api.ComputerCraftAPI;
import dan200.computercraft.shared.command.UserLevel;
import net.minecraft.commands.CommandSourceStack;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.server.permission.PermissionAPI;
import net.minecraftforge.server.permission.events.PermissionGatherEvent;
import net.minecraftforge.server.permission.nodes.PermissionNode;
import net.minecraftforge.server.permission.nodes.PermissionType;
import net.minecraftforge.server.permission.nodes.PermissionTypes;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
/**
* An implementation of {@link PermissionRegistry} using Forge's {@link PermissionAPI}.
*/
public final class ForgePermissionRegistry extends PermissionRegistry {
private final List<PermissionNode<?>> nodes = new ArrayList<>();
private ForgePermissionRegistry() {
}
private <T> PermissionNode<T> registerNode(String nodeName, PermissionType<T> type, PermissionNode.PermissionResolver<T> defaultResolver) {
checkNotFrozen();
var node = new PermissionNode<>(ComputerCraftAPI.MOD_ID, nodeName, type, defaultResolver);
nodes.add(node);
return node;
}
@Override
public Predicate<CommandSourceStack> registerCommand(String command, UserLevel fallback) {
var node = registerNode(
"command." + command, PermissionTypes.BOOLEAN,
(player, uuid, context) -> player != null && fallback.test(player)
);
return source -> {
var player = source.getPlayer();
return player == null ? fallback.test(source) : PermissionAPI.getPermission(player, node);
};
}
@Override
public void register() {
super.register();
MinecraftForge.EVENT_BUS.addListener((PermissionGatherEvent.Nodes event) -> event.addNodes(nodes));
}
@AutoService(PermissionRegistry.Provider.class)
public static final class Provider implements PermissionRegistry.Provider {
@Override
public Optional<PermissionRegistry> get() {
return Optional.of(new ForgePermissionRegistry());
}
}
}