api-surface #1
2 changed files with 279 additions and 0 deletions
feat(permissions): documented overwrite-resolution algorithm on Guild
commit
b9f1ced136
|
|
@ -61,6 +61,132 @@ defmodule Dexcord.Guild do
|
|||
@doc "The creation `DateTime` encoded in this guild's id, or `:error`."
|
||||
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
|
||||
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
|
||||
|
||||
import Bitwise
|
||||
|
||||
@all_permissions Dexcord.Permissions.all() |> Map.values() |> Enum.reduce(0, &Bitwise.bor/2)
|
||||
@timeout_allowed Bitwise.bor(
|
||||
Dexcord.Permissions.all()[:view_channel],
|
||||
Dexcord.Permissions.all()[:read_message_history]
|
||||
)
|
||||
|
||||
@doc """
|
||||
Computes a member's effective permissions in the guild (arity 2) or in a
|
||||
specific channel (arity 3, applying permission overwrites and the timeout
|
||||
rule).
|
||||
|
||||
`guild.roles` must be populated — when reading from the cache, set them first:
|
||||
`%{guild | roles: Dexcord.Cache.roles(guild.id)}`. Returns the raw permissions
|
||||
integer; combine with `Dexcord.Permissions.has?/2`.
|
||||
|
||||
Implements Discord's documented algorithm: owner → all; base = the @everyone
|
||||
role (`role id == guild id`) OR'd with each member role; `ADMINISTRATOR` → all,
|
||||
skipping overwrites entirely; otherwise channel overwrites in order —
|
||||
@everyone (deny then allow), aggregated role overwrites (all denies OR'd, all
|
||||
allows OR'd, deny before allow), then the member overwrite; finally the timeout
|
||||
rule (a member whose `communication_disabled_until` is in the future keeps only
|
||||
`VIEW_CHANNEL | READ_MESSAGE_HISTORY`, unless owner/administrator — a null or
|
||||
past value is NOT a timeout).
|
||||
"""
|
||||
@spec member_permissions(t(), Dexcord.Member.t(), term()) :: non_neg_integer()
|
||||
def member_permissions(guild, member, channel \\ nil)
|
||||
|
||||
def member_permissions(%{} = guild, member, channel) do
|
||||
user_id = member_user_id(member)
|
||||
|
||||
cond do
|
||||
guild.owner_id == user_id ->
|
||||
@all_permissions
|
||||
|
||||
true ->
|
||||
base = base_permissions(guild, member)
|
||||
|
||||
if has_flag?(base, :administrator) do
|
||||
@all_permissions
|
||||
else
|
||||
base
|
||||
|> apply_channel_overwrites(guild, member, user_id, channel)
|
||||
|> apply_timeout(member)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp member_user_id(member), do: member.user_id || (member.user && member.user.id)
|
||||
|
||||
defp has_flag?(perms, flag), do: Dexcord.Permissions.has?(perms, flag)
|
||||
|
||||
# base = @everyone role perms OR'd with each member role's perms (unknown role
|
||||
# ids contribute nothing).
|
||||
defp base_permissions(guild, member) do
|
||||
Enum.reduce(member.roles, role_permissions(guild, guild.id), fn role_id, acc ->
|
||||
bor(acc, role_permissions(guild, role_id))
|
||||
end)
|
||||
end
|
||||
|
||||
defp role_permissions(guild, role_id) do
|
||||
case Enum.find(guild.roles, fn role -> role.id == role_id end) do
|
||||
nil -> 0
|
||||
role -> role.permissions || 0
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_channel_overwrites(perms, _guild, _member, _user_id, nil), do: perms
|
||||
|
||||
defp apply_channel_overwrites(perms, guild, member, user_id, channel) do
|
||||
overwrites = channel.permission_overwrites || []
|
||||
|
||||
perms
|
||||
|> apply_everyone_overwrite(overwrites, guild.id)
|
||||
|> apply_role_overwrites(overwrites, member.roles)
|
||||
|> apply_member_overwrite(overwrites, user_id)
|
||||
end
|
||||
|
||||
defp apply_everyone_overwrite(perms, overwrites, guild_id) do
|
||||
case Enum.find(overwrites, fn ow -> ow.id == guild_id end) do
|
||||
nil -> perms
|
||||
ow -> apply_deny_allow(perms, ow.deny, ow.allow)
|
||||
end
|
||||
end
|
||||
|
||||
# All role overwrites for the member's roles are aggregated: denies OR'd, allows
|
||||
# OR'd, then applied deny-before-allow as a single tier.
|
||||
defp apply_role_overwrites(perms, overwrites, member_roles) do
|
||||
{deny, allow} =
|
||||
overwrites
|
||||
|> Enum.filter(fn ow -> ow.id in member_roles end)
|
||||
|> Enum.reduce({0, 0}, fn ow, {deny, allow} ->
|
||||
{bor(deny, ow.deny || 0), bor(allow, ow.allow || 0)}
|
||||
end)
|
||||
|
||||
apply_deny_allow(perms, deny, allow)
|
||||
end
|
||||
|
||||
defp apply_member_overwrite(perms, overwrites, user_id) do
|
||||
case Enum.find(overwrites, fn ow -> ow.id == user_id end) do
|
||||
nil -> perms
|
||||
ow -> apply_deny_allow(perms, ow.deny, ow.allow)
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_deny_allow(perms, deny, allow) do
|
||||
perms
|
||||
|> band(bnot(deny || 0))
|
||||
|> bor(allow || 0)
|
||||
end
|
||||
|
||||
defp apply_timeout(perms, member) do
|
||||
case member.communication_disabled_until do
|
||||
%DateTime{} = cdu ->
|
||||
if DateTime.compare(cdu, DateTime.utc_now()) == :gt do
|
||||
band(perms, @timeout_allowed)
|
||||
else
|
||||
perms
|
||||
end
|
||||
|
||||
_ ->
|
||||
perms
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Dexcord.UnavailableGuild do
|
||||
|
|
|
|||
153
test/dexcord/permissions_compute_test.exs
Normal file
153
test/dexcord/permissions_compute_test.exs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
defmodule Dexcord.PermissionsComputeTest do
|
||||
@moduledoc """
|
||||
Pure tests for `Dexcord.Guild.member_permissions/2,3` — the documented
|
||||
overwrite-resolution algorithm (owner → base roles → ADMINISTRATOR short-circuit
|
||||
→ @everyone/role/member overwrites → timeout rule), verified verbatim.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Bitwise
|
||||
|
||||
alias Dexcord.Guild
|
||||
alias Dexcord.Member
|
||||
alias Dexcord.Overwrite
|
||||
alias Dexcord.Permissions
|
||||
alias Dexcord.Role
|
||||
|
||||
@send Permissions.from_list([:send_messages])
|
||||
@view Permissions.from_list([:view_channel])
|
||||
@rmh Permissions.from_list([:read_message_history])
|
||||
@admin Permissions.from_list([:administrator])
|
||||
@ban Permissions.from_list([:ban_members])
|
||||
@all Permissions.all() |> Map.values() |> Enum.reduce(0, &bor/2)
|
||||
@timeout_allowed bor(@view, @rmh)
|
||||
|
||||
# Guild id 1; @everyone role has id == guild id (== 1).
|
||||
defp guild(roles), do: %Guild{id: 1, owner_id: 100, roles: roles}
|
||||
defp everyone(perms), do: %Role{id: 1, permissions: perms}
|
||||
defp role(id, perms), do: %Role{id: id, permissions: perms}
|
||||
defp member(user_id, role_ids), do: %Member{user_id: user_id, roles: role_ids}
|
||||
|
||||
test "the guild owner gets all permissions" do
|
||||
g = guild([everyone(0)])
|
||||
m = member(100, [])
|
||||
assert Guild.member_permissions(g, m) == @all
|
||||
end
|
||||
|
||||
test "an ADMINISTRATOR member gets all permissions, even with a deny-everything channel overwrite" do
|
||||
g = guild([everyone(0), role(20, @admin)])
|
||||
m = member(200, [20])
|
||||
|
||||
channel = %Dexcord.TextChannel{
|
||||
id: 5,
|
||||
permission_overwrites: [%Overwrite{id: 1, type: :role, allow: 0, deny: @all}]
|
||||
}
|
||||
|
||||
assert Guild.member_permissions(g, m, channel) == @all
|
||||
end
|
||||
|
||||
test "base permissions are the OR of @everyone and every member role" do
|
||||
g = guild([everyone(@view), role(10, @send), role(11, @ban)])
|
||||
m = member(200, [10, 11])
|
||||
|
||||
assert Guild.member_permissions(g, m) == (@view ||| @send ||| @ban)
|
||||
end
|
||||
|
||||
test "unknown member role ids are skipped" do
|
||||
g = guild([everyone(@view), role(10, @send)])
|
||||
m = member(200, [10, 999])
|
||||
|
||||
assert Guild.member_permissions(g, m) == (@view ||| @send)
|
||||
end
|
||||
|
||||
test "an @everyone channel overwrite that denies SEND_MESSAGES removes it" do
|
||||
g = guild([everyone(@view), role(10, @send)])
|
||||
m = member(200, [10])
|
||||
|
||||
channel = %Dexcord.TextChannel{
|
||||
id: 5,
|
||||
permission_overwrites: [%Overwrite{id: 1, type: :role, allow: 0, deny: @send}]
|
||||
}
|
||||
|
||||
result = Guild.member_permissions(g, m, channel)
|
||||
refute Permissions.has?(result, :send_messages)
|
||||
assert Permissions.has?(result, :view_channel)
|
||||
end
|
||||
|
||||
test "a role overwrite allow restores a bit denied by @everyone (deny then allow order)" do
|
||||
g = guild([everyone(@view), role(10, @send)])
|
||||
m = member(200, [10])
|
||||
|
||||
channel = %Dexcord.TextChannel{
|
||||
id: 5,
|
||||
permission_overwrites: [
|
||||
%Overwrite{id: 1, type: :role, allow: 0, deny: @send},
|
||||
%Overwrite{id: 10, type: :role, allow: @send, deny: 0}
|
||||
]
|
||||
}
|
||||
|
||||
result = Guild.member_permissions(g, m, channel)
|
||||
assert Permissions.has?(result, :send_messages)
|
||||
end
|
||||
|
||||
test "a member overwrite deny wins over a role overwrite allow (member applied last)" do
|
||||
g = guild([everyone(@view), role(10, 0)])
|
||||
m = member(200, [10])
|
||||
|
||||
channel = %Dexcord.TextChannel{
|
||||
id: 5,
|
||||
permission_overwrites: [
|
||||
%Overwrite{id: 10, type: :role, allow: @send, deny: 0},
|
||||
%Overwrite{id: 200, type: :member, allow: 0, deny: @send}
|
||||
]
|
||||
}
|
||||
|
||||
result = Guild.member_permissions(g, m, channel)
|
||||
refute Permissions.has?(result, :send_messages)
|
||||
end
|
||||
|
||||
test "within the role tier, an allow OR-aggregates over a deny of the same bit (allow wins)" do
|
||||
g = guild([everyone(@view), role(10, 0), role(11, 0)])
|
||||
m = member(200, [10, 11])
|
||||
|
||||
channel = %Dexcord.TextChannel{
|
||||
id: 5,
|
||||
permission_overwrites: [
|
||||
%Overwrite{id: 10, type: :role, allow: 0, deny: @send},
|
||||
%Overwrite{id: 11, type: :role, allow: @send, deny: 0}
|
||||
]
|
||||
}
|
||||
|
||||
result = Guild.member_permissions(g, m, channel)
|
||||
assert Permissions.has?(result, :send_messages)
|
||||
end
|
||||
|
||||
test "a timed-out member keeps exactly VIEW_CHANNEL | READ_MESSAGE_HISTORY" do
|
||||
future = DateTime.add(DateTime.utc_now(), 3600, :second)
|
||||
g = guild([everyone(@view ||| @send ||| @rmh)])
|
||||
|
||||
m = %Member{user_id: 200, roles: [], communication_disabled_until: future}
|
||||
|
||||
assert Guild.member_permissions(g, m) == @timeout_allowed
|
||||
end
|
||||
|
||||
test "a timed-out administrator is unaffected (owner/admin skip the timeout rule)" do
|
||||
future = DateTime.add(DateTime.utc_now(), 3600, :second)
|
||||
g = guild([everyone(0), role(20, @admin)])
|
||||
|
||||
m = %Member{user_id: 200, roles: [20], communication_disabled_until: future}
|
||||
|
||||
assert Guild.member_permissions(g, m) == @all
|
||||
end
|
||||
|
||||
test "a PAST communication_disabled_until is NOT a timeout" do
|
||||
past = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
g = guild([everyone(@view ||| @send)])
|
||||
|
||||
m = %Member{user_id: 200, roles: [], communication_disabled_until: past}
|
||||
|
||||
result = Guild.member_permissions(g, m)
|
||||
assert Permissions.has?(result, :send_messages)
|
||||
assert Permissions.has?(result, :view_channel)
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue