fluxer/fluxer_gateway/src/guild/guild_permissions.erl
2026-02-17 12:22:36 +00:00

663 lines
24 KiB
Erlang

%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_permissions).
-define(ALL_PERMISSIONS, 16#FFFFFFFFFFFFFFFF).
-export([
get_member_permissions/3,
can_view_channel/4,
can_view_channel_by_permissions/4,
can_manage_channel/3,
can_access_message_by_permissions/3,
apply_channel_overwrites/5,
get_max_role_position/2,
find_member_by_user_id/2,
find_role_by_id/2,
find_channel_by_id/2
]).
-export_type([permission/0]).
-type permission() :: non_neg_integer().
-type user_id() :: integer().
-type role_id() :: integer().
-type channel_id() :: integer().
-type maybe_channel_id() :: channel_id() | undefined.
-type guild_state() :: map().
-type guild_data() :: map().
-type member() :: map().
-type role() :: map().
-type channel() :: map().
-type overwrite() :: map().
-type member_roles() :: [role_id()].
-type maybe_member() :: member() | undefined.
-spec get_member_permissions(user_id(), maybe_channel_id(), guild_state()) -> permission().
get_member_permissions(UserId, ChannelId, State) ->
compute_member_permissions(UserId, ChannelId, undefined, State).
-spec can_view_channel(user_id(), channel_id(), maybe_member(), guild_state()) -> boolean().
can_view_channel(UserId, ChannelId, Member, State) ->
guild_virtual_channel_access:has_virtual_access(UserId, ChannelId, State) orelse
can_view_channel_by_permissions(UserId, ChannelId, Member, State).
-spec can_view_channel_by_permissions(user_id(), channel_id(), maybe_member(), guild_state()) ->
boolean().
can_view_channel_by_permissions(UserId, ChannelId, Member, State) ->
Perms = compute_member_permissions(UserId, ChannelId, Member, State),
(Perms band constants:view_channel_permission()) =/= 0.
-spec can_manage_channel(user_id(), maybe_channel_id(), guild_state()) -> boolean().
can_manage_channel(UserId, ChannelId, State) ->
Perms = get_member_permissions(UserId, ChannelId, State),
(Perms band constants:manage_channels_permission()) =/= 0.
-spec can_access_message_by_permissions(permission(), binary(), guild_state()) -> boolean().
can_access_message_by_permissions(Permissions, MessageId, State) ->
HasReadHistory = (Permissions band constants:read_message_history_permission()) =/= 0,
case HasReadHistory of
true ->
true;
false ->
case get_message_history_cutoff(State) of
null ->
false;
CutoffMs ->
MessageMs = snowflake_util:extract_timestamp(MessageId),
MessageMs >= CutoffMs
end
end.
-spec get_message_history_cutoff(guild_state()) -> integer() | null.
get_message_history_cutoff(State) ->
case resolve_data_map(State) of
undefined ->
null;
Data ->
Guild = maps:get(<<"guild">>, Data, #{}),
case maps:get(<<"message_history_cutoff">>, Guild, null) of
null ->
null;
CutoffBin when is_binary(CutoffBin) ->
calendar:rfc3339_to_system_time(
binary_to_list(CutoffBin), [{unit, millisecond}]
);
CutoffInt when is_integer(CutoffInt) ->
CutoffInt
end
end.
-spec apply_channel_overwrites(permission(), user_id(), member_roles(), channel(), role_id()) ->
permission().
apply_channel_overwrites(BasePerms, UserId, MemberRoles, Channel, EveryoneRoleId) ->
Overwrites = channel_overwrites(Channel),
EveryonePerms = apply_everyone_overwrites(BasePerms, Overwrites, EveryoneRoleId),
{RoleAllow, RoleDeny} = accumulate_role_overwrites(MemberRoles, Overwrites),
RolePerms = (EveryonePerms band bnot RoleDeny) bor RoleAllow,
apply_user_overwrites(RolePerms, Overwrites, UserId).
-spec get_max_role_position(user_id(), guild_state()) -> integer().
get_max_role_position(UserId, State) ->
case {find_member_by_user_id(UserId, State), resolve_data_map(State)} of
{undefined, _} ->
-1;
{_, undefined} ->
-1;
{Member, Data} ->
Roles = guild_data_index:role_index(Data),
compute_max_position(Member, Roles)
end.
-spec compute_max_position(member(), [role()] | map()) -> integer().
compute_max_position(Member, Roles) ->
lists:foldl(
fun(RoleId, MaxPos) ->
case find_role_by_id(RoleId, Roles) of
undefined ->
MaxPos;
Role ->
Position = maps:get(<<"position">>, Role, 0),
max(Position, MaxPos)
end
end,
-1,
member_role_ids(Member)
).
-spec find_member_by_user_id(user_id(), guild_state()) -> member() | undefined.
find_member_by_user_id(UserId, State) when is_integer(UserId) ->
case resolve_data_map(State) of
undefined ->
undefined;
Data ->
Members = guild_data_index:member_map(Data),
maps:get(UserId, Members, undefined)
end;
find_member_by_user_id(_, _) ->
undefined.
-spec find_role_by_id(role_id(), [role()] | map()) -> role() | undefined.
find_role_by_id(RoleId, Roles) when is_map(Roles) ->
maps:get(to_int(RoleId), Roles, undefined);
find_role_by_id(RoleId, Roles) ->
TargetId = to_int(RoleId),
lists:foldl(
fun
(_, Found) when Found =/= undefined -> Found;
(Role, undefined) ->
case role_id(Role) =:= TargetId of
true -> Role;
false -> undefined
end
end,
undefined,
ensure_list(Roles)
).
-spec find_channel_by_id(channel_id(), guild_state()) -> channel() | undefined.
find_channel_by_id(ChannelId, State) when is_integer(ChannelId) ->
case resolve_data_map(State) of
undefined ->
undefined;
Data ->
Channels = guild_data_index:channel_index(Data),
maps:get(ChannelId, Channels, undefined)
end;
find_channel_by_id(_, _) ->
undefined.
-spec compute_member_permissions(user_id(), maybe_channel_id(), maybe_member(), guild_state()) ->
permission().
compute_member_permissions(UserId, ChannelId, ProvidedMember, State) when is_integer(UserId) ->
case resolve_data_map(State) of
undefined ->
0;
Data ->
OwnerId = guild_owner_id(Data),
case UserId =:= OwnerId of
true ->
?ALL_PERMISSIONS;
false ->
compute_non_owner_permissions(UserId, ChannelId, ProvidedMember, State, Data)
end
end;
compute_member_permissions(_, _, _, _) ->
0.
-spec compute_non_owner_permissions(
user_id(), maybe_channel_id(), maybe_member(), guild_state(), guild_data()
) ->
permission().
compute_non_owner_permissions(UserId, ChannelId, ProvidedMember, State, Data) ->
case resolve_member(UserId, ProvidedMember, State) of
undefined ->
0;
Member ->
GuildId = guild_id(State),
Roles = guild_data_index:role_index(Data),
BasePermissions = base_role_permissions(GuildId, Roles),
MemberRoles = member_role_ids(Member),
Permissions = aggregate_role_permissions(MemberRoles, Roles, BasePermissions),
case (Permissions band constants:administrator_permission()) =/= 0 of
true ->
?ALL_PERMISSIONS;
false ->
maybe_apply_channel_overwrites(
Permissions, UserId, MemberRoles, ChannelId, GuildId, State
)
end
end.
-spec resolve_member(user_id(), maybe_member(), guild_state()) -> maybe_member().
resolve_member(_UserId, Member, _State) when is_map(Member) ->
Member;
resolve_member(UserId, _Member, State) ->
find_member_by_user_id(UserId, State).
-spec guild_owner_id(guild_data()) -> user_id().
guild_owner_id(Data) ->
Guild = maps:get(<<"guild">>, Data, #{}),
to_int(maps:get(<<"owner_id">>, Guild, <<"0">>)).
-spec guild_id(guild_state()) -> integer().
guild_id(State) ->
case maps:get(id, State, undefined) of
undefined -> to_int(maps:get(<<"id">>, State, 0));
GuildId when is_integer(GuildId) -> GuildId;
GuildId -> to_int(GuildId)
end.
-spec base_role_permissions(role_id(), map()) -> permission().
base_role_permissions(GuildId, Roles) ->
case find_role_by_id(GuildId, Roles) of
undefined -> 0;
Role -> role_permissions(Role)
end.
-spec aggregate_role_permissions(member_roles(), [role()] | map(), permission()) -> permission().
aggregate_role_permissions(MemberRoles, Roles, BasePermissions) ->
lists:foldl(
fun(RoleId, Acc) ->
case find_role_by_id(RoleId, Roles) of
undefined -> Acc;
Role -> Acc bor role_permissions(Role)
end
end,
BasePermissions,
MemberRoles
).
-spec maybe_apply_channel_overwrites(
permission(), user_id(), member_roles(), maybe_channel_id(), role_id(), guild_state()
) -> permission().
maybe_apply_channel_overwrites(Permissions, _UserId, _MemberRoles, undefined, _GuildId, _State) ->
Permissions;
maybe_apply_channel_overwrites(Permissions, UserId, MemberRoles, ChannelId, GuildId, State) when
is_integer(ChannelId)
->
case find_channel_by_id(ChannelId, State) of
undefined -> Permissions;
Channel -> apply_channel_overwrites(Permissions, UserId, MemberRoles, Channel, GuildId)
end;
maybe_apply_channel_overwrites(Permissions, _UserId, _MemberRoles, _ChannelId, _GuildId, _State) ->
Permissions.
-spec member_role_ids(member()) -> member_roles().
member_role_ids(Member) ->
RoleIds = maps:get(<<"roles">>, Member, []),
extract_integer_list(RoleIds).
-spec role_permissions(role()) -> permission().
role_permissions(Role) ->
to_int(maps:get(<<"permissions">>, Role, <<"0">>)).
-spec role_id(role()) -> role_id().
role_id(Role) ->
to_int(maps:get(<<"id">>, Role, <<"0">>)).
-spec channel_overwrites(channel()) -> [overwrite()].
channel_overwrites(Channel) ->
case maps:get(<<"permission_overwrites">>, Channel, []) of
Overwrites when is_list(Overwrites) -> Overwrites;
_ -> []
end.
-spec apply_everyone_overwrites(permission(), [overwrite()], role_id()) -> permission().
apply_everyone_overwrites(BasePerms, Overwrites, EveryoneRoleId) ->
lists:foldl(
fun(Overwrite, Acc) ->
case overwrite_matches_role(Overwrite, EveryoneRoleId) of
true ->
apply_allow_deny(Acc, overwrite_allow(Overwrite), overwrite_deny(Overwrite));
false ->
Acc
end
end,
BasePerms,
Overwrites
).
-spec accumulate_role_overwrites(member_roles(), [overwrite()]) -> {permission(), permission()}.
accumulate_role_overwrites(MemberRoles, Overwrites) ->
lists:foldl(
fun(RoleId, {AllowAcc, DenyAcc}) ->
lists:foldl(
fun(Overwrite, {A, D}) ->
case overwrite_matches_role(Overwrite, RoleId) of
true -> {A bor overwrite_allow(Overwrite), D bor overwrite_deny(Overwrite)};
false -> {A, D}
end
end,
{AllowAcc, DenyAcc},
Overwrites
)
end,
{0, 0},
MemberRoles
).
-spec apply_user_overwrites(permission(), [overwrite()], user_id()) -> permission().
apply_user_overwrites(Perms, Overwrites, UserId) ->
lists:foldl(
fun(Overwrite, Acc) ->
case overwrite_matches_user(Overwrite, UserId) of
true ->
apply_allow_deny(Acc, overwrite_allow(Overwrite), overwrite_deny(Overwrite));
false ->
Acc
end
end,
Perms,
Overwrites
).
-spec overwrite_matches_role(overwrite(), role_id()) -> boolean().
overwrite_matches_role(Overwrite, RoleId) when is_map(Overwrite), is_integer(RoleId) ->
overwrite_type(Overwrite) =:= 0 andalso overwrite_id(Overwrite) =:= RoleId;
overwrite_matches_role(_, _) ->
false.
-spec overwrite_matches_user(overwrite(), user_id()) -> boolean().
overwrite_matches_user(Overwrite, UserId) when is_map(Overwrite), is_integer(UserId) ->
overwrite_type(Overwrite) =:= 1 andalso overwrite_id(Overwrite) =:= UserId;
overwrite_matches_user(_, _) ->
false.
-spec overwrite_id(overwrite()) -> integer().
overwrite_id(Overwrite) ->
to_int(maps:get(<<"id">>, Overwrite, <<"0">>)).
-spec overwrite_type(overwrite()) -> integer().
overwrite_type(Overwrite) ->
maps:get(<<"type">>, Overwrite, 0).
-spec overwrite_allow(overwrite()) -> permission().
overwrite_allow(Overwrite) ->
to_int(maps:get(<<"allow">>, Overwrite, <<"0">>)).
-spec overwrite_deny(overwrite()) -> permission().
overwrite_deny(Overwrite) ->
to_int(maps:get(<<"deny">>, Overwrite, <<"0">>)).
-spec apply_allow_deny(permission(), permission(), permission()) -> permission().
apply_allow_deny(Acc, Allow, Deny) ->
(Acc band bnot Deny) bor Allow.
-spec extract_integer_list(list()) -> [integer()].
extract_integer_list(List) when is_list(List) ->
lists:reverse(
lists:foldl(
fun(Value, Acc) ->
case type_conv:to_integer(Value) of
undefined -> Acc;
Int -> [Int | Acc]
end
end,
[],
List
)
);
extract_integer_list(_) ->
[].
-spec ensure_list(term()) -> list().
ensure_list(List) when is_list(List) -> List;
ensure_list(_) -> [].
-spec to_int(term()) -> integer().
to_int(Value) ->
case type_conv:to_integer(Value) of
undefined -> 0;
Int -> Int
end.
-spec resolve_data_map(guild_state() | map()) -> guild_data() | undefined.
resolve_data_map(State) when is_map(State) ->
case maps:find(data, State) of
{ok, Data} when is_map(Data) -> Data;
{ok, Data} ->
Data;
error ->
case maps:is_key(<<"members">>, State) of
true -> State;
false -> undefined
end
end;
resolve_data_map(_) ->
undefined.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
owner_receives_full_permissions_test() ->
OwnerId = 1,
GuildId = 100,
State = #{
id => GuildId,
data => #{
<<"guild">> => #{<<"owner_id">> => integer_to_binary(OwnerId)},
<<"roles">> => [#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => <<"0">>}]
}
},
?assertEqual(?ALL_PERMISSIONS, get_member_permissions(OwnerId, undefined, State)).
channel_scope_permissions_test() ->
GuildId = 42,
UserId = 600,
ChannelId = 700,
RoleId = 800,
View = constants:view_channel_permission(),
State = #{
id => GuildId,
data => #{
<<"guild">> => #{<<"owner_id">> => integer_to_binary(GuildId + 1)},
<<"roles">> => [
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => <<"0">>},
#{<<"id">> => integer_to_binary(RoleId), <<"permissions">> => <<"0">>}
],
<<"members">> => [
#{
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
<<"roles">> => [integer_to_binary(RoleId)]
}
],
<<"channels">> => [
#{
<<"id">> => integer_to_binary(ChannelId),
<<"permission_overwrites">> => [
#{
<<"id">> => integer_to_binary(GuildId),
<<"type">> => 0,
<<"allow">> => <<"0">>,
<<"deny">> => <<"0">>
},
#{
<<"id">> => integer_to_binary(RoleId),
<<"type">> => 0,
<<"allow">> => integer_to_binary(View),
<<"deny">> => <<"0">>
}
]
}
]
}
},
?assertEqual(0, get_member_permissions(UserId, undefined, State)),
ChannelPerms = get_member_permissions(UserId, ChannelId, State),
?assert((ChannelPerms band View) =/= 0).
apply_channel_overwrites_e2e_test() ->
View = constants:view_channel_permission(),
GuildId = 5,
RoleId = 9,
UserId = 11,
Channel = #{
<<"permission_overwrites">> => [
#{
<<"id">> => integer_to_binary(GuildId),
<<"type">> => 0,
<<"allow">> => <<"0">>,
<<"deny">> => integer_to_binary(View)
},
#{
<<"id">> => integer_to_binary(RoleId),
<<"type">> => 0,
<<"allow">> => integer_to_binary(View),
<<"deny">> => <<"0">>
},
#{
<<"id">> => integer_to_binary(UserId),
<<"type">> => 1,
<<"allow">> => <<"0">>,
<<"deny">> => integer_to_binary(View)
}
]
},
Base = View,
Result = apply_channel_overwrites(Base, UserId, [RoleId], Channel, GuildId),
?assertEqual(0, Result).
administrator_role_grants_all_permissions_test() ->
Admin = constants:administrator_permission(),
GuildId = 100,
UserId = 200,
ChannelId = 300,
OwnerId = 999,
State = #{
id => GuildId,
data => #{
<<"guild">> => #{<<"owner_id">> => integer_to_binary(OwnerId)},
<<"roles">> => [
#{
<<"id">> => integer_to_binary(GuildId),
<<"permissions">> => integer_to_binary(Admin)
}
],
<<"members">> => [
#{<<"user">> => #{<<"id">> => integer_to_binary(UserId)}, <<"roles">> => []}
],
<<"channels">> => [
#{<<"id">> => integer_to_binary(ChannelId), <<"permission_overwrites">> => []}
]
}
},
?assertEqual(?ALL_PERMISSIONS, get_member_permissions(UserId, undefined, State)),
?assertEqual(?ALL_PERMISSIONS, get_member_permissions(UserId, ChannelId, State)),
?assert(can_view_channel(UserId, ChannelId, undefined, State)).
find_member_by_user_id_found_test() ->
State = #{
data => #{
<<"members">> => [
#{<<"user">> => #{<<"id">> => <<"123">>}, <<"nick">> => <<"Test">>}
]
}
},
Result = find_member_by_user_id(123, State),
?assertEqual(<<"Test">>, maps:get(<<"nick">>, Result)).
find_member_by_user_id_not_found_test() ->
State = #{data => #{<<"members">> => []}},
?assertEqual(undefined, find_member_by_user_id(123, State)).
find_member_by_user_id_map_storage_test() ->
State = #{
data => #{
<<"members">> => #{
321 => #{<<"user">> => #{<<"id">> => <<"321">>}, <<"nick">> => <<"Mapped">>}
}
}
},
Result = find_member_by_user_id(321, State),
?assertEqual(<<"Mapped">>, maps:get(<<"nick">>, Result)).
find_role_by_id_found_test() ->
Roles = [#{<<"id">> => <<"100">>, <<"name">> => <<"Admin">>}],
Result = find_role_by_id(100, Roles),
?assertEqual(<<"Admin">>, maps:get(<<"name">>, Result)).
find_role_by_id_not_found_test() ->
Roles = [#{<<"id">> => <<"100">>}],
?assertEqual(undefined, find_role_by_id(999, Roles)).
find_role_by_id_map_index_test() ->
Roles = #{
100 => #{<<"id">> => <<"100">>, <<"name">> => <<"Admin">>}
},
Result = find_role_by_id(100, Roles),
?assertEqual(<<"Admin">>, maps:get(<<"name">>, Result)).
find_channel_by_id_with_index_test() ->
State = #{
data => #{
<<"channels">> => [#{<<"id">> => <<"900">>, <<"name">> => <<"general">>}],
<<"channel_index">> => #{900 => #{<<"id">> => <<"900">>, <<"name">> => <<"general">>}}
}
},
Result = find_channel_by_id(900, State),
?assertEqual(<<"general">>, maps:get(<<"name">>, Result)).
to_int_test() ->
?assertEqual(123, to_int(123)),
?assertEqual(123, to_int(<<"123">>)),
?assertEqual(0, to_int(undefined)).
ensure_list_test() ->
?assertEqual([1, 2], ensure_list([1, 2])),
?assertEqual([], ensure_list(undefined)),
?assertEqual([], ensure_list(#{})).
can_access_message_with_read_history_test() ->
ReadHistory = constants:read_message_history_permission(),
State = #{data => #{<<"guild">> => #{}}},
MessageId = <<"100">>,
?assertEqual(true, can_access_message_by_permissions(ReadHistory, MessageId, State)).
can_access_message_no_read_history_no_cutoff_test() ->
State = #{data => #{<<"guild">> => #{}}},
MessageId = <<"100">>,
?assertEqual(false, can_access_message_by_permissions(0, MessageId, State)).
can_access_message_no_read_history_null_cutoff_test() ->
State = #{data => #{<<"guild">> => #{<<"message_history_cutoff">> => null}}},
MessageId = <<"100">>,
?assertEqual(false, can_access_message_by_permissions(0, MessageId, State)).
can_access_message_no_read_history_message_before_cutoff_test() ->
CutoffMs = 1704067200000,
BeforeCutoffTimestamp = CutoffMs - 60000,
FluxerEpoch = 1420070400000,
RelativeTs = BeforeCutoffTimestamp - FluxerEpoch,
Snowflake = RelativeTs bsl 22,
MessageId = integer_to_binary(Snowflake),
State = #{data => #{<<"guild">> => #{<<"message_history_cutoff">> => CutoffMs}}},
?assertEqual(false, can_access_message_by_permissions(0, MessageId, State)).
can_access_message_no_read_history_message_after_cutoff_test() ->
CutoffMs = 1704067200000,
AfterCutoffTimestamp = CutoffMs + 60000,
FluxerEpoch = 1420070400000,
RelativeTs = AfterCutoffTimestamp - FluxerEpoch,
Snowflake = RelativeTs bsl 22,
MessageId = integer_to_binary(Snowflake),
State = #{data => #{<<"guild">> => #{<<"message_history_cutoff">> => CutoffMs}}},
?assertEqual(true, can_access_message_by_permissions(0, MessageId, State)).
can_access_message_no_read_history_message_at_cutoff_test() ->
CutoffMs = 1704067200000,
FluxerEpoch = 1420070400000,
RelativeTs = CutoffMs - FluxerEpoch,
Snowflake = RelativeTs bsl 22,
MessageId = integer_to_binary(Snowflake),
State = #{data => #{<<"guild">> => #{<<"message_history_cutoff">> => CutoffMs}}},
?assertEqual(true, can_access_message_by_permissions(0, MessageId, State)).
can_access_message_with_rfc3339_cutoff_test() ->
CutoffBin = <<"2024-01-01T00:00:00Z">>,
CutoffMs = calendar:rfc3339_to_system_time("2024-01-01T00:00:00Z", [{unit, millisecond}]),
AfterCutoffTimestamp = CutoffMs + 60000,
FluxerEpoch = 1420070400000,
RelativeTs = AfterCutoffTimestamp - FluxerEpoch,
Snowflake = RelativeTs bsl 22,
MessageId = integer_to_binary(Snowflake),
State = #{data => #{<<"guild">> => #{<<"message_history_cutoff">> => CutoffBin}}},
?assertEqual(true, can_access_message_by_permissions(0, MessageId, State)).
-endif.