This uses a new feature I developed for Sequel with Ubicloud in mind. I found that explicit casts for array parameters used in this case are not required, as long as PostgreSQL can implicitly cast elements of the array to the type of the LHS of the expression. Taking an example query from the query parameterization analysis: ```sql DELETE FROM "applied_action_tag" WHERE (("tag_id" = $1) AND ("action_id" = ANY($2))) ``` In this case, $2 is a PostgreSQL array containing uuids. As action_id is a uuid type, PostgreSQL will assume $2 is uuid[], and things will work correctly. It took me a while to figure this out, because PostgreSQL's behavior is different when using ARRAY. When not auto parameterizing, Sequel literalizes arrays using ARRAY. Initial work on auto parameterization used ARRAY and parameterized the elements, and this let me to believe that explicitly casting was necessary for arrays, when it turns out not to be. As an example, this doesn't work (assuming $2 is a uuid string): ```sql DELETE FROM "applied_action_tag" WHERE (("tag_id" = $1) AND ("action_id" = ANY(ARRAY[$2]))) ``` For some reason, PostgreSQL thinks the array is text[] instead of uuid[], even though the LHS is uuid. The previous work around was to assume string arrays could be represented as PostgreSQL text[] types. I've used this successfully in other applications, but in those, I wasn't using uuids or enum types. As the majority of Ubicloud's usage in these cases is either uuid[] type or enum array types, the assumption of text[] was not a good one for Ubicloud. I previously added the Sequel.any_{type,uuid} methods to Ubicloud to make it easier to fix the failing cases. However, this is really a leaky abstraction, and it's likely something that would trip up other developers. By avoiding the explicit cast for string arrays used as parameters, we can remove all usage of Sequel.any_{type,uuid} and have everything work correctly. This approach should be safe to run in all environments. I've made it so that the conversion is performed for arrays with one or more elements only in frozen testing mode, since that is how the query parameterization analysis is run and it can result in a reduced number of distinct queries. It can also catch potential issues if the tests only test with single element arrays, but we are using multiple element arrays in production. By default, only arrays of two elements or more are use this conversion, because PostgreSQL will use a more optimized query plan for a single element value list than for a multiple element value list. Over 10% of Ubicloud's distinct parameterized queries use this new feature (126/1130).
368 lines
16 KiB
Ruby
368 lines
16 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Clover
|
|
tag_perm_map = {
|
|
"subject" => "Project:subjtag",
|
|
"action" => "Project:acttag",
|
|
"object" => "Project:objtag"
|
|
}.freeze
|
|
|
|
hash_branch(:project_prefix, "user") do |r|
|
|
r.web do
|
|
r.is do
|
|
authorize("Project:user", @project.id)
|
|
|
|
r.get do
|
|
@users = @project.accounts_dataset.order_by(:email).all
|
|
@subject_tag_map = SubjectTag.subject_id_map_for_project_and_accounts(@project.id, @users.map(&:id))
|
|
@allowed_view_tag_names = dataset_authorize(@project.subject_tags_dataset, "SubjectTag:view").map(:name)
|
|
@allowed_add_tag_names_map = dataset_authorize(@project.subject_tags_dataset, "SubjectTag:add").select_hash(:name, Sequel.as(true, :v))
|
|
@allowed_remove_tag_names_map = dataset_authorize(@project.subject_tags_dataset, "SubjectTag:remove").select_hash(:name, Sequel.as(true, :v))
|
|
@invitations = @project.invitations_dataset.order_by(:email).all
|
|
view "project/user"
|
|
end
|
|
|
|
r.post do
|
|
email = r.params["email"]
|
|
policy = r.params["policy"]
|
|
|
|
if ProjectInvitation[project_id: @project.id, email: email]
|
|
flash["error"] = "'#{email}' already invited to join the project."
|
|
r.redirect "#{@project.path}/user"
|
|
elsif @project.invitations_dataset.count >= 50
|
|
flash["error"] = "You can't have more than 50 pending invitations."
|
|
r.redirect "#{@project.path}/user"
|
|
end
|
|
|
|
unless policy == ""
|
|
unless (tag = dataset_authorize(@project.subject_tags_dataset, "SubjectTag:add").first(name: policy))
|
|
flash["error"] = "You don't have permission to invite users with this subject tag."
|
|
r.redirect "#{@project_data[:path]}/user"
|
|
end
|
|
end
|
|
|
|
if (user = Account.exclude(status_id: 3)[email: email])
|
|
user.add_project(@project)
|
|
tag&.add_subject(user.id)
|
|
Util.send_email(email, "Invitation to Join '#{@project.name}' Project on Ubicloud",
|
|
greeting: "Hello,",
|
|
body: ["You're invited by '#{current_account.name}' to join the '#{@project.name}' project on Ubicloud.",
|
|
"To join project, click the button below.",
|
|
"For any questions or assistance, reach out to our team at support@ubicloud.com."],
|
|
button_title: "Join Project",
|
|
button_link: "#{Config.base_url}#{@project.path}/dashboard")
|
|
else
|
|
@project.add_invitation(email: email, policy: (policy if tag), inviter_id: current_account_id, expires_at: Time.now + 7 * 24 * 60 * 60)
|
|
Util.send_email(email, "Invitation to Join '#{@project.name}' Project on Ubicloud",
|
|
greeting: "Hello,",
|
|
body: ["You're invited by '#{current_account.name}' to join the '#{@project.name}' project on Ubicloud.",
|
|
"To join project, you need to create an account on Ubicloud. Once you create an account, you'll be automatically joined to the project.",
|
|
"For any questions or assistance, reach out to our team at support@ubicloud.com."],
|
|
button_title: "Create Account",
|
|
button_link: "#{Config.base_url}/create-account")
|
|
end
|
|
|
|
flash["notice"] = "Invitation sent successfully to '#{email}'."
|
|
|
|
r.redirect "#{@project.path}/user"
|
|
end
|
|
end
|
|
|
|
r.post "policy/managed" do
|
|
authorize("Project:user", @project.id)
|
|
user_policies = typecast_params.Hash("user_policies") || {}
|
|
invitation_policies = typecast_params.Hash("invitation_policies") || {}
|
|
user_policies.transform_keys! { UBID.to_uuid(it) }
|
|
account_ids = user_policies.keys
|
|
|
|
DB.transaction do
|
|
allowed_add_tags = dataset_authorize(@project.subject_tags_dataset, "SubjectTag:add").to_hash(:name)
|
|
allowed_remove_tags = dataset_authorize(@project.subject_tags_dataset, "SubjectTag:remove").to_hash(:name)
|
|
project_account_ids = @project
|
|
.accounts_dataset
|
|
.where(Sequel[:accounts][:id] => account_ids)
|
|
.select_map(Sequel[:accounts][:id])
|
|
subject_tag_map = SubjectTag.subject_id_map_for_project_and_accounts(@project.id, project_account_ids)
|
|
project_account_ids.each do |account_id|
|
|
subject_tag_map[account_id] ||= [] # Handle accounts not in any tags
|
|
end
|
|
additions = {}
|
|
removals = {}
|
|
issues = []
|
|
|
|
user_policies.each do |account_id, policy|
|
|
unless (existing_tags = subject_tag_map[account_id])
|
|
issues << "Cannot change the policy for user, as they are not associated to project"
|
|
next
|
|
end
|
|
unless existing_tags.length < 2
|
|
issues << "Cannot change the policy for user, as they are in multiple subject tags"
|
|
next
|
|
end
|
|
policy = nil if policy == ""
|
|
next if existing_tags.include?(policy)
|
|
unless (added_tag = allowed_add_tags[policy]) || !policy
|
|
issues << "You don't have permission to add members to '#{policy}' tag"
|
|
next
|
|
end
|
|
if (tag = existing_tags.first)
|
|
unless (removed_tag = allowed_remove_tags[tag])
|
|
issues << "You don't have permission to remove members from '#{tag}' tag"
|
|
next
|
|
end
|
|
(removals[removed_tag] ||= []) << account_id
|
|
end
|
|
(additions[added_tag] ||= []) << account_id if added_tag
|
|
end
|
|
|
|
additions.each { |tag, user_ids| tag.add_members(user_ids) }
|
|
removals.each { |tag, user_ids| tag.remove_members(user_ids) }
|
|
additions.transform_keys!(&:name)
|
|
removals.transform_keys!(&:name)
|
|
|
|
if @project.subject_tags_dataset.first(name: "Admin").member_ids.empty?
|
|
flash["error"] = "The project must have at least one admin."
|
|
DB.rollback_on_exit
|
|
r.redirect "#{@project.path}/user"
|
|
end
|
|
|
|
invitatation_map = @project
|
|
.invitations_dataset
|
|
.where(email: invitation_policies.keys)
|
|
.to_hash(:email)
|
|
invitation_policy_changes = {}
|
|
invitation_policies.each do |email, policy|
|
|
policy = nil if policy == ""
|
|
next unless (inv = invitatation_map[email])
|
|
old_policy = inv.policy
|
|
next if policy == old_policy
|
|
if policy && !allowed_add_tags[policy]
|
|
issues << "You don't have permission to add invitation to '#{policy}' tag"
|
|
next
|
|
end
|
|
if old_policy && !allowed_remove_tags[old_policy]
|
|
issues << "You don't have permission to remove invitation from '#{old_policy}' tag"
|
|
next
|
|
end
|
|
(invitation_policy_changes[policy] ||= []) << inv.email
|
|
(additions[policy] ||= []) << 1 if policy
|
|
(removals[old_policy] ||= []) << 1 if old_policy
|
|
end
|
|
invitation_policy_changes.each do |policy, emails|
|
|
@project
|
|
.invitations_dataset
|
|
.where(email: emails)
|
|
.update(policy:)
|
|
end
|
|
|
|
changes = []
|
|
additions.each { |name, user_ids| changes << "#{user_ids.size} members added to #{name}" }
|
|
removals.each { |name, user_ids| changes << "#{user_ids.size} members removed from #{name}" }
|
|
|
|
flash["notice"] = changes.empty? ? "No change in user policies" : changes.join(", ")
|
|
flash["error"] = issues.uniq.join(", ") unless issues.empty?
|
|
end
|
|
|
|
r.redirect "#{@project.path}/user"
|
|
end
|
|
|
|
r.on "access-control" do
|
|
r.get true do
|
|
authorize("Project:viewaccess", @project.id)
|
|
|
|
uuids = {}
|
|
@project.access_control_entries.each do |ace|
|
|
# Omit personal action token subjects
|
|
uuids[ace.subject_id] = nil unless UBID.uuid_class_match?(ace.subject_id, ApiKey)
|
|
uuids[ace.action_id] = nil if ace.action_id
|
|
uuids[ace.object_id] = nil if ace.object_id
|
|
end
|
|
UBID.resolve_map(uuids)
|
|
@aces = @project.access_control_entries.map do |ace|
|
|
next unless (subject = uuids[ace.subject_id])
|
|
editable = !(subject.is_a?(SubjectTag) && subject.name == "Admin")
|
|
[ace.ubid, [subject, uuids[ace.action_id], uuids[ace.object_id]], editable]
|
|
end
|
|
@aces.compact!
|
|
sort_aces!(@aces)
|
|
|
|
@subject_options = {nil => [["", "Choose a Subject"]], **SubjectTag.options_for_project(@project)}
|
|
@action_options = {nil => [["", "All Actions"]], **ActionTag.options_for_project(@project)}
|
|
@object_options = {nil => [["", "All Objects"]], **ObjectTag.options_for_project(@project)}
|
|
|
|
view "project/access-control"
|
|
end
|
|
|
|
r.post true do
|
|
authorize("Project:editaccess", @project.id)
|
|
|
|
DB.transaction do
|
|
typecast_params.array!(:Hash, "aces").each do
|
|
ubid, deleted, subject_id, action_id, object_id = it.values_at("ubid", "deleted", "subject", "action", "object")
|
|
subject_id = nil if subject_id == ""
|
|
action_id = nil if action_id == ""
|
|
object_id = nil if object_id == ""
|
|
|
|
next unless subject_id
|
|
check_ace_subject(UBID.to_uuid(subject_id)) unless deleted
|
|
|
|
if ubid == "template"
|
|
next if deleted == "true"
|
|
ace = AccessControlEntry.new_with_id(project_id: @project.id)
|
|
else
|
|
next unless (ace = AccessControlEntry[project_id: @project.id, id: UBID.to_uuid(ubid)])
|
|
check_ace_subject(ace.subject_id)
|
|
if deleted == "true"
|
|
ace.destroy
|
|
next
|
|
end
|
|
end
|
|
ace.update_from_ubids(subject_id:, action_id:, object_id:)
|
|
end
|
|
end
|
|
|
|
flash["notice"] = "Access control entries saved successfully"
|
|
|
|
r.redirect "#{@project_data[:path]}/user/access-control"
|
|
end
|
|
|
|
r.on "tag", %w[subject action object] do |tag_type|
|
|
@tag_type = tag_type
|
|
@display_tag_type = tag_type.capitalize
|
|
@tag_model = Object.const_get(:"#{@display_tag_type}Tag")
|
|
|
|
r.get true do
|
|
authorize("Project:viewaccess", @project.id)
|
|
@tags = dataset_authorize(@tag_model.where(project_id: @project.id).order(:name), "#{@tag_model}:view").all
|
|
view "project/tag-list"
|
|
end
|
|
|
|
r.post true do
|
|
authorize(tag_perm_map[tag_type], @project.id)
|
|
@tag_model.create_with_id(project_id: @project.id, name: typecast_params.nonempty_str("name"))
|
|
flash["notice"] = "#{@display_tag_type} tag created successfully"
|
|
r.redirect "#{@project_data[:path]}/user/access-control/tag/#{@tag_type}"
|
|
end
|
|
|
|
r.on String do |ubid|
|
|
next unless (@tag = @tag_model[project_id: @project.id, id: UBID.to_uuid(ubid)])
|
|
# Metatag uuid is used to differentiate being allowed to manage
|
|
# tag itself, compared to being able to manage things contained in
|
|
# the tag.
|
|
@authorize_id = (tag_type == "object") ? @tag.metatag_uuid : @tag.id
|
|
|
|
r.is do
|
|
r.get true do
|
|
authorize("#{@tag.class}:view", @authorize_id)
|
|
|
|
members = @current_members = {}
|
|
@tag.member_ids.each do
|
|
next if @tag_type == "subject" && UBID.uuid_class_match?(it, ApiKey)
|
|
members[it] = nil
|
|
end
|
|
UBID.resolve_map(members)
|
|
view "project/tag"
|
|
end
|
|
|
|
authorize(tag_perm_map[tag_type], @project.id)
|
|
|
|
if @tag_type == "subject" && @tag.name == "Admin"
|
|
flash["error"] = "Cannot modify Admin subject tag"
|
|
r.redirect "#{@project_data[:path]}/user/access-control/tag/#{@tag_type}"
|
|
end
|
|
|
|
r.post do
|
|
@tag.update(name: typecast_params.nonempty_str("name"))
|
|
flash["notice"] = "#{@display_tag_type} tag name updated successfully"
|
|
r.redirect "#{@project_data[:path]}/user/access-control/tag/#{@tag_type}/#{@tag.ubid}"
|
|
end
|
|
|
|
r.delete do
|
|
@tag.destroy
|
|
flash["notice"] = "#{@display_tag_type} tag deleted successfully"
|
|
204
|
|
end
|
|
end
|
|
|
|
r.post "associate" do
|
|
authorize("#{@tag.class}:add", @authorize_id)
|
|
|
|
# Use serializable isolation to try to prevent concurrent changes
|
|
# from introducing loops
|
|
changes_made = to_add = issues = nil
|
|
DB.transaction(isolation: :serializable) do
|
|
to_add = typecast_params.array(:nonempty_str, "add") || []
|
|
to_add.reject! { UBID.class_match?(it, ApiKey) } if @tag_type == "subject"
|
|
to_add.map! { UBID.to_uuid(it) }
|
|
to_add, issues = @tag.check_members_to_add(to_add)
|
|
issues = "#{": " unless issues.empty?}#{issues.join(", ")}"
|
|
unless to_add.empty?
|
|
@tag.add_members(to_add)
|
|
changes_made = true
|
|
end
|
|
end
|
|
|
|
if changes_made
|
|
flash["notice"] = "#{to_add.length} members added to #{@tag_type} tag#{issues}"
|
|
else
|
|
flash["error"] = "No change in membership#{issues}"
|
|
end
|
|
|
|
r.redirect "#{@project_data[:path]}/user/access-control/tag/#{@tag_type}/#{@tag.ubid}"
|
|
end
|
|
|
|
r.post "disassociate" do
|
|
authorize("#{@tag.class}:remove", @authorize_id)
|
|
|
|
to_remove = typecast_params.array(:nonempty_str, "remove") || []
|
|
to_remove.reject! { UBID.class_match?(it, ApiKey) } if @tag_type == "subject"
|
|
to_remove.map! { UBID.to_uuid(it) }
|
|
|
|
num_removed = nil
|
|
# No need for serializable isolation here, as we are removing
|
|
# entries and that will not introduce loops
|
|
DB.transaction do
|
|
num_removed = @tag.remove_members(to_remove)
|
|
|
|
if @tag_type == "subject" && @tag.name == "Admin" && !@tag.member_ids.find { UBID.uuid_class_match?(it, Account) }
|
|
raise Sequel::ValidationFailed, "Must keep at least one account in Admin subject tag"
|
|
end
|
|
end
|
|
|
|
flash["notice"] = "#{num_removed} members removed from #{@tag_type} tag"
|
|
r.redirect "#{@project_data[:path]}/user/access-control/tag/#{@tag_type}/#{@tag.ubid}"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
r.delete "invitation", String do |email|
|
|
authorize("Project:user", @project.id)
|
|
|
|
@project.invitations_dataset.where(email: email).destroy
|
|
# Javascript handles redirect
|
|
flash["notice"] = "Invitation for '#{email}' is removed successfully."
|
|
204
|
|
end
|
|
|
|
r.delete String do |user_ubid|
|
|
authorize("Project:user", @project.id)
|
|
|
|
next unless (user = Account.from_ubid(user_ubid))
|
|
|
|
unless @project.accounts.count > 1
|
|
response.status = 400
|
|
next {error: {message: "You can't remove the last user from '#{@project.name}' project. Delete project instead."}}
|
|
end
|
|
|
|
@project.disassociate_subject(user.id)
|
|
user.remove_project(@project)
|
|
|
|
# Javascript refreshes page
|
|
flash["notice"] = "Removed #{user.email} from #{@project.name}"
|
|
204
|
|
end
|
|
end
|
|
end
|
|
end
|