From b9fc8a09d25570cfb201c3deddbb8772e920aaa0 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 21:22:47 +0200 Subject: [PATCH] feat: extend params with nested_from and assign_pair --- lib/prism/params.rb | 35 ++++++++++++++ lib/prism/policy.rb | 68 +++++++++++++++++++++++++++ lib/prism/rate_limiter.rb | 99 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 lib/prism/policy.rb create mode 100644 lib/prism/rate_limiter.rb diff --git a/lib/prism/params.rb b/lib/prism/params.rb index ac3cbb2..0f9fa31 100644 --- a/lib/prism/params.rb +++ b/lib/prism/params.rb @@ -1,9 +1,40 @@ module Prism + # Hash-like request parameter container: string/symbol keys are + # interchangeable everywhere (see #convert_key below). class Params < Hash def self.from(source) source.each_with_object(new) { |(key, value), acc| acc[key] = value } 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) super(convert_key(key)) end @@ -27,6 +58,10 @@ module Prism alias_method :include?, :key? alias_method :member?, :key? + def delete(key, &block) + super(convert_key(key), &block) + end + private def convert_key(key) diff --git a/lib/prism/policy.rb b/lib/prism/policy.rb new file mode 100644 index 0000000..ed2dedb --- /dev/null +++ b/lib/prism/policy.rb @@ -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 diff --git a/lib/prism/rate_limiter.rb b/lib/prism/rate_limiter.rb new file mode 100644 index 0000000..b481ffc --- /dev/null +++ b/lib/prism/rate_limiter.rb @@ -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