Files
ubicloud/spec/lib/authorization_spec.rb
Jeremy Evans dede69cf2b Make ActionType::NAME_MAP raise for invalid actions
The previous behavior was fail open if you were using this directly
to set the action for an AccessControlEntry, because a nil action_id
means all actions are allowed.

This more strict behavior will catch problems, and found one place
in the specs where we weren't testing what we wanted to be testing
(since the related spec had all actions allowed).
2025-01-09 09:55:55 -08:00

304 lines
16 KiB
Ruby

# frozen_string_literal: true
require "sequel/model"
RSpec.describe Authorization do
let(:users) {
[
Account.create_with_id(email: "auth1@example.com"),
Account.create_with_id(email: "auth2@example.com")
]
}
let(:projects) { (0..1).map { users[_1].create_project_with_default_policy("project-#{_1}") } }
let(:vms) {
(0..3).map do |index|
ps = Prog::Vnet::SubnetNexus.assemble(projects[index / 2].id, name: "vm#{index}-ps", location: "hetzner-fsn1").subject
Prog::Vm::Nexus.assemble("key", projects[index / 2].id, name: "vm#{index}", private_subnet_id: ps.id)
end.map(&:subject)
}
let(:pg) {
Prog::Postgres::PostgresResourceNexus.assemble(
project_id: projects[0].id, location: "hetzner-fsn1", name: "pg0", target_vm_size: "standard-2", target_storage_size_gib: 128
).subject
}
after do
users.each(&:destroy)
end
before do
allow(Config).to receive(:postgres_service_project_id).and_return(projects[0].id)
end
def add_separate_aces(policies, project_id: projects[0].id)
ace_subjects, ace_actions, ace_objects = policies.values_at(:subjects, :actions, :objects)
Array(ace_subjects).each do |subject_id|
Array(ace_actions).each do |action|
action_id = ActionType::NAME_MAP.fetch(action) { ActionTag[project_id: nil, name: action].id } if action
Array(ace_objects).each do |object_id|
AccessControlEntry.create_with_id(project_id:, subject_id:, action_id:, object_id:)
end
end
end
end
def add_single_ace(policies, project_id: projects[0].id)
ace_subjects, ace_actions, ace_objects = policies.values_at(:subjects, :actions, :objects)
subject_tag = SubjectTag.create_with_id(project_id:, name: "S")
Array(ace_subjects).each do |subject_id|
subject_tag.add_subject(subject_id)
end
subject_tag = yield subject_tag if block_given?
action_id = unless ace_actions == [nil]
action_tag = ActionTag.create_with_id(project_id:, name: "A")
Array(ace_actions).each_with_index do |action_id, i|
action_id = ActionType::NAME_MAP.fetch(action_id) { ActionTag[project_id: nil, name: action_id].id }
action_tag.add_action(action_id)
end
action_tag = yield action_tag if block_given?
action_tag.id
end
object_id = unless ace_objects == [nil]
object_tag = ObjectTag.create_with_id(project_id:, name: "A")
Array(ace_objects).each do |object_id|
object_tag.add_object(object_id)
end
object_tag = yield object_tag if block_given?
object_tag.id
end
AccessControlEntry.create_with_id(project_id:, subject_id: subject_tag.id, action_id:, object_id:)
end
def add_single_ace_with_nested_tags(policies, project_id: projects[0].id)
add_single_ace(policies, project_id:) do |tag|
3.times do |i|
old_tag = tag
tag = tag.class.create_with_id(project_id: tag.project_id, name: i.to_s)
tag.send(:"add_#{tag.class.name.delete_suffix("Tag").downcase}", old_tag.id)
end
tag
end
end
# rubocop:disable RSpec/MissingExpectationTargetMethod
describe "#matched_policies" do
it "without specific object" do
AccessControlEntry.dataset.destroy
project_id = projects[0].id
[
[{}, SecureRandom.uuid, "Vm:view", 0],
[{}, SecureRandom.uuid, ["Vm:view"], 0],
[{}, users[0].id, "Vm:view", 0],
[{}, users[0].id, ["Vm:view"], 0],
[{subjects: users[0].id, actions: "Vm:all", objects: [nil]}, users[0].id, "Vm:view", 1],
[{subjects: users[0].id, actions: "Vm:all", objects: [nil]}, users[0].id, "Postgres:view", 0],
[{subjects: users[0].id, actions: "Member", objects: [nil]}, users[0].id, "Vm:view", 1],
[{subjects: users[0].id, actions: "Member", objects: [nil]}, users[0].id, "Project:edit", 0],
[{subjects: users[0].id, actions: "Vm:view", objects: [nil]}, users[0].id, "Vm:view", 1],
[{subjects: users[0].id, actions: "Vm:view", objects: [nil]}, users[0].id, ["Vm:view", "Vm:create"], 1],
[{subjects: users[0].id, actions: ["Vm:view", "Vm:delete"], objects: [nil]}, users[0].id, ["Vm:view", "Vm:create"], 1],
[{subjects: users[0].id, actions: "Vm:view", objects: vms[0].id}, users[0].id, "Vm:view", 1],
[{subjects: users[0].id, actions: "Vm:view", objects: [vms[0].id, vms[1].id]}, users[0].id, "Vm:view", 2],
[{subjects: users[0].id, actions: "Vm:delete", objects: vms[0].id}, users[0].id, "Vm:view", 0],
[{subjects: users[0].id, actions: %w[Vm:view Vm:delete], objects: vms[0].id}, users[0].id, "Vm:view", 1],
[{subjects: users[0].id, actions: [nil], objects: vms[0].id}, users[0].id, "Vm:view", 1],
[{subjects: users[0].id, actions: "Postgres:view", objects: pg.id}, users[0].id, "Postgres:edit", 0],
[{subjects: users[0].id, actions: "Postgres:edit", objects: pg.id}, users[0].id, "Postgres:view", 0],
[{subjects: users[0].id, actions: "Postgres:view", objects: pg.id}, users[0].id, "Postgres:view", 1]
].each do |policies, subject_id, actions, matched_count|
DB.transaction(rollback: :always) do
add_separate_aces(policies)
expect(described_class.matched_policies(project_id, subject_id, actions).count).to eq(matched_count)
expect(described_class.all_permissions(project_id, subject_id, nil) & Array(actions)).send((matched_count == 0) ? :to : :not_to, be_empty)
end
DB.transaction(rollback: :always) do
add_single_ace(policies)
expect(described_class.matched_policies(project_id, subject_id, actions).count).to eq((matched_count == 0) ? 0 : 1)
expect(described_class.all_permissions(project_id, subject_id, nil) & Array(actions)).send((matched_count == 0) ? :to : :not_to, be_empty)
end
DB.transaction(rollback: :always) do
add_single_ace_with_nested_tags(policies)
expect(described_class.matched_policies(project_id, subject_id, actions).count).to eq((matched_count == 0) ? 0 : 1)
expect(described_class.all_permissions(project_id, subject_id, nil) & Array(actions)).send((matched_count == 0) ? :to : :not_to, be_empty)
end
end
end
it "with specific object" do
AccessControlEntry.dataset.destroy
project_id = projects[0].id
[
[{}, SecureRandom.uuid, "Vm:view", UBID.from_uuidish(SecureRandom.uuid).to_s.sub(/\A../, "00"), 0],
[{}, SecureRandom.uuid, ["Vm:view"], UBID.from_uuidish(SecureRandom.uuid).to_s.sub(/\A../, "00"), 0],
[{}, SecureRandom.uuid, ["Vm:view"], vms[0].id, 0],
[{}, users[0].id, ["Vm:view"], vms[0].id, 0],
[{subjects: users[0].id, actions: "Vm:all", objects: [nil]}, users[0].id, "Vm:view", vms[0].id, 1],
[{subjects: users[0].id, actions: "Vm:all", objects: [nil]}, users[0].id, "Postgres:view", vms[0].id, 0],
[{subjects: users[0].id, actions: "Member", objects: [nil]}, users[0].id, "Vm:view", vms[0].id, 1],
[{subjects: users[0].id, actions: "Member", objects: [nil]}, users[0].id, "Project:edit", vms[0].id, 0],
[{subjects: users[0].id, actions: "Vm:view", objects: [nil]}, users[0].id, "Vm:view", vms[0].id, 1],
[{subjects: users[0].id, actions: "Vm:view", objects: [nil]}, users[0].id, ["Vm:view", "Vm:create"], vms[0].id, 1],
[{subjects: [users[0].id], actions: ["Vm:view"], objects: [nil]}, users[0].id, "Vm:view", vms[0].id, 1],
[{subjects: users[0].id, actions: ["Vm:view", "Vm:delete"], objects: [nil]}, users[0].id, ["Vm:view", "Vm:create"], vms[0].id, 1],
[{subjects: users[0].id, actions: "Vm:delete", objects: [nil]}, users[0].id, "Vm:view", vms[0].id, 0],
[{subjects: [users[0].id], actions: ["Vm:view"], objects: [nil]}, users[0].id, "Vm:view", vms[0].id, 1],
[{subjects: users[0].id, actions: "Vm:view", objects: vms[0].id}, users[0].id, "Vm:view", vms[0].id, 1],
[{subjects: users[0].id, actions: "Vm:view", objects: [vms[0].id, vms[1].id]}, users[0].id, ["Vm:view", "Vm:create"], vms[0].id, 1],
[{subjects: [users[0].id], actions: ["Vm:view"], objects: vms[0].id}, users[0].id, "Vm:view", vms[0].id, 1],
[{subjects: users[0].id, actions: ["Vm:view", "Vm:delete"], objects: vms[0].id}, users[0].id, ["Vm:view", "Vm:create"], vms[0].id, 1],
[{subjects: users[0].id, actions: "Vm:delete", objects: vms[0].id}, users[0].id, "Vm:view", vms[0].id, 0],
[{subjects: [users[0].id], actions: ["Vm:view"], objects: vms[0].id}, users[0].id, "Vm:view", vms[0].id, 1],
[{subjects: users[0].id, actions: "Vm:view", objects: vms[1].id}, users[0].id, "Vm:view", vms[0].id, 0],
[{subjects: [users[0].id], actions: ["Vm:view"], objects: vms[1].id}, users[0].id, "Vm:view", vms[0].id, 0],
[{subjects: users[0].id, actions: ["Vm:view", "Vm:delete"], objects: vms[1].id}, users[0].id, ["Vm:view", "Vm:create"], vms[0].id, 0],
[{subjects: users[0].id, actions: "Vm:delete", objects: vms[1].id}, users[0].id, "Vm:view", vms[0].id, 0],
[{subjects: [users[0].id], actions: ["Vm:view"], objects: vms[1].id}, users[0].id, "Vm:view", vms[0].id, 0]
].each do |policies, subject_id, actions, object_id, matched_count|
DB.transaction(rollback: :always) do
add_separate_aces(policies)
expect(described_class.matched_policies(project_id, subject_id, actions, object_id).count).to eq(matched_count)
expect(described_class.all_permissions(project_id, subject_id, object_id) & Array(actions)).send((matched_count == 0) ? :to : :not_to, be_empty)
end
DB.transaction(rollback: :always) do
add_single_ace(policies)
expect(described_class.matched_policies(project_id, subject_id, actions, object_id).count).to eq((matched_count == 0) ? 0 : 1)
expect(described_class.all_permissions(project_id, subject_id, object_id) & Array(actions)).send((matched_count == 0) ? :to : :not_to, be_empty)
end
DB.transaction(rollback: :always) do
add_single_ace_with_nested_tags(policies)
expect(described_class.matched_policies(project_id, subject_id, actions, object_id).count).to eq((matched_count == 0) ? 0 : 1)
expect(described_class.all_permissions(project_id, subject_id, object_id) & Array(actions)).send((matched_count == 0) ? :to : :not_to, be_empty)
end
end
end
end
# rubocop:enable RSpec/MissingExpectationTargetMethod
describe "#has_permission?" do
it "returns true when has matched policies" do
expect(described_class.has_permission?(projects[0].id, users[0].id, "Vm:view", vms[0].id)).to be(true)
end
it "returns false when has no matched policies" do
AccessControlEntry.dataset.destroy
expect(described_class.has_permission?(projects[0].id, users[0].id, "Vm:view", vms[0].id)).to be(false)
end
end
describe "#authorize" do
it "does not raise error when there existed a matching access control entry when using UUID object_id" do
expect { described_class.authorize(projects[0].id, users[0].id, "Vm:view", vms[0].id) }.not_to raise_error
end
it "does not raise error when there existed a matching access control entry when using UBID object_id" do
expect { described_class.authorize(projects[0].id, users[0].id, "Vm:view", vms[0].ubid) }.not_to raise_error
end
it "does not raise error when there existed a matching access control entry when object_id in in project" do
st = SubjectTag.create_with_id(project_id: projects[0].id, name: "test")
expect { described_class.authorize(projects[0].id, users[0].id, "Vm:view", projects[0].id) }.not_to raise_error
expect { described_class.authorize(projects[0].id, users[0].id, "Vm:view", vms[0].id) }.not_to raise_error
expect { described_class.authorize(projects[0].id, users[0].id, "Vm:view", st.id) }.not_to raise_error
end
it "raises error when has matched policies when object is in project" do
st = SubjectTag.create_with_id(project_id: projects[1].id, name: "test")
expect { described_class.authorize(projects[0].id, users[0].id, "Vm:view", projects[1].id) }.to raise_error Authorization::Unauthorized
expect { described_class.authorize(projects[0].id, users[0].id, "Vm:view", vms[3].id) }.to raise_error Authorization::Unauthorized
expect { described_class.authorize(projects[0].id, users[0].id, "Vm:view", st.id) }.to raise_error Authorization::Unauthorized
end
it "raises error when non-UBID/non-UUID is used" do
expect { described_class.authorize(projects[0].id, users[0].id, "Vm:view", "some-garbage") }.to raise_error UBIDParseError
end
it "raises error when has no matched policies" do
AccessControlEntry.dataset.destroy
expect { described_class.authorize(projects[0].id, users[0].id, "Vm:view", vms[0].id) }.to raise_error Authorization::Unauthorized
end
end
describe ".dataset_authorize" do
it "returns authorized resources for user and project and action when user has full permissions" do
vms
expect(described_class.dataset_authorize(Vm.dataset, projects[0].id, users[0].id, "Vm:view").select_map(:id).sort).to eq([vms[0].id, vms[1].id].sort)
expect(described_class.dataset_authorize(Vm.dataset, projects[0].id, users[1].id, "Vm:view").select_map(:id)).to be_empty
expect(described_class.dataset_authorize(Vm.dataset, projects[1].id, users[0].id, "Vm:view").select_map(:id)).to be_empty
expect(described_class.dataset_authorize(Vm.dataset, projects[1].id, users[1].id, "Vm:view").select_map(:id).sort).to eq([vms[2].id, vms[3].id].sort)
end
{
add_separate_aces: "direct permissions",
add_single_ace: "indirect permissions via tag",
add_single_ace_with_nested_tags: "indirect permissions via nested tag"
}.each do |method, desc|
it "returns authorized resources for user and project and action when user has #{desc}" do
vms
AccessControlEntry.dataset.destroy
send(method, {subjects: users[0].id, actions: "Vm:view", objects: vms[0].id})
send(method, {subjects: users[1].id, actions: "Vm:view", objects: vms[3].id}, project_id: projects[1].id)
expect(described_class.dataset_authorize(Vm.dataset, projects[0].id, users[0].id, "Vm:view").select_map(:id)).to eq([vms[0].id])
expect(described_class.dataset_authorize(Vm.dataset, projects[0].id, users[1].id, "Vm:view").select_map(:id)).to be_empty
expect(described_class.dataset_authorize(Vm.dataset, projects[1].id, users[0].id, "Vm:view").select_map(:id)).to be_empty
expect(described_class.dataset_authorize(Vm.dataset, projects[1].id, users[1].id, "Vm:view").select_map(:id)).to eq([vms[3].id])
end
end
end
describe "#HyperTagMethods" do
it "hyper_tag_name" do
expect(users[0].hyper_tag_name).to eq("user/auth1@example.com")
p = vms[0].projects.first
expect(vms[0].hyper_tag_name(p)).to eq("project/#{p.ubid}/location/eu-central-h1/vm/vm0")
expect(projects[0].hyper_tag_name).to eq("project/#{projects[0].ubid}")
end
it "hyper_tag_name error" do
c = Class.new(Sequel::Model) do
include Authorization::HyperTagMethods
end
expect { c.new.hyper_tag_name }.to raise_error NoMethodError
end
it "hyper_tag methods" do
project = Project.create_with_id(name: "test")
expect(project.hyper_tag(project)).to be_nil
project.associate_with_project(project)
expect(project.hyper_tag(project)).to exist
project.dissociate_with_project(project)
expect(project.hyper_tag(project)).to be_nil
end
it "associate/dissociate with project" do
project = Project.create_with_id(name: "test")
expect(users[0].hyper_tag(project)).to be_nil
users[0].associate_with_project(project)
expect(users[0].hyper_tag(project)).to exist
users[0].dissociate_with_project(project)
expect(users[0].hyper_tag(project)).to be_nil
end
it "does not associate/dissociate with nil project" do
project = Project.create_with_id(name: "test")
expect(project.associate_with_project(nil)).to be_nil
expect(users[0].hyper_tag(project)).to be_nil
expect(project.dissociate_with_project(nil)).to be_nil
expect(users[0].hyper_tag(project)).to be_nil
end
end
end