Files
ubicloud/routes/project/user.rb

398 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
view "project/user"
end
r.post do
email = typecast_params.nonempty_str!("email")
handle_validation_failure("project/user")
if ProjectInvitation[project_id: @project.id, email: email]
raise_web_error("'#{email}' already invited to join the project.")
elsif @project.invitations_dataset.count >= 50
raise_web_error("You can't have more than 50 pending invitations.")
end
if (policy = typecast_params.nonempty_str("policy"))
unless (tag = dataset_authorize(@project.subject_tags_dataset, "SubjectTag:add").first(name: policy))
raise_web_error("You don't have permission to invite users with this subject tag.")
end
end
user = Account.exclude(status_id: 3)[email: email]
DB.transaction do
if user
result = DB[:access_tag]
.returning(:hyper_tag_id)
.insert_conflict
.insert(hyper_tag_id: user.id, project_id: @project.id)
audit_log(@project, "add_account", user)
if result.empty?
raise_web_error("The requested user already has access to this project")
end
if tag
tag.add_subject(user.id)
audit_log(tag, "add_member", user)
end
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)
audit_log(@project, "add_invitation")
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
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)
handle_validation_failure("project/user")
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 do |tag, user_ids|
tag.add_members(user_ids)
audit_log(tag, "add_member", user_ids)
end
removals.each do |tag, user_ids|
tag.remove_members(user_ids)
audit_log(tag, "remove_member", user_ids)
end
additions.transform_keys!(&:name)
removals.transform_keys!(&:name)
if @project.subject_tags_dataset.first(name: "Admin").member_ids.empty?
raise_web_error("The project must have at least one admin.")
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:)
audit_log(@project, "update_invitation")
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}" }
no_audit_log if changes.empty?
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.is do
r.get 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 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(project_id: @project.id)
audit_action = "create"
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
audit_log(ace, "destroy")
next
end
audit_action = "update"
end
ace.update_from_ubids(subject_id:, action_id:, object_id:)
audit_log(ace, audit_action, [subject_id, action_id, object_id])
end
end
flash["notice"] = "Access control entries saved successfully"
r.redirect "#{@project_data[:path]}/user/access-control"
end
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.is do
r.get do
authorize("Project:viewaccess", @project.id)
view "project/tag-list"
end
r.post do
authorize(tag_perm_map[tag_type], @project.id)
handle_validation_failure("project/tag-list")
DB.transaction do
tag = @tag_model.create(project_id: @project.id, name: typecast_params.nonempty_str("name"))
audit_log(tag, "create")
end
flash["notice"] = "#{@display_tag_type} tag created successfully"
r.redirect "#{@project_data[:path]}/user/access-control/tag/#{@tag_type}"
end
end
r.on :ubid_uuid do |id|
next unless (@tag = @tag_model[project_id: @project.id, id:])
# 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 do
authorize("#{@tag.class}:view", @authorize_id)
view "project/tag"
end
authorize(tag_perm_map[tag_type], @project.id)
if @tag_type == "subject" && @tag.name == "Admin"
handle_validation_failure("project/tag-list")
raise_web_error("Cannot modify Admin subject tag")
end
r.post do
handle_validation_failure("project/tag")
@tag.update(name: typecast_params.nonempty_str("name"))
audit_log(@tag, "update")
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
audit_log(@tag, "destroy")
flash["notice"] = "#{@display_tag_type} tag deleted successfully"
204
end
end
r.post "associate" do
authorize("#{@tag.class}:add", @authorize_id)
handle_validation_failure("project/tag")
# 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)
audit_log(@tag, "add_member", 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)
handle_validation_failure("project/tag")
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)
audit_log(@tag, "remove_member", 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
audit_log(@project, "destroy_invitation")
# Javascript handles redirect
flash["notice"] = "Invitation for '#{email}' is removed successfully."
204
end
r.delete :ubid_uuid do |id|
authorize("Project:user", @project.id)
next unless (user = @project.accounts_dataset[id:])
unless @project.accounts_dataset.count > 1
raise_web_error("You can't remove the last user from '#{@project.name}' project. Delete project instead.")
end
@project.disassociate_subject(user.id)
user.remove_project(@project)
audit_log(@project, "remove_account", user)
# Javascript refreshes page
flash["notice"] = "Removed #{user.email} from #{@project.name}"
204
end
end
end
end