Minecraftforge: Let's talk about permissions (again).

Created on 24 May 2016  路  61Comments  路  Source: MinecraftForge/MinecraftForge

Just testing the waters to see how many modders would be receptive to the idea of a Permissions API in Forge, and how we should get around to doing it. I intend such a Permissions API to be extended by Sponge, ForgeEssentials and other such permissions-providing systems.

I know this isn't the first time I've raised the topic, but I feel it's an important one to discuss, especially with the return of "big" multiplayer servers based on the Sponge platform, which is pretty much feature-complete at this point.


  • I have a current draft of the Permissions API available here.
  • This API is sufficiently easy to use for modders (just call PermissionManager.checkPermission(player, "mymod.something.breakTheWorld"), you get back a boolean), and should stay that way.
  • Currently, the API draft provides additional permission-checking 'contexts' based on location and entity. I'm looking for more feedback on this (should this be expanded? Removed? Converted to a capability?)
  • One thing that can be considered is to add a PermissionActor capability, which would contain the entity doing the action (player? fakeplayer? TE?). This could be used to store a player reference to the owner of a machine, for example.

Outstanding issues:

  • How should contexts be structured?

I'd like to hear feedback on this, so I would really appreciate it if you could put your thoughts about this in the comments.

Feature

Most helpful comment

Shhhhhh. You are ruining the suprise party :P

All 61 comments

Really like the idea of this.

Another point is that if it would be kept lightweight, other mods could build upon that and add things like "Zones" or "Areas" that have specific permissions stored.

@Lordmau5 the location context in the draft allows for that. You can feed it some Vec3s (gonna change that to BlockPos soon), and it'll pass them to the implementation. The implementation would then be responsible for deciding if it has location based permissions (like zones and areas), then calculate the result accordingly.

@LatvianModder weren't you working on something for forge?

Shhhhhh. You are ruining the suprise party :P

Ok, so basically, here's my idea
https://gist.github.com/LatvianModder/b179ddcac33f867edcd98b74222926cc

The idea is pretty simple. OPs always have all permissions, and for players you pass default permission. If there is no handler or it returned Event.Result.DEFAULT, it returns that default permission. I use GameProfile instead of UUID, because it contains both UUID and Username, and you can always get a game profile from EntityPlayer.

I figured I'd chuck in my two cents:

I think it would be a wonderful addition to Forge and Sponge will simply overlay the API available in SpongeAPI on top of the Forge system (as we do elsewhere).

Some of my thoughts:

  • Should only be available from the server. Client would just lie anyways, why bother.
  • Builtin system should be an abstract layer, let mods provide the implementation as to let admins choose the best one for them
  • Builtin system should likely be contextual (Server/Provider/World)
  • I'm not completely sold on builtin zone permissions support. Seems like a feature of a permissions granting mod? This has been something I've considered refactoring out of SpongeAPI.
  • (Controversial) The permissions abstraction contract should guarantee thread-safety.

Another suggestion of my side is that, if possible, we should get some Sponge developers into this conversation so they can drop their thoughts on this as well.
Also, this way we can cooperate with them and figure out what else would be requested / needed by them whilst still keeping it lightweight :)

I agree with @Zidane - I think absolutely everything that isn't directly related to permissions (like areas, ranks, command permissions and what not) should be handled by implementation. The API itself should Only ask handler to do that, and that's it. I designed mine to be super simple. It could have been even simpler by not checking for OP, but I think that OPs should always have all permissions, since that in theory is the highest vanilla rank player can get. Only thing more powerful than OP is server itself, and that obviously doesn't have GameProfile, nor it should have restrictions

By itself, the API would already provide a default op-based implementation, along with permission controls for vanilla MC commands, Forge commands, bypassing spawn protection, and using command blocks.

And I agree that things like ranks and areas don't belong in the API.


The thing about "giving OPs all permissions" is that there's this pesky concept called "op permission levels" that Mojang introduced with a 1.8 snapshot (and is still in 1.9.4)

Despite being a part of server.properties, ops.json includes the "level" that you can set for an individual op.

From the MC Wiki:

Sets permission level for ops.
1 - Ops can bypass spawn protection. (trusted user?)
2 - Ops can use /clear, /difficulty, /effect, /gamemode, /gamerule, /give, and /tp, and can edit command blocks. (world mod?)
3 - Ops can use /ban, /deop, /kick, and /op. (global mod?)
4 - Ops can use /stop. (admin?)

Should the API respect this permission level setting (since it seems to be able to differ between ops) or not?

I think the default Vanilla impl Sponge defaults to when no other Permissions service is installed basically says that having OP means you have all permissions.

One thing I'd stress here is the abstraction layer should pay no mind to the OP setting by exposing it. This was a flaw ages past of Bukkit's system and has no place in a permissions framework. You have the perm or you don't...not that you are OP or not.

So we can just assume if you're an op, you have op perm level 4.

Ideally I'd like to patch this op permission level mess all away and replace it with "real" permissions, but I'd think that such a change might be too invasive.

I wouldn't patch it as its not really necessary. The OP level struct is a poor man's perm system and I don't know of anyone who actually uses it. Anyone wanting more than OP already use a server mod with permissions plugin.

@luacs1998 You should probably mention #1403 and the series of prs you've made.

So? I know the faults with those, and I'm rebooting the entire proposal from the beginning.

No need to dredge up the past.

@luacs1998 I agree with you on including levels on permissions. Most of the perm systems now does not have a leveling system, either it's sponge's, liteloader's, or someone other ones.

Actually sponge's subject system could replace the use of "op permission levels".

I agree with you on including levels on permissions. Most of the perm systems now does not have a leveling system, either it's sponge's, liteloader's, or someone other ones.

His point was to get away from leveling system as far as possible.
No sane permission system have predefined "levels" randomly chosen by someone with less than adequate perception of reality.

So @AnrDaemon we should use subjects to replace permission levels.

@liach People should understand that there can't just be a set amount of "levels" with increasing permissions to allow for proper modularity / dynamic permissions.

Say I want 2 groups for mods:

  • One, that manages the first group of players in Area A
  • The second one, that manages the 2nd group of players in Area B

If I now just tell them "Okay, you have permission level 2", they will be able to modify areas they are not supposed to modify.

Allowing a "string"-based system (or whatever else) that allows for modularity to say "okay, group A can modify in area A, and group B in area B", more people will be able to take advantage of that.


Best example I could think of right now...

we should use subjects to replace permission levels.

I don't understand your terminology. Can you please explain?

Actually it should be something like the one below which replaces leveled ops:
https://github.com/SpongePowered/SpongeAPI/blob/master/src/main/java/org/spongepowered/api/service/permission/SubjectCollection.java
This may also fulfill requirement of @Lordmau5

Yeah, to clarify a few things on Sponge's permission system:

  1. Sponge hooks as many places as possible where op status is checked and replaces them with permission checks. There are a lot of weird places this is done (got to check canSendCommands, canCommandSenderUseCommand/whatever the methods are called) and I don't think we have every single one, though most have been captured by now. iirc these are commands, spawn protection, and command blocks.
  2. The fallback permission implementation uses the ops.json, representing each op level as a separate group (op_1, op_2, etc). MC's commands have their permissions added to the appropriate op group (fallback is all transient so this is done on server start), and any op level above the configured op level in server.properties is given access to all commands.

Taking a brief look at the permissions API you're proposing, it seems decent. My current issues are:
1) It revolves too much around op/not-op status (in the hardcoded PermissionLevel enum that's used in a few places
2) The permission context fields are fixed -- this prevents mods/plugins from providing additional data. In Sponge contexts are a Map.Entry<String, String> -- I'm still not always happy with this but it provides the flexibility necessary to allow for plugins to step in and do a lot (for example in sponge we have srcip/destip, world, etc contexts, and PEX adds server-tag contexts, and a region plugin could add region contexts.

IF a permissions system was ever implemented in Forge it MUST be a simple API.
checkPremission("some.permission.path", OPTIONAL_context_info) boolean
The only complex part is figuring out how to structure that optional context.
Map would make sense and then a certin implementation of basic contexts:

IWorldContext implements IContext { getWorld() }
IPositionContext implements IWorldContext {getX/Y/Z}
IPlayerContext implements IEntityContext {getPlayer() }
IStringContext implements IContext{ getValue(); }

The caller would supply as much context as they can something like:

checkPermission("entity.interact", new ContextBuilder().add("player", new IPlayerContext()).add("target", new IEntityContext()).add("world", IWorldContext()).add("Billie Jean", IString("is not my lover")).build());

Then one handler could go:

if (key == "entity.interact"){
  if (context.contains("world"))
    return !context.get("world").getWorld().isNether() ? CANCEL : I_DONT_CARE; //No poking things in the nether!
  return I_DONT_CARE;
}

I_DONT_CARE could be false, depending on if we want to chain permission managers or what.
at the end of the chain it'd be a boolean.

Yeah, seems reasonable. To deal with the yes/no/idc in Sponge, we have a Tristate enum with TRUE/FALSE/UNDEFINED -- the subject decides what an undefined response from the permission service means (false for players, true for console, etc -- there are better ways to do this but only if you add a ton more api so it's probably good enough for forge).

I stayed away from folks subclassing context since that makes it harder for permissions plugins to serialize permissions associated with a specific context key-value pair. Instead, context objects are a String->String mapping, and plugins that understand a specific context register a callback function where they can receive matching checks and declare which contexts are active for a certain subject/ICommandSender. If you don't want to allow custom contexts or find a better solution, richer context objects would be nice to have though.

later edit
also, permission strings should be mod-namespaced as a convention -- so rather than entity.interact it would be myprotectionmod.entity.interact, or minecraft.command.toggledownfall and such)

Also we used a Set of contexts for sponge because a player may be in multiple contexts of the same type at any given time (regions for example) which makes things more flexible.

@LexManos :

IF a permissions system was ever implemented in Forge it MUST be a simple API.
checkPremission("some.permission.path", OPTIONAL_context_info) boolean

Already covered in the draft. One of the main issues here is how we can easily deal with contexts in a way that both modders and implementations can be happy.

I_DONT_CARE could be false, depending on if we want to chain permission managers or what.
at the end of the chain it'd be a boolean.

There will only be 1 permission manager - chaining more will just open a Pandora's Box that we don't want.


@zml2008

1) It revolves too much around op/not-op status (in the hardcoded PermissionLevel enum that's used in a few places

PermissionLevel is used to specify a default, and it's up to the implementation to specify what the respective levels mean. You would ignore DefaultPermissionProvider - that's just a fallback if no other implementor is found.

2) The permission context fields are fixed -- this prevents mods/plugins from providing additional data. In Sponge contexts are a Map.Entry<String, String> -- I'm still not always happy with this but it provides the flexibility necessary to allow for plugins to step in and do a lot (for example in sponge we have srcip/destip, world, etc contexts, and PEX adds server-tag contexts, and a region plugin could add region contexts.

As I mentioned in reply to Lex above, I'm looking for a middle ground where contexts are simple enough for everyone to understand, and would appreciate input on that.

I know Subject, Object and Permission.
The actor, the target, and the action itself.
What do you mean by "context"?

Why does context have to be a map? Why not interface IContext with hasWorld() getWorld() hasEntity() getEntity() hasPos() getPos() and hasString() getString()?
And new ContextBuilder().setWorld(..).setEntity(..); where ContextBuilder implements IContext.

I have a better question.
Shouldn't "context" be an internal state of the permission manager?
Where I agree with previous speakers, is that permission API must be simple. And tossing "context" around is as far from "simple" as I can imagine.

@AnrDaemon I dont think context is needed at all. You should request permission for player, no matter where he is. That's why my API only gives GameProfile (UUID would be enough, but GameProfile is cooler) and permission node (String, best lowercase, with at least one '.'). If the implementation wants to know something about player, go ahead, take UUID and retrieve player from PlayerList.getPlayerByUUID(uuid). I think that part should be completly up to implementation, and Forge should Only have that first part

The problem with having a single context class is that there can be multiple players., Multiple entities, multiple worlds.
Attacker and target for example.

Yes at the bare minimum you're going to have the ID of the thing asking for permission but you need contex for things like factions where you can claim chunks and the like.
An no Arn context being an internal state of the manager would be dumb...

Attacker is a subject, target is an object, attack is a permission.
If an implementation needs context of the attack, it will pull it by itself, when needed, from both target, subject, the world they are in, and chain it as far as it needs be.
You, as an API, simply don't know what context is essential for permission resolution, nor do you actually CARE about it. You can't make one up beforehand. Or we get back to predefined "levels" and less than adequate perception of reality.

Saying "it would be dumb" without providing an explanation doesn't really contribute to the discussion, IMO.

How exactly does it pull it by itself?
Pull it from where?
How do they know what to do?
Are you saying that instead of having a central instance of the permissions manager we instead pass one to every function allowing each function to modify the context how it sees fit?
How are you proposing this is actually implemented?
The API wont give a shit about whats in the context that's not what we are discussing.
We're discussing the general concept of adding that extra context data so that IF THE IMPLEMENTATION SO CHOOSES it can pull in that data.
See my above example about poking things in the nether. How do you propose to do that?

Why not just use hasPermission(EnityPlayer player, String node)?
A) you have world, player, position, whatever there
B) since permissions shouldnt be checked for offline players anyway, entityplayer would work

The world and position can be different.

I'm thinking of cases where machines/fakeplayers do something on behalf of a person.

Again, you DONT have the world, position, or anything.
How about block break? Okay you have the world, but you don't have the position.
You could try and guess the position by doing a raytrace from the player, but that's faulty and could produce the wrong values.
Context is needed, it's just trying to define how the context is given.

And yes, offline players will be checking permissions.
And players will always be checking permission cross dimensions.
Example being a player has placed down a block breaker.
Every time the breaker tries to break a block it needs to check if it can.
And it SHOULD check if it can under the guide of whoever placed it.

Ok, it kinda looks like you're all veering off into the direction of recreating a shitty version of events with string types and guessed event parameters in some context object. That's a bad idea.

So far, it seems like we're pretty settled on the most basic permission resolution method being:

public boolean hasPermission(ICommandSender source, String permission);

(I'm using ICommandSender here because I think it's reasonable for things like commandblock to have permissions, this should support at least EntityPlayerMP, possibly GameProfile, probably ICommandSender. Maybe a superinterface of the above is necessary (Subject is what we call it in Sponge)).

This would be a fully workable permisisons API on its own, and perform like at least 80% of what people want to do.

Remember, permissions should be little more than an entity-associated key-value store with inheritance. The goal is that you can control what actions are allowed or not _without_ having to write a mod. The way I see contexts, they describe the subject being checked to perform an action, _not_ the action itself. So context would say that you are in a certain world or region or connecting from a certain ip, not who you're attacking or what exactly you're attacking with (contexts have come out of an abstraction of world-specific permissions -- that is also ok and would also cover a lot of use cases).

As is, unless I'm sadly mistaken, this would not work for many things.....

  • Blood Magic projectile dig spell. Super long range, enough time for you to switch dimensions before it actually breaks anything.
  • IC2 Lazer.
  • Force fields from RFTools
  • Builder from RFTools
  • All curses from witchery.

Yeah, those are some situations when being able to specify contexts would be useful -- replace the current state of the player with the state of the item performing the action (or in the case of long-range projectiles, a snapshot of state at the time the projectile was launched). However, these are all more complex scenarios, imo part of the 20% outside of the single API method's usefulness.

In modded minecraft its more like 90% needs context, 10% doesn't. Modding is pretty much all about automation and doing things remotely. There needs to be a way to get that information.
And you've yet to address how the hell you're going to do my simple example at the top without any context.
I will say this, and let me be clear. THIS WILL NOT GO INTO FORGE WITHOUT A FORM OF PROVIDING CONTEXT.
I don't care what sponge does, I don't care what you can TRY and find from the consumers end There needs to be some design put in place.
Anyone who argues that point get the hell out of this thread.

Permissions is NOT a entity associated key-value store. It's a {something} associated key-value store.
This is not anything to do with writing a mod or not. You're not writing the mod OTHERS are.
At the end of the day you'll have a json file, or in game commands, or a gui or something that lets you manage the permissions checks. We are not talking about that part right now. This has nothing to do with the end user/server guy.

@zml2008

A bit of design background why contexts are needed:

  • Mods are not expected to check permissions themselves (though, if they wish, they are more than welcome to). Mods are expected to throw events (yes IC2 Mining Laser I'M LOOKING AT YOU!!).
  • These events will be caught by a "protection mod". It is through this protection mod that permissions will be checked. For example, a protection mod will be responsible for translating this:

@Cancelable
class BreakEvent extends BlockEvent
{
public BreakEvent(World world, BlockPos pos, IBlockState state, EntityPlayer player) {//method}
}

into

ContextBuilder builder = new ContextBuilder().add("player", new IPlayerContext(player)).add("target", new IBlockContext(pos, state)).add("world", IWorldContext(world)).build());

This translation will require the use of contexts - if not, what do you translate the World, the BlockPos, and the IBlockState to? Also, for example, a FakePlayer would also not pass in its FakePlayer object - it would pass in the object of the EntityPlayer that 'owns' the machine the FakePlayer is acting on behalf of. Both the subject and the action would also need to be checked, because there are mods out there that have location-based permissions, after all.

It is NOT Forge's place to perform the translation, it's up to whatever mod you have installed. But Forge must provide a means by which the translation can be done, hence the need for contexts.

  • Next, the API (which is the meat of our discussion here) will be called through this method call:
    checkPermission(player, "block.break", builder);.

The API, and by extension, Forge, will ONLY be responsible for passing the permission check and the relevant contexts to a permission implementation. Thus, any mod that checks permissions would neither need to know or care whether it's interacting with FE, PermissionsEx, ServerTools, or something else altogether.

  • The API will then pass it on to the permission implementation, which is then responsible for "unpacking" the package of contexts. The implementation is free to do as it sees fit regarding the contexts - it can act on them, ignore them, or something else altogether. This is none of our concern - the API is just a messenger. In the case of Sponge, I hope you will be passing it on to your permission plugins.

Think of it as solving a murder case. You have the suspect, the act, and the evidence. Can you prove, using the evidence, that the suspect committed the act and should be locked up (returning permission true)? Or do you let him walk a free man (returning false)? Or do you hand it to a fellow detective, as you're stumped and unsure how to proceed (returning null)? And it would be quite a miscarriage of justice if you just charged the suspect or just let him go free without checking the evidence.

Yes, it's not the API's place to care about _what_ contexts are given, but it's the API's place to ensure that contexts can be passed from someone calling the API to the implementation behind it, and in a format both parties can understand.


Some thoughts of mine:

I'm currently leaning towards Lex's method of defining contexts, but I'd also like to have a list of standardized keys that both "protection mods" and permission implementors can refer to. My idea is that a ContextBuilder would simply wrap a Map<String, IContext>, The String is a key that is hopefully part of the standardized keys, allowing the permission implementor to make an inference "hey, it's a sourceWorld, so it must be an IWorldContext", for example.

@LexManos , @luacs1998 - thanks for explanation. Much appreciated.

I suggest a context system like such a cause system. It handles different objects properly without a lot of useless fields.

That looks way heavy

These are the only fields:

    final Object[] cause;
    final String[] names;

    // lazy load
    @Nullable private Map<String, Object> namedObjectMap;
    @Nullable private ImmutableList<Object> immutableCauses;

All other things are utility methods that are optional when transplanting. This is not that 'heavy'.

I see no point in making special case for this type of mod. Can't those listen to Forge events like any other mod ? Why are "Permission" mods so lazy they need to receive help to make their code work ?
If it is about communicating, the inter mod communication tool already exist within Forge.

The point is there's not enough Forge events.
About 10% of what is necessary for permissions to function. Or even less.

There are plenty enough events for permissions to work and if there are not you need to start PRing the ones you need that has always been the case.
However to answer your question @GotoLink this is basically a extreme subset of the event system.
With a single 'mod' handling this one 'event' to look up the dictionary of if this 'event' should be canceled or not.

The way existing protection mods work already is exactly what you're talking about directly waiting for the forge events and canceling them as necessary.
There is no explicit NEED for this API except to provide OTHER MODDERS a standard way to query this information without firing a full, expensive event to the entire bus.

Yeah. Current event system may be enough for vanilla MC, but an unified permissions API provided by the server itself will encourage compatibility across whole mod community.
Less hacking and ASM'ing = healthier mod interaction = good for everyone involved.

Yeah, that's the way things have worked on Bukkit. A permissions plugin is a data provider only (shared between any interested plugin -- string-valued options are also supported by most), a world protection plugin maps between events thrown by mods/plugins and permissions, and general plugins can check permissions as they want (usually for commands/special features they add). The Bukkit permissions model tends to treat mods like Vanilla though -- it would have to be figured out how best to integrate mods if they can check permissions themselves and where it makes sense to behave like vanilla rather than do custom things.

luacs1998 did say that "Mods are expected to throw events" though.
If this suggestion is meant to avoid "expensive event" fired into the bus, i would imagine the API will need to ensure the implementation is good enough performance-wise. Maybe shipping a default implementation within Forge ?

I don't like the idea of an interpreted String within the permission arguments. That would usually end up being regex nightmare. Also, particularly strange to add the mod name inside it, as permissions should certainly not care about the mod itself, if this suggestion is supposed to encourage compatibility.
Better imo to put as much info as possible (like action intent, and optional indirect actor) within the context, and avoid differenciating mods.
Action intents can even be reduced to a small number of cases:
enum Intent{ ADD, DESTROY, LOOK, MOVE }

Permissions themselves don't care about anything. They are just indexes in the map.
I don't see, why would anyone try to use regular expressions to check permissions.
And what you are suggesting is precisely an overcomplicaition with tendency for unusability.
And, yes, Forge is expected to provide default implementation on the level enough to mimic the Vanilla OP levels management.

Events, broadly speaking, are only as expensive as their listeners thanks to how the bus is implemented.

No regex, that would murder performance :p

Screw this discussion start talking code.
How do you propose to make a permission system block removal of blocks in a region.
Simplest most common use of this system.
'Bob' Claim Chunk 0,0
'Sally' Trys place block at 0,0,0 while standing at 100,0,100
Write the code or STFU.

1)checkPermission(playerBob, "protectmod.claim", new ContextBuilder().add("startloc", new ILocationContext(0, 0, 0)).add("endloc", new ILocationContext(16, 255, 16));

2)checkPermission(playerSally, "protectmod.place.block", new ContextBuilder().add("location", new ILocationContext(0, 0, 0)); where the implementation is expected to ignore Sally's position and use the provided location context.

@williewillus added labels [Feature]

About the contexts:
There is absolutely no reason to add anything like a Map or anything extendable for contexts, because it's useless.
I know many of you guys want to have some extremly powerful API to allow for some crazy shit permission checks, but you are forgetting here that this should be a generalized API.

Reasons an extendable / Map context is useless:

  • Permission mods would have to know the custom context fields which they don't
  • Mods that add custom fields would not know what to add
  • There would be permission mods using different fields than others
  • There would be zero compatibility
  • Custom context fields only make sense if the mod that uses those in permission checks also requires a certain permission manager mod to be present
  • All context a mod could potentially provide can be passed by attaching it to the player

There are many more reasons I could think of but the result is that it's pretty much useless to add anything else than player, location and target kind of properties which are present pretty much all the time.

We have quite the experience with handling permission checks in FE and we probably also have the most powerful permission system that exists for MC right now (I didn't fully check Sponge's yet so I can't be 100% sure). Even so we ourselves could never really make use of all the context fields that we included in the api - it basically breaks down to the 3 attributes I mentioned before.

Also we shouldn't forget that adding such an API should allow small mods to easily allow permission control for certain features - it's targeted at a broad community and should allow to replace a lot of settings that had to be set statically in config files before.

If there are mods that need really special checks, they should instead directly target a certain permission framework and bypass the API (directly calling the permission mods functions).

If someone does not agree with this explanation: Try to find a situation that could not be handled this way which would still work with _any_ permission mod.

I'm thinking a list of standardized context names (perhaps available through a Constants class) that would allow the implementor to assume "hey, this calls itself a sourceWorld, hence it must be an IWorldContext."

You could break this convention at your own risk, of course.

And if you already define a constant list of context names, you could directly use an interface for it.
It breaks down to the same thing with the first one being more performant and easier to use.

EDIT:
We just need to make sure that the general context can hold the information that is the minimum needed for forge internal permission checks.

@olee @luacs1998 My permission API does exactly that ffs >.<

https://github.com/MinecraftForge/MinecraftForge/issues/2899#issuecomment-221828882

Well yeah of course the permission plugin shouldn't have to know about every possible context -- with a common API it's the job of plugins providing contexts to register the appropriate callback to determine matching -- so a region protection plugin would register a callback for its region context to determine whether or not that context applies to a certain subject. In sponge the implemented interface is https://github.com/SpongePowered/SpongeAPI/blob/master/src/main/java/org/spongepowered/api/service/context/ContextCalculator.java, which is registered in a PermissionService.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

jredfox picture jredfox  路  38Comments

AEnterprise picture AEnterprise  路  63Comments

fscan picture fscan  路  24Comments

williewillus picture williewillus  路  23Comments

mcenderdragon picture mcenderdragon  路  21Comments