Last active
February 16, 2024 20:13
-
-
Save ramonrails/d1a390ade5dddeddc72178ab85c319e7 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# | |
# 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