Files
ubicloud/helpers/general.rb
Jeremy Evans c3e89fa568 Return 404 instead of 403 for unauthorized project access
403 leaks information about whether the requested project exists.
This approach for projects is now similar to how we treat other
nested objects, where we retrieve from an authorized dataset,
instead of retrieving the object and then (hopefully) performing
authorization on it.  It should also be faster as it eliminates
an unnecessary query.

Unfortunatley, the route specs mock Project.[] in quite a few
places.  To avoid a bunch of spec churn, add Clover.authorized_project,
and change the mocking to mock that instead.

As a consequence of this handling, deleting an unauthorized project
now returns 204 instead of 403.  I believe that is how deletion
of other unauthorized objects is handled, so the behavior is now
more consistent, but it is something to be aware of.
2025-05-16 02:06:02 +09:00

274 lines
8.2 KiB
Ruby

# frozen_string_literal: true
class Clover < Roda
def self.name_or_ubid_for(model)
# (\z)? to force a nil as first capture
[/(\z)?(#{model.ubid_type}[a-tv-z0-9]{24})/, /([a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)/]
end
[Firewall, KubernetesCluster, LoadBalancer, PostgresResource, PrivateSubnet, Vm].each do |model|
const_set(:"#{model.table_name.upcase}_NAME_OR_UBID", name_or_ubid_for(model))
end
# Designed only for compatibility with existing mocking in the specs
def self.authorized_project(account, project_id)
account.projects_dataset[Sequel[:project][:id] => project_id, :visible => true]
end
class RodaResponse
API_DEFAULT_HEADERS = DEFAULT_HEADERS.merge("content-type" => "application/json").freeze
WEB_DEFAULT_HEADERS = DEFAULT_HEADERS.merge(
"content-type" => "text/html",
"x-frame-options" => "deny",
"x-content-type-options" => "nosniff"
)
# :nocov:
if Config.production?
WEB_DEFAULT_HEADERS["strict-transport-security"] = "max-age=63072000; includeSubDomains"
end
# :nocov:
WEB_DEFAULT_HEADERS.freeze
attr_accessor :json
def default_headers
json ? API_DEFAULT_HEADERS : WEB_DEFAULT_HEADERS
end
end
AUDIT_LOG_DS = DB[:audit_log].returning(nil)
SUPPORTED_ACTIONS = Set.new(<<~ACTIONS.split.each(&:freeze)).freeze
add_account
add_invitation
add_member
associate
attach_vm
connect
create
create_replica
destroy
destroy_invitation
detach_vm
disassociate
disconnect
promote
remove_account
remove_member
reset_superuser_password
restart
restore
restrict
set_maintenance_window
unrestrict
update
update_billing
update_invitation
ACTIONS
LOGGED_ACTIONS = Set.new(%w[create create_replica destroy]).freeze
def audit_log(object, action, objects = [])
raise "unsupported audit_log action: #{action}" unless SUPPORTED_ACTIONS.include?(action)
# Currently, only store create and destroy actions in non-test mode.
# This can be removed later if we decide to expand to logging all actions.
# :nocov:
return unless LOGGED_ACTIONS.include?(action) || Config.test?
# :nocov:
project_id = @project.id
subject_id = current_account.id
ubid_type = object.class.ubid_type
object_ids = Array(objects).map do
case it
when Sequel::Model
it.id
when String
if it.length == 26
UBID.to_uuid(it)
else
it
end
else
it
end
end
object_ids.compact!
object_ids.unshift(object.id) unless object.is_a?(Project)
object_ids = Sequel.pg_array(object_ids, :uuid)
AUDIT_LOG_DS.insert(project_id:, ubid_type:, action:, subject_id:, object_ids:)
end
def no_audit_log
# Do nothing, this is a no-op method only used to check in the specs
# that all non-GET requests have some form of audit logging, as an explicit
# indication that audit logging is not needed
nil
end
def before_rodauth_create_account(account, name)
account[:id] = Account.generate_uuid
account[:name] = name
Validation.validate_account_name(account[:name])
end
def after_rodauth_create_account(account_id)
account = Account[account_id]
account.create_project_with_default_policy("Default")
ProjectInvitation.where(email: account.email).all do |inv|
account.add_project(inv.project)
inv.project.subject_tags_dataset.first(name: inv.policy)&.add_subject(account_id)
inv.destroy
end
end
def current_account_id
rodauth.session_value
end
def current_personal_access_token_id
rodauth.session["pat_id"]
end
def check_found_object(obj)
unless obj
response.status = if request.delete? && request.remaining_path.empty?
no_authorization_needed
204
else
404
end
request.halt
end
end
def no_authorization_needed
# Do nothing, this is a no-op method only used to check in the specs
# that all requests have some form of authorization, or an explicit
# indication that additional authorization is not needed
nil
end
private def each_authorization_id
return to_enum(:each_authorization_id) unless block_given?
yield current_account_id
if (pat_id = current_personal_access_token_id)
yield pat_id
end
nil
end
def authorize(actions, object_id)
if @project_permissions && object_id == @project.id
fail Authorization::Unauthorized unless has_project_permission(actions)
else
each_authorization_id do |id|
Authorization.authorize(@project.id, id, actions, object_id)
end
end
end
def has_permission?(actions, object_id)
each_authorization_id.all? do |id|
Authorization.has_permission?(@project.id, id, actions, object_id)
end
end
def all_permissions(object_id)
each_authorization_id.map do |id|
Authorization.all_permissions(@project.id, id, object_id)
end.reduce(:&)
end
def dataset_authorize(ds, actions)
each_authorization_id do |id|
ds = Authorization.dataset_authorize(ds, @project.id, id, actions)
end
ds
end
def has_project_permission(actions)
if actions.is_a?(Array)
!@project_permissions.intersection(actions).empty?
else
@project_permissions.include?(actions)
end
end
def current_account
return @current_account if defined?(@current_account)
@current_account = Account[rodauth.session_value]
end
def authorized_object(key:, perm:, association: nil, id: nil, ds: @project.send(:"#{association}_dataset"), location_id: nil)
if id ||= typecast_params.ubid_uuid(key)
ds = dataset_authorize(ds, perm)
ds = ds.where(location_id:) if location_id
ds.first(id:)
end
end
def check_visible_location
# If location previously retrieved in project/location route, check that it is visible
# This is called when creating resources in the api routes.
#
# If location not previously retrieved, require it be visible or tied to the current project
# when retrieving it. This is called when creating resources in the web routes.
@location ||= Location.visible_or_for_project(@project.id).first(id: request.params["location"])
handle_invalid_location unless @location&.visible_or_for_project?(@project.id)
end
def handle_invalid_location
if api?
# Only show locations globally visible or tied to the current project.
valid_locations = Location.visible_or_for_project(@project.id).select_order_map(:display_name)
response.write({error: {
code: 404,
type: "InvalidLocation",
message: "Validation failed for following path components: location",
details: {location: "Given location is not a valid location. Available locations: #{valid_locations.join(", ")}"}
}}.to_json)
end
response.status = 404
request.halt
end
def check_required_web_params(required_keys)
params = request.params
# Committee handles validation for API
if web?
missing_required_keys = required_keys - params.keys
unless missing_required_keys.empty?
fail Validation::ValidationFailed.new({body: "Request body must include required parameters: #{missing_required_keys.join(", ")}"})
end
end
params
end
def fetch_location_based_prices(*resource_types)
# We use 1 month = 672 hours for conversion. Number of hours
# in a month changes between 672 and 744, We are also capping
# billable hours to 672 while generating invoices. This ensures
# that users won't see higher price in their invoice compared
# to price calculator and also we charge same amount no matter
# the number of days in a given month.
BillingRate.rates.filter { resource_types.include?(it["resource_type"]) }
.group_by { [it["resource_type"], it["resource_family"], it["location"]] }
.map { |_, brs| brs.max_by { it["active_from"] } }
.each_with_object(Hash.new { |h, k| h[k] = h.class.new(&h.default_proc) }) do |br, hash|
hash[br["location"]][br["resource_type"]][br["resource_family"]] = {
hourly: br["unit_price"].to_f * 60,
monthly: br["unit_price"].to_f * 60 * 672
}
end
end
def default_rodauth_name
api? ? :api : nil
end
end