Skip to content

Beginnings of a permission layer#2530

Open
Wunka wants to merge 37 commits intoPixelGuys:masterfrom
Wunka:permissionLayer
Open

Beginnings of a permission layer#2530
Wunka wants to merge 37 commits intoPixelGuys:masterfrom
Wunka:permissionLayer

Conversation

@Wunka
Copy link
Contributor

@Wunka Wunka commented Jan 30, 2026

This is a first step in the direction of #1961

Usage:
Users / Groups can be either whitelisted or blacklisted from a permissionPath.
For example a user can be granted the permission: "command" but be blacklisted from "command/spawn"
This would mean the user can run any command except spawn.

Users can be added to groups.
User permissions precede the group permissions, so if a user is blacklisted from the spawn command and joines a group who is whitelisted for the spawn command, the user would still have no permission to use spawn.

For handling all this 2 Commands are introduced:

  • /perm to add permssionPaths to the whitelist / blacklist of the user or a certain group
  • /group to create / delete / join or leave groups

Tasks for a complete permission layer (can be follow-up issues):

@IntegratedQuantum
Copy link
Member

Could you give an example of how this would look?

Also one relevant case would be to allow for localized permissions to e.g. forbid editing in a region (#81)
Would this fit into your system?

@Wunka
Copy link
Contributor Author

Wunka commented Jan 30, 2026

Example for /spawn: (we could also add the prefix command to show that we are talking about command permissions)
server hoster has complete access so: user.addPermision("spawn.*")
other users can only get their spawnpoint: user.addPermision("spawn.get")

you can chain the dots as long as we don't reach the limit with recursions (for checking and adding permissions)
so making something like user.addPermission("blocks.protecotor.[some way to identify the explicit block]") would be possible

@IntegratedQuantum
Copy link
Member

Yeah, that seems reasonable (though with how much this is like a file system, I'd prefer if it used slashes).
For the protector block it would probably not address the block directly but rather mentioning a string that can be shared for multiple protector blocks (otherwise it would be a nightmare to add another player to your region).

And of course we should pick a reasonable hierarchy, there should be a subcategory for commands, and inside of commands there should be a subcategory of get commands.

Also one problem I see in your code is that if you enable a parent node then all the subtree information is lost. So if I give you permissions to run all commands, then take this permission away at a later point, you cannot even run /help anymore.
On the other hand it could also be reasonable to be able to disable child nodes from an enabled parent (maybe I want you to be able to run all commands except from world edit commands, or all getter commands, except from getting the seed).

@Wunka
Copy link
Contributor Author

Wunka commented Jan 30, 2026

okay so to adresss a few of your things I think I need somekind of global saved permission tree. So I think I will change the system so that as an example commands register themself like registerPermission("command/get/spawn") that way we have a complete set.

if you enable a parent node then all the subtree information is lost. So if I give you permissions to run all commands, then take this permission away at a later point, you cannot even run /help anymore.

I think I will introduce an optional fn checkPermission(msg) bool to _commands.zig which in default checks just the command name (like it is now) and for help: overwrite it so that it just checks the permission of the command you want to run help on.

On the other hand it could also be reasonable to be able to disable child nodes from an enabled parent (maybe I want you to be able to run all commands except from world edit commands, or all getter commands, except from getting the seed).

This is already supported? There is just not a good way to enable that. But through the global saved permission tree you could do things like: user.addPermission("commands/get/*): user.removePermission("commands/get/seed");

Also in that regard what do you think about * ? should it mabe just be usable at the top so user.addPermission("*") and for commands you don't need it. Just: user.addPermission("commands/get") to get permission to all get commands

@IntegratedQuantum
Copy link
Member

okay so to adresss a few of your things I think I need somekind of global saved permission tree.

I don't see how a global permission tree makes this easier, if at all I think it adds more edge cases, since the tree can change between updates (or just during normal gameplay).

@Wunka
Copy link
Contributor Author

Wunka commented Jan 30, 2026

This is already supported? There is just not a good way to enable that. But through the global saved permission tree you could do things like: user.addPermission("commands/get/*): user.removePermission("commands/get/seed");

A lot can probably be done without the global tree but this not for what I see.
(execept I introduce being able to switch between whitelist and blacklist)

@Wunka
Copy link
Contributor Author

Wunka commented Jan 30, 2026

The tree could maybe be also only used to get a picture of the current tree. So "*" instead of giving .all could just loop over all subPermissions in the global tree and add them all to the user.

@IntegratedQuantum
Copy link
Member

(execept I introduce being able to switch between whitelist and blacklist)

Yeah, that's what I would propose, just add an enum to each node which has 3 states allowed, forbidden and neutral.
Then during iteration you have to go the entire way from top to bottom of the tree and switch states.

Another approach would be to kill recursion (and I'd always be in favor of that), and have just a single per player white and a single per player black list with the full permission path, then to check permission you'd basically just do:

var iterator = std.mem.findRightMostIteratorWhateverIDontKnow(permissionPath, '/');
while(iterator.next()) |endIndex| {
    if(blacklist.has(permissionPath[0..endIndex])) return false;
    if(whitelist.has(permissionPath[0..endIndex])) return true;
}
return false;

@Wunka
Copy link
Contributor Author

Wunka commented Jan 30, 2026

Another approach would be to kill recursion (and I'd always be in favor of that), and have just a single per player white and a single per player black list with the full permission path, then to check permission you'd basically just do:

Do I understand that right that your findRightMostIteratorWhateverIDontKnow would make out of example "command/get/spawn" -> {"command/get/spawn", "command/get", "command"} ?
that way all whitelist/blacklist would only work on the deepest specialization?
If yes I think I will do that.

@IntegratedQuantum
Copy link
Member

Yeah, exactly.
And the list itself could just be a StringHashMapUnmanaged(void)

@Wunka
Copy link
Contributor Author

Wunka commented Jan 30, 2026

Changed to the black/whitelist system. (much better)
While on that I also added groups.

For the whitelist / blacklist system as long as one group has whitelist access you are good to go.
But the userlists have still higher priority so if a user is blacklisted from a command the game doesn't care even if he is part of some root group which has access to everything.

And for commands in general: I don't want to spend as much time into things like seperate permissions for the seperate subcommands as I think this would be much easier with #1425

@IntegratedQuantum
Copy link
Member

The other important thing would be sharing with the client. This is relevant for anything that has client-side prediction (e.g. gamemode permissions, protector block, #2414, ...)

For that I'd suggest to precompute the full set of permission from the user+group permission and send that to the client on each change. I think that would be easier and more efficient.

Also another important reminder: Add testing! The permission system should have unit tests for all of its functionality.

@Wunka
Copy link
Contributor Author

Wunka commented Jan 31, 2026

added tests and updated the description of the PR to reflect the changes

@Wunka
Copy link
Contributor Author

Wunka commented Jan 31, 2026

Oh what I also wanted to mention is that I am thinking of adding a kinda root permission. currently there is no way for someone to have access to literally everything (without specifing every top level permission like "command"). So maybe if someone has access to "root" or some other keyword they just skip every check.

Or we start everything with "/" and then you can get access to that

@IntegratedQuantum
Copy link
Member

Starting everything with a slash seems to be a reasonable option.

@Wunka
Copy link
Contributor Author

Wunka commented Feb 1, 2026

The permissions / groups are now saved into zon files. (player permission in player zon and groups into a new file groups.zig.zon)
Before this can be passed up as a bare bones system. there is one problem I don't know how to adrress.
See this scenario:

  1. group created with name "group1"
  2. "player1" joines "group1"
  3. "player1" leaves the server
  4. "group1" is deleted (currently only online players are updated and offlines ones are when they join the server)
  5. another person creates his own group, but it has the same name "group1"
  6. "player1" joines the server again, but is not not removed from "group1" as it has the same name

So either we update every player zon file when a group is deleted
or
groups save the players instead (I think this solves the problem better, but I would like some input)

@Wunka Wunka mentioned this pull request Feb 2, 2026
Copy link
Member

@IntegratedQuantum IntegratedQuantum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So either we update every player zon file when a group is deleted
or
groups save the players instead (I think this solves the problem better, but I would like some input)

groups saving which players use them might cause other problems.

What we can do instead is give groups a unique ID. Every time you delete a group id, you don't reuse it, and when you load a player with a deleted id, then you can just detect this and remove it.

Also a thought on multithreading: I want to multithread player actions in the future, so we need to support reading permissions from multiple threads. In order to achieve this without adding locks everywhere, I'd suggest to add assertions to every modifying function that assert that we are on the server thread (see sync.threadContext).

Because of the commands there is also now the requirement that groups don't start with "/"

Why is that a problem?

} else if (std.ascii.eqlIgnoreCase(arg, "join")) {
op = .join;
} else if (std.ascii.eqlIgnoreCase(arg, "leave")) {
op = .leave;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

std.meta.stringToEnum()

Comment on lines 20 to 21
.string => |string| fillMapHelper(allocator, map, string),
.stringOwned => |string| fillMapHelper(allocator, map, string),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for a poorly named helper:

Suggested change
.string => |string| fillMapHelper(allocator, map, string),
.stringOwned => |string| fillMapHelper(allocator, map, string),
.string, .stringOwned => |string| {
...
},

Comment on lines 16 to 18
if (zon != .array) return;

for (zon.array.items) |item| {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (zon != .array) return;
for (zon.array.items) |item| {
for (zon.toSlice()) |item| {

map.put(allocator.allocator, duped, {}) catch unreachable;
}

fn fillMap(allocator: NeverFailingAllocator, map: *std.StringHashMapUnmanaged(void), zon: ZonElement) void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer symmetrical naming, mapFromZon, mapToZon

}

fn mapToZon(allocator: NeverFailingAllocator, map: *std.StringHashMapUnmanaged(void)) ZonElement {
var zon: ZonElement = .initArray(allocator);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
var zon: ZonElement = .initArray(allocator);
const zon: ZonElement = .initArray(allocator);

var it = std.mem.splitBackwardsScalar(u8, permissionPath, '/');
var current = permissionPath;

while (it.next()) |path| {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this iterator is not really suited, I'd suggest

while(std.mem.lastIndexOfScalar(u8, current, '/)) |nextPos| {
    ...
    current = current[0..nextPos];
}

Comment on lines 86 to 87
const _key = self.list(listType).getKeyPtr(permissionPath);
if (_key) |key| {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const _key = self.list(listType).getKeyPtr(permissionPath);
if (_key) |key| {
const key = self.list(listType).getKeyPtr(permissionPath) orelse return false;

fillMap(self.allocator, self.list(listType), zon);
}

pub fn listToZon(self: *Permissions, allocator: NeverFailingAllocator, listType: ListType) ZonElement {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of leaving the serialization of both lists to the caller, I'd prefer a function that stores it all, you can do this by taking the ZonElement instead of returning it.

pub const PermissionGroup = struct {
allocator: NeverFailingAllocator,
permissions: Permissions,
members: std.StringHashMapUnmanaged(void) = .{},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You do a lot of bookkeeping here, but I cannot see any place where you actually use these for anything.

groups.put(allocator.dupe(u8, name), .init(allocator)) catch unreachable;
}

pub fn deleteGroup(name: []const u8) bool {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many of these C-style functions should be member functions (of either the PermissionGroup or the User or if you want them in this file, then I'd suggest a wrapper struct around all the stuff that's stored in the player) with only a single global getGroup(name) function to access them by name.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything that has not directly something to do with the groups list is now somewhere else.
everything else is also for now I think done.

One problem I have is that the asserts to cause the tests to fail...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case I'd suggest to just disable the assert when in testing (do this inside the assert function)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added. Now all test pass again

@Wunka
Copy link
Contributor Author

Wunka commented Feb 3, 2026

Why is that a problem?

poorly made commands... thats the problem. will fix when I get back to this

Copy link
Member

@IntegratedQuantum IntegratedQuantum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also now that I think about more, you are freeing resources everywhere, so in order to access them from other threads you need to either make use of garbage collection or also assert that they are on the server thread.

pub const mask = @import("worldedit/mask.zig");
pub const replace = @import("worldedit/replace.zig");

pub const perm = @import("permission/perm.zig");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sort them alphabetically, permission comes before worledit

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Files should be named in snake_case, unless they have top-level fields.
Also I would suggest to just call it permission

Comment on lines 12 to 14
if (zon != .array) return;

for (zon.toSlice()) |item| {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check is unnecessary, toSlice does the check internally.

Suggested change
if (zon != .array) return;
for (zon.toSlice()) |item| {
for (zon.toSlice()) |item| {

const ZonElement = main.ZonElement;
const sync = main.sync;

fn mapFromZon(allocator: NeverFailingAllocator, map: *std.StringHashMapUnmanaged(void), zon: ZonElement) void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a thought, but we have a specific hashmap, and two member functions, how about making a wrapper struct for it?

return zon;
}

pub const Permissions = struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add MARK comments for all the big types and the start of the test section

}
};

var groups: std.StringHashMap(PermissionGroup) = undefined;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you forgot to reset the groups. I'd suggest to tie the init/deinit call to the world init/deinit to prevent any problems with persisting resources.

groups.deinit();
}

pub fn groupsToZon(allocator: NeverFailingAllocator) ZonElement {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could unified with init (see comment above)
or if not, should assert that groups is empty.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I unified init and groupsFromZon.
I let groupsToZon stay if we want to save it sometimes in between joining and leaving a world.

Wunka and others added 3 commits February 6, 2026 20:12
Co-authored-by: IntegratedQuantum <43880493+IntegratedQuantum@users.noreply.github.com>
Co-authored-by: IntegratedQuantum <43880493+IntegratedQuantum@users.noreply.github.com>
Co-authored-by: IntegratedQuantum <43880493+IntegratedQuantum@users.noreply.github.com>
@Wunka Wunka mentioned this pull request Feb 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants