Files
ubicloud/lib/authorization.rb
Jeremy Evans 954d439b30 Avoid correlated subquery in authorization query
I'm not sure if PostgreSQL would correctly optimize the correlated
subquery, so this switches back to a pure join approach.  This
puts all conditions in the JOINs, avoiding the use of WHERE.

When we move to supporting personal access tokens, this should
be trivial to expand to multiple subjects (both account and
personal access token subjects) in a single query, using a loop
for the subject joins over all provided subjects.

This is similar in nature to the original SQL query, before it
was switched to a correlated subquery, but the joins are in a
different order.
2024-11-21 12:58:56 -08:00

177 lines
5.9 KiB
Ruby

# frozen_string_literal: true
module Authorization
class Unauthorized < CloverError
def initialize
super(403, "Forbidden", "Sorry, you don't have permission to continue with this request.")
end
end
def self.has_permission?(subject_id, actions, object_id)
!matched_policies_dataset(subject_id, actions, object_id).empty?
end
def self.authorize(subject_id, actions, object_id)
unless has_permission?(subject_id, actions, object_id)
fail Unauthorized
end
end
def self.all_permissions(subject_id, object_id)
matched_policies_dataset(subject_id, nil, object_id).select_map(:actions).tap(&:flatten!)
end
def self.authorized_resources_dataset(subject_id, actions)
matched_policies_dataset(subject_id, actions).select(Sequel[:applied_tag][:tagged_id])
end
def self.authorized_resources(subject_id, actions)
matched_policies_dataset(subject_id, actions).select_map(Sequel[:applied_tag][:tagged_id])
end
def self.expand_actions(actions)
extended_actions = Set["*"]
Array(actions).each do |action|
extended_actions << action
parts = action.split(":")
parts[0..-2].each_with_index.each { |_, i| extended_actions << "#{parts[0..i].join(":")}:*" }
end
extended_actions.to_a
end
def self.matched_policies_dataset(subject_id, actions = nil, object_id = nil)
dataset = DB.from { access_policy.as(:acl) }
.select(Sequel[:applied_tag][:tagged_id], Sequel[:applied_tag][:tagged_table], :subjects, :actions, :objects)
.cross_join(Sequel.pg_jsonb_op(Sequel[:acl][:body])["acls"].to_recordset.as(:items, [:subjects, :actions, :objects].map { |c| Sequel.lit("#{c} JSONB") }))
.join(:access_tag, project_id: Sequel[:acl][:project_id]) do
Sequel.pg_jsonb_op(:objects).has_key?(Sequel[:access_tag][:name])
end
.join(:applied_tag, access_tag_id: :id)
.join(:access_tag, {project_id: Sequel[:acl][:project_id]}, table_alias: :subject_access_tag) do
Sequel.pg_jsonb_op(:subjects).has_key?(Sequel[:subject_access_tag][:name])
end
.join(:applied_tag, {access_tag_id: :id, tagged_id: subject_id}, table_alias: :subject_applied_tag)
if object_id
begin
ubid = UBID.parse(object_id)
rescue UBIDParseError
# nothing
else
object_id = ubid.to_uuid
end
dataset = dataset.where(Sequel[:applied_tag][:tagged_id] => object_id)
end
if actions
dataset = dataset.where(Sequel.pg_jsonb_op(:actions).contain_any(Sequel.pg_array(expand_actions(actions))))
end
dataset
end
def self.matched_policies(subject_id, actions = nil, object_id = nil)
matched_policies_dataset(subject_id, actions, object_id).all
end
module ManagedPolicy
ManagedPolicyClass = Struct.new(:name, :actions) do
def acls(subjects, objects)
{acls: [{subjects: Array(subjects), actions: actions, objects: Array(objects)}]}
end
def apply(project, accounts, append: false)
subjects = accounts.map { _1&.hyper_tag(project) }.compact.map { _1.name }
if append && (existing_body = project.access_policies_dataset.where(name: name).select_map(:body).first)
subjects = (subjects + existing_body["acls"].first["subjects"]).uniq
end
object = project.hyper_tag_name(project)
acls = self.acls(subjects, object).to_json
policy = AccessPolicy.new_with_id(project_id: project.id, name: name, managed: true, body: acls)
policy.skip_auto_validations(:unique) do
policy.insert_conflict(target: [:project_id, :name], update: {body: acls}).save_changes
end
end
end
Admin = ManagedPolicyClass.new("admin", ["*"])
Member = ManagedPolicyClass.new("member", ["Vm:*", "PrivateSubnet:*", "Firewall:*", "Postgres:*", "Project:view", "Project:github"])
def self.from_name(name)
ManagedPolicy.const_get(name.to_s.capitalize)
rescue NameError
nil
end
end
module Dataset
def authorized(subject_id, actions)
# We can't use "id" column directly, because it's ambiguous in big joined queries.
# We need to determine table of id explicitly.
# @opts is the hash of options for this dataset, and introduced at Sequel::Dataset.
from = @opts[:from].first
where { {Sequel[from][:id] => Authorization.authorized_resources_dataset(subject_id, actions)} }
end
end
module HyperTagMethods
def self.included(base)
base.class_eval do
many_to_many :projects, join_table: :access_tag, left_key: :hyper_tag_id, right_key: :project_id
end
end
def hyper_tag_name(project = nil)
raise NoMethodError
end
def hyper_tag(project)
AccessTag.where(project_id: project.id, hyper_tag_id: id).first
end
def associate_with_project(project)
return if project.nil?
DB.transaction do
self_tag = AccessTag.create_with_id(
project_id: project.id,
name: hyper_tag_name(project),
hyper_tag_id: id,
hyper_tag_table: self.class.table_name
)
project_tag = project.hyper_tag(project)
tag(self_tag)
tag(project_tag) if self_tag.id != project_tag.id
self_tag
end
end
def dissociate_with_project(project)
return if project.nil?
DB.transaction do
project_tag = project.hyper_tag(project)
untag(project_tag)
hyper_tag(project).destroy
end
end
end
module TaggableMethods
def self.included(base)
base.class_eval do
many_to_many :applied_access_tags, class: :AccessTag, join_table: :applied_tag, left_key: :tagged_id, right_key: :access_tag_id
end
end
def tag(access_tag)
AppliedTag.create(access_tag_id: access_tag.id, tagged_id: id, tagged_table: self.class.table_name)
end
def untag(access_tag)
AppliedTag.where(access_tag_id: access_tag.id, tagged_id: id).destroy
end
end
end