feat: extend params with nested_from and assign_pair
This commit is contained in:
1 parent
464bcda96f
commit
b9fc8a09d2
3 files changed
+202
No files matched your search
@@ -1,9 +1,40 @@
|
|||||||
module Prism
|
module Prism
|
||||||
|
# Hash-like request parameter container: string/symbol keys are
|
||||||
|
# interchangeable everywhere (see #convert_key below).
|
||||||
class Params < Hash
|
class Params < Hash
|
||||||
def self.from(source)
|
def self.from(source)
|
||||||
source.each_with_object(new) { |(key, value), acc| acc[key] = value }
|
source.each_with_object(new) { |(key, value), acc| acc[key] = value }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Rack-style bracket expansion: turns a flat list of [key, value] pairs
|
||||||
|
# such as [["post[title]", "hi"], ["tags[]", "a"], ["tags[]", "b"]]
|
||||||
|
# (what a browser actually sends for a form built with
|
||||||
|
# Prism::FormBuilder's "object[attribute]" field names) into the nested
|
||||||
|
# Params/Array tree StrongParameters expects
|
||||||
|
# (params.require(:post).permit(:title), params['tags'] == ["a", "b"]).
|
||||||
|
def self.nested_from(pairs)
|
||||||
|
pairs.each_with_object(new) { |(key, value), acc| assign_pair(acc, key, value) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.assign_pair(container, key, value)
|
||||||
|
segments = key.to_s.scan(/[^\[\]]+|\[\]/)
|
||||||
|
name = segments.shift
|
||||||
|
return if name.nil?
|
||||||
|
insert(container, name, segments, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.insert(container, key, remaining, value)
|
||||||
|
if remaining.empty?
|
||||||
|
container[key] = value
|
||||||
|
elsif remaining.first == '[]'
|
||||||
|
(container[key] ||= []) << value
|
||||||
|
else
|
||||||
|
container[key] ||= new
|
||||||
|
insert(container[key], remaining.shift, remaining, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_class_method :insert
|
||||||
|
|
||||||
def [](key)
|
def [](key)
|
||||||
super(convert_key(key))
|
super(convert_key(key))
|
||||||
end
|
end
|
||||||
@@ -27,6 +58,10 @@ module Prism
|
|||||||
alias_method :include?, :key?
|
alias_method :include?, :key?
|
||||||
alias_method :member?, :key?
|
alias_method :member?, :key?
|
||||||
|
|
||||||
|
def delete(key, &block)
|
||||||
|
super(convert_key(key), &block)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def convert_key(key)
|
def convert_key(key)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
module Prism
|
||||||
|
# Pundit-lite authorization: a Policy pairs a user with a record and
|
||||||
|
# answers yes/no permission questions about it, keeping "who can do
|
||||||
|
# what" out of models and controllers. A PostPolicy answers questions
|
||||||
|
# about a Post (`edit?`, `destroy?`); its nested Scope narrows a
|
||||||
|
# collection down to what a given user may see at all.
|
||||||
|
#
|
||||||
|
# class PostPolicy < Prism::Policy
|
||||||
|
# def destroy?
|
||||||
|
# user && record.user_id == user.id
|
||||||
|
# end
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# Any query left undefined answers false (default-deny), so a policy only
|
||||||
|
# has to spell out what it allows.
|
||||||
|
class Policy
|
||||||
|
class NotAuthorizedError < StandardError
|
||||||
|
attr_reader :record, :query
|
||||||
|
|
||||||
|
def initialize(record, query)
|
||||||
|
@record = record
|
||||||
|
@query = query
|
||||||
|
super("#{record.class} not authorized for #{query}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
attr_reader :user, :record
|
||||||
|
|
||||||
|
def initialize(user, record)
|
||||||
|
@user = user
|
||||||
|
@record = record
|
||||||
|
end
|
||||||
|
|
||||||
|
def method_missing(name, *args, &block)
|
||||||
|
return false if args.empty? && !block && name.to_s.end_with?('?')
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
def respond_to_missing?(name, include_private = false)
|
||||||
|
name.to_s.end_with?('?') || super
|
||||||
|
end
|
||||||
|
|
||||||
|
# Narrows a collection (an ORM Relation or any enumerable) down to what
|
||||||
|
# `user` may see, e.g. `PostPolicy::Scope.new(current_user, Post.all).resolve`.
|
||||||
|
class Scope
|
||||||
|
attr_reader :user, :scope
|
||||||
|
|
||||||
|
def initialize(user, scope)
|
||||||
|
@user = user
|
||||||
|
@scope = scope
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve
|
||||||
|
raise NotImplementedError, "#{self.class} must implement #resolve"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class << self
|
||||||
|
# PostPolicy for a Post/Post instance, Admin::PostPolicy for
|
||||||
|
# Admin::Post - mirrors how Controller#controller_path and
|
||||||
|
# Authenticatable resolve names lazily instead of hardcoding them.
|
||||||
|
def for_record(record)
|
||||||
|
klass = record.is_a?(Class) ? record : record.class
|
||||||
|
Object.const_get("#{klass.name}Policy")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
require_relative 'rate_limiter/memory_store'
|
||||||
|
require_relative 'rate_limiter/pstore_store'
|
||||||
|
|
||||||
|
module Prism
|
||||||
|
# Rack::Attack-lite: request throttling, blocklisting/safelisting and a
|
||||||
|
# fail2ban-style repeat-offender ban, evaluated by
|
||||||
|
# Middleware::RateLimiting on every request. Rules are discriminator
|
||||||
|
# blocks that receive a Prism::Request and return a truthy key (or nil
|
||||||
|
# to skip the rule for that request) - the same shape for all four rule
|
||||||
|
# kinds, only how the key is used differs.
|
||||||
|
module RateLimiter
|
||||||
|
class Rule
|
||||||
|
attr_reader :name, :limit, :period
|
||||||
|
|
||||||
|
def initialize(name:, discriminator:, limit: nil, period: nil)
|
||||||
|
@name = name
|
||||||
|
@discriminator = discriminator
|
||||||
|
@limit = limit
|
||||||
|
@period = period
|
||||||
|
end
|
||||||
|
|
||||||
|
def key_for(request)
|
||||||
|
@discriminator.call(request)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class << self
|
||||||
|
attr_writer :store
|
||||||
|
|
||||||
|
def store
|
||||||
|
@store ||= MemoryStore.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def throttles
|
||||||
|
@throttles ||= {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def blocklists
|
||||||
|
@blocklists ||= {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def safelists
|
||||||
|
@safelists ||= {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def tracks
|
||||||
|
@tracks ||= {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def throttle(name, limit:, period:, &discriminator)
|
||||||
|
throttles[name.to_s] = Rule.new(name: name.to_s, discriminator: discriminator, limit: limit, period: period)
|
||||||
|
end
|
||||||
|
|
||||||
|
def blocklist(name, &discriminator)
|
||||||
|
blocklists[name.to_s] = Rule.new(name: name.to_s, discriminator: discriminator)
|
||||||
|
end
|
||||||
|
|
||||||
|
def safelist(name, &discriminator)
|
||||||
|
safelists[name.to_s] = Rule.new(name: name.to_s, discriminator: discriminator)
|
||||||
|
end
|
||||||
|
|
||||||
|
def track(name, &discriminator)
|
||||||
|
tracks[name.to_s] = Rule.new(name: name.to_s, discriminator: discriminator)
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_notify(&block)
|
||||||
|
@notifier = block
|
||||||
|
end
|
||||||
|
|
||||||
|
def notify(type, name, request, payload = nil)
|
||||||
|
@notifier&.call(type, name, request, payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Bans +discriminator+ (typically an IP) for +bantime+ seconds once
|
||||||
|
# the block yields true +maxretry+ times within a +findtime+ window -
|
||||||
|
# the classic fail2ban pattern, e.g. banning an IP that repeatedly
|
||||||
|
# probes for `/etc/passwd` in the query string. Meant to be called
|
||||||
|
# from inside a #blocklist block.
|
||||||
|
def fail2ban_filter(discriminator, maxretry:, findtime:, bantime:)
|
||||||
|
key = "fail2ban:#{discriminator}"
|
||||||
|
return true if store.banned?(key)
|
||||||
|
return false unless yield
|
||||||
|
count = store.count(key, findtime)
|
||||||
|
return false if count < maxretry
|
||||||
|
store.ban!(key, bantime)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def reset!
|
||||||
|
@throttles = {}
|
||||||
|
@blocklists = {}
|
||||||
|
@safelists = {}
|
||||||
|
@tracks = {}
|
||||||
|
@notifier = nil
|
||||||
|
store.reset!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in new issue
Block a user