Skip to content

Instantly share code, notes, and snippets.

@ramonrails
Last active February 16, 2024 20:13
Show Gist options
  • Save ramonrails/d1a390ade5dddeddc72178ab85c319e7 to your computer and use it in GitHub Desktop.
Save ramonrails/d1a390ade5dddeddc72178ab85c319e7 to your computer and use it in GitHub Desktop.
#
# Bucket is a wrapper around Apartment::Tenant for utility methods
#
class Bucket
PUBLIC = "public".freeze
SYSTEM_NAMES_REGEX_PATTERN = 'information_schema|pg_\w+'.freeze
#
# Class methods
#
class << self
#
# Create a database bucket schema with the name
#
# @param [String] name of the bucket to create
#
# @return [String] bucket name, if created or exists, otherwise `nil`
#
def create(name = nil)
name = Bucket.sanitize_name(name)
# return blank if no name given
return if name.blank?
return name if Bucket.exists?(name)
# create the schema
Apartment::Tenant.create(name)
# return the bucket name
name
end
alias ensure create
#
# current bucket name
#
# @return [String] name of the current bucket
#
def current
Apartment::Tenant.current
end
#
# Does this bucket already exists or not
#
# @param [String] name of the bucket
#
# @return [Boolean] exists? => true, otherwise => false
#
def exists?(name = nil)
name = name.to_s # if symbol was given
# W3C specifications (a-z 0-9 -) 63 max
name = name.downcase.gsub(/[^a-z0-9\-]/, "")[...63] # Bucket.sanitize_name(name)
# return nothing if no bucket name given
return if name.blank?
# verify from underlying database record, not ORM object in memory
# WARN: potential SQL injection => WHERE schema_name = '#{name}';",
names = ActiveRecord::Base.connection.execute(
"SELECT schema_name FROM information_schema.schemata"
).pluck('schema_name')
# check if schema with that name exists
(names & [name]).present?
end
#
# Is the given name restricted for creating buckets?
#
# @param [String] name of the bucket to check
#
# @return [Boolean] restricted or not (configured restricted names, system names, `public`)
#
def restricted_name?(name = nil)
name = name.to_s # if symbol was given
# W3C specifications (a-z 0-9 -) 63 max + `_` (underscore)
# NOTE: `_` underscrore is added here
name = name.downcase.gsub(/[^a-z0-9\-_]/, "")[...63]
# exclude names restricted by us
# exclude system names
name.in?(restricted_names) ||
name.match?(/#{Bucket::SYSTEM_NAMES_REGEX_PATTERN}/o) ||
name == Bucket::PUBLIC
end
#
# Restricted names configured by us
#
# @return [String[Array]] names collection
#
def restricted_names
Apartment::Elevators::Subdomain.excluded_subdomains
end
#
# Existing usable bucket names (sensitive/system schemas filtered out)
#
# @return [String[Array]] names of the usable existing buckets
#
def names
# pluck the names of available schemas
# exclude system buckets
# but, add `public`
(
ActiveRecord::Base.connection.execute(
"
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name
NOT SIMILAR TO '%(#{Bucket::SYSTEM_NAMES_REGEX_PATTERN})%';
"
).pluck("schema_name") << Bucket::PUBLIC
).uniq.sort
end
# Usage:
# Bucket.all, Bucket.names
alias_method :all, :names
#
# Switch permanently to the bucket
#
# @param [String|Symbol] name of the bucket
#
# @return [String] name of the bucket we are in, after the switch
#
def switch!(name = nil)
name = name.to_s # if symbol was given
name = Bucket.sanitize_name(name) unless name == Bucket::PUBLIC
# no action when
# - bucket name missing
# - we are already in the bucket
return if name.blank?
return name if Bucket.current == name
# switch only if bucket exists
Apartment::Tenant.switch! name if Bucket.exists?(name)
# return where we are now
Bucket.current
end
#
# Peek into the bucket for one action/call, without permanently switching to it
#
# @param [String|Symbol] name of the bucket to peek
# @param [Block] &block to execute while peeking
#
# @return [Any] value returned by the block excuted during the peeking
#
def switch(name = nil, &block)
name = name.to_s # if symbol was given
name = Bucket.sanitize_name(name) unless name == Bucket::PUBLIC
# proceed when
# - name given
# - block present
# - schema exists
return unless name.present? && block && Bucket.exists?(name)
# switch to the bucket and run the block
Apartment::Tenant.switch(name, &block)
end
#
# Switch permanently to the public bucket
#
# @return [String] 'public'
#
def public!
# swith permanently to public bucket
Apartment::Tenant.switch! Bucket::PUBLIC if Bucket.exists? Bucket::PUBLIC
# return the bucket name
Bucket.current
end
#
# Are we at the public bucket?
#
# @return [Boolean] true or false
#
def public?
Bucket.current == Bucket::PUBLIC
end
#
# Really destroy a bucket and all data in it
#
# @param [String] name of the bucket
# @param [Hash] **options to make it tough to accidently destroy a bucket with code
#
# @return [Any] return value of apartment gem when bucket is dropped
#
def really_destroy!(name = nil, **options)
name = Bucket.sanitize_name(name)
# no action when
# - bucket name is missing
# - bucket does not exist
return unless name.present? && Bucket.exists?(name) && options[:all_data_permanently] == true
Apartment::Tenant.drop(name)
end
#
# Sanitize the bucket name as per W3C specifications
#
# @param [String] name of the bucket
# @param [Array] any arguments as array
#
# @return [String] sanitized name
#
def sanitize_name(name = nil, *args)
# remove anything other than a-z0-9 from the name
# max 63 characters (W3C DNS specs)
# exclude any system name
# exclude restricted names
excluded_names = [
(args.present? ? args.collect(&:to_s).join("|") : nil),
Bucket.restricted_names.join("|"),
Bucket::SYSTEM_NAMES_REGEX_PATTERN
].compact.join("|")
# FIXME: Check: RegexDoS, Category: Denial of Service, Model attribute used in regular expression
#
# first, convert to string
name.to_s
# clear any sensitive names
.gsub(/(#{excluded_names})/, "")
# comply to W3C specifications
.downcase.gsub(/[^a-z0-9\-]/, "")[...63]
# clear sensitive names again (just in case someone was playing smart)
.gsub(/(#{excluded_names})/, "")
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment