mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +08:00
Followup 5823e4e3b2,
this commit allows the addition of users along with
groups to access control lists, modifying DAccessControl
to support selecting a user or group from the same
search input.
Shown here is a mix of user & group permissions in the
`DAccessControl` component:
<img width="611" height="664" alt="image"
src="https://github.com/user-attachments/assets/c25e13b0-8885-4ce7-972c-5116f3acb094"
/>
When the search opens, we show the site's groups that
the user can see as preloaded values, showing only the
group name for clarity:
<img width="612" height="389" alt="image"
src="https://github.com/user-attachments/assets/df93a94b-918e-4fed-b045-ac72f02aca41"
/>
When searching a GET request is sent and users are included
in search results.
<img width="608" height="404" alt="image"
src="https://github.com/user-attachments/assets/ff17e6c0-2505-480e-ae25-e6f8309555bc"
/>
---------
Co-authored-by: Jordan Vidrine <jordan@jordanvidrine.com>
356 lines
12 KiB
Ruby
Vendored
356 lines
12 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
RSpec.describe AccessControlList do
|
|
fab!(:target, :category)
|
|
fab!(:group) { Fabricate(:group, name: "marketing", full_name: "Marketing Team") }
|
|
fab!(:other_group) { Fabricate(:group, name: "support", full_name: "Support Team") }
|
|
fab!(:user) { Fabricate(:user, name: "User One") }
|
|
fab!(:other_user) { Fabricate(:user, name: "User Two") }
|
|
|
|
describe ".expand_list_for_bulk_insert" do
|
|
it "collapses multiple groups sharing a permission into one record" do
|
|
list = [
|
|
{ type: "group", id: group.id, permission: "view" },
|
|
{ type: "group", id: other_group.id, permission: "view" },
|
|
]
|
|
|
|
result = described_class.expand_list_for_bulk_insert(list, target, "core")
|
|
|
|
expect(result.size).to eq(1)
|
|
entry = result.first
|
|
expect(entry).to match(
|
|
permission: "view",
|
|
allowed_group_ids: contain_exactly(group.id, other_group.id),
|
|
allowed_user_ids: [],
|
|
target_type: "Category",
|
|
target_id: target.id,
|
|
owner: "core",
|
|
)
|
|
end
|
|
|
|
it "produces a separate record per distinct permission" do
|
|
list = [
|
|
{ type: "group", id: group.id, permission: "view" },
|
|
{ type: "group", id: group.id, permission: "edit" },
|
|
]
|
|
|
|
result = described_class.expand_list_for_bulk_insert(list, target, "core")
|
|
|
|
expect(result.map { |entry| entry[:permission] }).to contain_exactly("view", "edit")
|
|
expect(result.find { |entry| entry[:permission] == "edit" }[:allowed_group_ids]).to(
|
|
contain_exactly(group.id),
|
|
)
|
|
end
|
|
|
|
it "accepts a symbol type and a custom owner" do
|
|
list = [{ type: :group, id: group.id, permission: "view" }]
|
|
|
|
result = described_class.expand_list_for_bulk_insert(list, target, "chat")
|
|
|
|
expect(result.first[:allowed_group_ids]).to contain_exactly(group.id)
|
|
expect(result.first[:owner]).to eq("chat")
|
|
end
|
|
|
|
it "collapses users and groups sharing a permission into one record" do
|
|
list = [
|
|
{ type: "group", id: group.id, permission: "view" },
|
|
{ type: "user", id: user.id, permission: "view" },
|
|
{ type: :user, id: other_user.id, permission: "view" },
|
|
]
|
|
|
|
result = described_class.expand_list_for_bulk_insert(list, target, "core")
|
|
|
|
expect(result).to contain_exactly(
|
|
{
|
|
permission: "view",
|
|
allowed_group_ids: contain_exactly(group.id),
|
|
allowed_user_ids: contain_exactly(user.id, other_user.id),
|
|
target_type: "Category",
|
|
target_id: target.id,
|
|
owner: "core",
|
|
},
|
|
)
|
|
end
|
|
|
|
it "returns records that insert_all! can persist as valid acls" do
|
|
list = [
|
|
{ type: "group", id: group.id, permission: "view" },
|
|
{ type: "user", id: user.id, permission: "edit" },
|
|
]
|
|
|
|
described_class.insert_all!(described_class.expand_list_for_bulk_insert(list, target, "core"))
|
|
|
|
acls = described_class.where(target: target)
|
|
expect(acls.pluck(:permission)).to contain_exactly("view", "edit")
|
|
expect(acls.find_by(permission: "view").allowed_group_ids).to contain_exactly(group.id)
|
|
expect(acls.find_by(permission: "edit").allowed_user_ids).to contain_exactly(user.id)
|
|
end
|
|
end
|
|
|
|
describe ".inject_mandatory_acl" do
|
|
let(:mandatory_acl) { { type: :group, id: group.id, permission: "manage" } }
|
|
|
|
before do
|
|
target.class.stubs(:has_mandatory_acl?).returns(true)
|
|
target.class.stubs(:mandatory_acl).returns([mandatory_acl])
|
|
end
|
|
|
|
it "adds missing mandatory acl entries" do
|
|
flattened_acl = [{ type: "group", id: other_group.id, permission: "view" }]
|
|
|
|
result = described_class.inject_mandatory_acl(flattened_acl, target)
|
|
|
|
expect(result).to contain_exactly(
|
|
{ type: "group", id: other_group.id, permission: "view" },
|
|
mandatory_acl,
|
|
)
|
|
end
|
|
|
|
it "does not duplicate mandatory acl entries" do
|
|
flattened_acl = [{ type: "group", id: group.id, permission: "manage" }]
|
|
|
|
result = described_class.inject_mandatory_acl(flattened_acl, target)
|
|
|
|
expect(result).to contain_exactly({ type: "group", id: group.id, permission: "manage" })
|
|
end
|
|
end
|
|
|
|
describe ".flattened_list" do
|
|
fab!(:view_acl) do
|
|
Fabricate(
|
|
:access_control_list_with_groups,
|
|
target: target,
|
|
permission: "view",
|
|
groups: [group],
|
|
)
|
|
end
|
|
|
|
fab!(:edit_acl) do
|
|
Fabricate(
|
|
:access_control_list_with_groups,
|
|
target: target,
|
|
permission: "edit",
|
|
groups: [group, other_group],
|
|
)
|
|
end
|
|
|
|
before { target.class.stubs(:acl_is_mandatory?).returns(false) }
|
|
|
|
it "returns one entry per group per acl with the group metadata" do
|
|
list = described_class.where(target: target).flattened_list
|
|
|
|
expect(list.size).to eq(3)
|
|
|
|
view_entry = list.find { |entry| entry[:permission] == "view" }
|
|
expect(view_entry).to include(
|
|
type: :group,
|
|
id: group.id,
|
|
permission: "view",
|
|
display_name: group.full_name || group.name,
|
|
target_type: "Category",
|
|
target_id: target.id,
|
|
)
|
|
expect(view_entry[:metadata]).to eq({ auto_group: false })
|
|
end
|
|
|
|
it "falls back to the group name for display_name" do
|
|
group.update!(full_name: "")
|
|
|
|
entry = described_class.where(target: target, permission: "view").flattened_list.first
|
|
|
|
expect(entry[:display_name]).to eq(group.name)
|
|
end
|
|
|
|
it "emits an entry for every allowed group on an acl" do
|
|
list = described_class.where(target: target, permission: "edit").flattened_list
|
|
|
|
expect(list.map { |entry| entry[:id] }).to contain_exactly(group.id, other_group.id)
|
|
end
|
|
|
|
it "flags automatic groups in the metadata" do
|
|
Fabricate(
|
|
:access_control_list_with_groups,
|
|
target: target,
|
|
permission: "manage",
|
|
groups: [Group.find(Group::AUTO_GROUPS[:admins])],
|
|
)
|
|
|
|
entry = described_class.where(target: target, permission: "manage").flattened_list.first
|
|
|
|
expect(entry[:metadata]).to eq({ auto_group: true })
|
|
end
|
|
|
|
it "returns one entry per user per acl with the user display name" do
|
|
Fabricate(
|
|
:access_control_list_with_users,
|
|
target: target,
|
|
permission: "manage",
|
|
users: [user, other_user],
|
|
)
|
|
|
|
list = described_class.where(target: target, permission: "manage").flattened_list
|
|
|
|
expect(list).to contain_exactly(
|
|
include(
|
|
type: :user,
|
|
id: user.id,
|
|
permission: "manage",
|
|
display_name: user.display_name,
|
|
mandatory: false,
|
|
target_type: "Category",
|
|
target_id: target.id,
|
|
),
|
|
include(
|
|
type: :user,
|
|
id: other_user.id,
|
|
permission: "manage",
|
|
display_name: other_user.display_name,
|
|
mandatory: false,
|
|
target_type: "Category",
|
|
target_id: target.id,
|
|
),
|
|
)
|
|
expect(list).to all(exclude(:metadata))
|
|
end
|
|
|
|
it "marks mandatory acl entries" do
|
|
target
|
|
.class
|
|
.stubs(:acl_is_mandatory?)
|
|
.with({ type: :group, id: group.id, permission: "view" })
|
|
.returns(true)
|
|
|
|
list = described_class.where(target: target).flattened_list
|
|
|
|
expect(list.map { |entry| entry.slice(:id, :permission, :mandatory) }).to contain_exactly(
|
|
{ id: group.id, permission: "view", mandatory: true },
|
|
{ id: group.id, permission: "edit", mandatory: false },
|
|
{ id: other_group.id, permission: "edit", mandatory: false },
|
|
)
|
|
end
|
|
|
|
it "returns an empty array when there are no acls" do
|
|
expect(described_class.where(target: Fabricate(:category)).flattened_list).to eq([])
|
|
end
|
|
|
|
it "does not error if one of the groups is deleted" do
|
|
group.destroy!
|
|
expect { described_class.where(target: target).flattened_list }.not_to raise_error
|
|
end
|
|
|
|
context "when for_target is provided" do
|
|
it "stamps every entry with the given target's id and type" do
|
|
list = described_class.where(target: target).flattened_list(for_target: target)
|
|
|
|
expect(list.map { |entry| entry[:target_type] }.uniq).to eq(["Category"])
|
|
expect(list.map { |entry| entry[:target_id] }.uniq).to eq([target.id])
|
|
end
|
|
|
|
it "raises when the relation spans more than one target" do
|
|
Fabricate(:access_control_list_with_groups, permission: "view", groups: [group])
|
|
|
|
expect { described_class.all.flattened_list(for_target: target) }.to raise_error(
|
|
Acl::MixedTargetError,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe ".preload_allowed" do
|
|
it "populates allowed_groups_preloaded from allowed_group_ids" do
|
|
acl = Fabricate(:access_control_list_with_groups, groups: [group, other_group])
|
|
|
|
loaded = described_class.where(id: acl.id).preload_allowed.first
|
|
|
|
expect(loaded.allowed_groups_preloaded.map(&:id)).to contain_exactly(group.id, other_group.id)
|
|
end
|
|
|
|
it "populates allowed_users_preloaded from allowed_user_ids" do
|
|
acl = Fabricate(:access_control_list_with_users, users: [user, other_user])
|
|
|
|
loaded = described_class.where(id: acl.id).preload_allowed.first
|
|
|
|
expect(loaded.allowed_users_preloaded.map(&:id)).to contain_exactly(user.id, other_user.id)
|
|
end
|
|
|
|
it "loads the allowed groups for the whole relation without an N+1" do
|
|
one = Fabricate(:access_control_list_with_groups, groups: [group])
|
|
|
|
queries_for_one =
|
|
track_sql_queries do
|
|
described_class
|
|
.where(id: one.id)
|
|
.preload_allowed
|
|
.each { |acl| acl.allowed_groups_preloaded }
|
|
end
|
|
|
|
two = Fabricate(:access_control_list_with_groups, groups: [group])
|
|
three = Fabricate(:access_control_list_with_groups, groups: [other_group])
|
|
|
|
queries_for_many =
|
|
track_sql_queries do
|
|
described_class
|
|
.where(id: [one.id, two.id, three.id])
|
|
.preload_allowed
|
|
.each { |acl| acl.allowed_groups_preloaded }
|
|
end
|
|
|
|
expect(queries_for_many.size).to eq(queries_for_one.size)
|
|
end
|
|
end
|
|
|
|
describe ".matching_group" do
|
|
fab!(:member_user, :user)
|
|
fab!(:member_group) { Fabricate(:group).tap { |new_group| new_group.add(member_user) } }
|
|
fab!(:non_member_user, :user)
|
|
fab!(:group_acl) { Fabricate(:access_control_list_with_groups, groups: [member_group]) }
|
|
fab!(:direct_user_acl) { Fabricate(:access_control_list_with_users, users: [member_user]) }
|
|
fab!(:other_user_acl) { Fabricate(:access_control_list_with_users, users: [non_member_user]) }
|
|
|
|
it "matches acls allowing the group directly" do
|
|
expect(described_class.matching_group(member_group)).to include(group_acl)
|
|
end
|
|
|
|
it "matches acls allowing users in the group" do
|
|
expect(described_class.matching_group(member_group)).to include(direct_user_acl)
|
|
end
|
|
|
|
it "does not match acls for users outside the group" do
|
|
expect(described_class.matching_group(member_group)).not_to include(other_user_acl)
|
|
end
|
|
end
|
|
|
|
describe ".matching_user" do
|
|
fab!(:user)
|
|
fab!(:member_group) { Fabricate(:group).tap { |new_group| new_group.add(user) } }
|
|
fab!(:non_member_group, :group)
|
|
fab!(:group_acl) { Fabricate(:access_control_list_with_groups, groups: [member_group]) }
|
|
fab!(:other_group_acl) do
|
|
Fabricate(:access_control_list_with_groups, groups: [non_member_group])
|
|
end
|
|
fab!(:direct_user_acl) { Fabricate(:access_control_list_with_users, users: [user]) }
|
|
fab!(:anonymous_acl) do
|
|
Fabricate(:access_control_list, allowed_group_ids: [Group::AUTO_GROUPS[:anonymous_users]])
|
|
end
|
|
|
|
it "matches acls allowing a group the user belongs to" do
|
|
expect(described_class.matching_user(user)).to include(group_acl)
|
|
end
|
|
|
|
it "matches acls that allow the user directly" do
|
|
expect(described_class.matching_user(user)).to include(direct_user_acl)
|
|
end
|
|
|
|
it "does not match acls for groups the user does not belong to" do
|
|
expect(described_class.matching_user(user)).not_to include(other_group_acl, anonymous_acl)
|
|
end
|
|
|
|
it "matches the anonymous group acl for a nil user" do
|
|
expect(described_class.matching_user(nil)).to include(anonymous_acl)
|
|
end
|
|
|
|
it "does not match member group acls for a nil user" do
|
|
expect(described_class.matching_user(nil)).not_to include(group_acl, direct_user_acl)
|
|
end
|
|
end
|
|
end
|