Commit 5257bd1d authored by Sam Wiskow's avatar Sam Wiskow
Browse files

feat: add rule names as stable counter keys (Stage 1b)

Replaces `rule_index` in Redis keys and log payloads with a required
`name` field on `Rule`, so counters survive rule reordering and log
consumers see human-readable names instead of integer positions.

- Rule.new requires `name:` (String/Symbol, /\A[a-z0-9_]+\z/, ≤64 chars)
  and raises in dev/test for violations; production stores as-is for
  the Evaluator to sanitize
- Evaluator.prepare_rules sanitizes invalid names and deduplicates by
  sanitized name before the evaluation loop, emitting WARN events
- Redis key shape: labkit:rl:{call_site}:{rule_name}:{char}:{value}
- Log payload: rule_name replaces rule_index

Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent dc3aa483
Loading
Loading
Loading
Loading
+182 −40
Original line number Diff line number Diff line
# frozen_string_literal: true

require "openssl"
require "labkit/logging/json_logger"

module Labkit
  module RateLimit
    # Evaluator holds the static parts of a rate limit check (name, rules, Redis)
    # and exposes a per-request #check(identifier) method.
    # @api private
    # Evaluator contains the core rule-matching + Redis counter logic.
    class Evaluator
      KNOWN_CHARACTERISTICS = [:user, :ip, :namespace, :plan, :endpoint].freeze
      KNOWN_ACTIONS = [:block, :log].freeze
      REDIS_KEY_PREFIX = "labkit:rl"
      CHAR_VALUE_MAX_LENGTH = 200
      MISSING_VALUE_SENTINEL = "_unknown_"

      def initialize(name:, rules:, redis:, logger:)
        @name   = name
      UNKNOWN_SENTINEL = "unknown_characteristic"
      CALL_SITE_PATTERN = /\A[a-z0-9_]+\z/
      RULE_NAME_PATTERN = CALL_SITE_PATTERN
      RULE_NAME_MAX_LENGTH = 64

      def initialize(call_site:, identifier:, rules:, redis:, logger: nil)
        @call_site = call_site
        @identifier = identifier
        @rules = rules
        @redis = redis
        @logger = logger
        @logger = logger || build_default_logger
      end

      def check(identifier)
        check_rules(identifier)
      def evaluate
        validate_call_site!
        evaluate_rules
      rescue ArgumentError
        raise
      rescue StandardError => e
        # Intentionally broad: fail-open applies to any unexpected error (network,
        # timeout, OOM) not only Redis protocol errors.
        log_error(e, identifier)
        Result.new(matched: false, error: true)
        # timeout, OOM, etc.), not only Redis protocol errors.
        log_evaluate_error(e)
        :allow
      end

      private

      def check_rules(identifier)
        @rules.each do |rule|
          next unless rule_matches?(rule, identifier)
      def evaluate_rules
        aggregate = :allow

        prepare_rules(@rules).each do |rule, rule_name|
          next unless rule_matches?(rule, @identifier)

          return evaluate_rule(rule, identifier)
          result = evaluate_rule(rule, rule_name)
          aggregate = :block if result == :block
        end

        Result.new(matched: false)
        aggregate
      end

      def rule_matches?(rule, identifier)
        rule.match.all? { |key, value| identifier[key] == value }
      # Returns an array of [rule, sanitized_name] pairs with duplicates removed.
      # In dev/test: raises ArgumentError on invalid or duplicate names.
      # In production: sanitizes invalid names (logging WARN) and drops later
      # occurrences of duplicates (logging WARN), keeping first occurrence.
      def prepare_rules(rules)
        seen = {}
        result = []

        rules.each_with_index do |rule, idx|
          rule_name = sanitize_rule_name(rule.name)

          if seen.key?(rule_name)
            if dev_or_test?
              raise ArgumentError,
                "Duplicate rule name #{rule_name.inspect} at occurrence #{idx}"
            end

      def evaluate_rule(rule, identifier)
        redis_key = build_redis_key(rule, identifier)
        resolved_limit = Integer(resolve_value(rule.limit))
        resolved_period = Integer(resolve_value(rule.period))
            @logger.warn(
              message: "rate_limit_duplicate_rule_name",
              call_site: @call_site,
              name: rule_name,
              dropped_occurrence: idx
            )
            next
          end

        count = incr_with_ttl(redis_key, resolved_period)
        exceeded = count > resolved_limit
          seen[rule_name] = true
          result << [rule, rule_name]
        end

        Result.new(matched: true, exceeded: exceeded, action: rule.action, rule: rule)
        result
      end

      def build_redis_key(rule, identifier)
        key = "#{REDIS_KEY_PREFIX}:#{@name}:#{rule.name}"
      # Returns a sanitized rule name string.
      # Valid names pass through unchanged. Invalid names in production are
      # downcased, non-matching chars replaced with _, truncated to 64 chars,
      # and a WARN is logged. In dev/test the Rule constructor already raised,
      # so invalid names should never reach here.
      def sanitize_rule_name(name)
        name_str = name.to_s
        return name_str if RULE_NAME_PATTERN.match?(name_str) && name_str.length <= RULE_NAME_MAX_LENGTH

        sanitized = name_str.downcase.gsub(/[^a-z0-9_]/, "_")[0, RULE_NAME_MAX_LENGTH]
        @logger.warn(
          message: "rate_limit_invalid_rule_name",
          call_site: @call_site,
          original_name: name_str,
          sanitized_name: sanitized
        )
        sanitized
      end

      def validate_call_site!
        return if CALL_SITE_PATTERN.match?(@call_site)

        raise ArgumentError, "Invalid call_site: #{@call_site.inspect}. Must match /\\A[a-z0-9_]+\\z/" if dev_or_test?

        sanitized = @call_site.gsub(/[^a-z0-9_]/, "_")
        @logger.warn(
          message: "rate_limit_invalid_call_site",
          call_site: @call_site,
          sanitized: sanitized
        )
        @call_site = sanitized
      end

      def rule_matches?(rule, identifier)
        rule.match.all? do |key, value|
          identifier[key] == value
        end
      end

      def evaluate_rule(rule, rule_name)
        exceeded = false

        rule.characteristics.each do |char|
          value = resolve_char_value(char, identifier)
          key += ":#{char}:#{encode_char_value(value)}"
          char_value = resolve_characteristic(char, @identifier)

          if char_value.nil?
            log_skipped_characteristic(rule, rule_name, char)
            next
          end
        key

          redis_key = build_redis_key(@call_site, rule_name, char, char_value)

          count = incr_with_ttl(redis_key, rule.period)
          rule_exceeded = count > rule.limit

          exceeded = true if rule_exceeded

          log_rule(rule, rule_name, count, redis_key, rule_exceeded)
        end

        exceeded && rule.action == :block ? :block : :allow
      end

      def resolve_characteristic(char, identifier)
        unless KNOWN_CHARACTERISTICS.include?(char)
          raise ArgumentError, "Unknown characteristic: #{char.inspect}. Known: #{KNOWN_CHARACTERISTICS.inspect}" if dev_or_test?

          @logger.warn(
            message: "rate_limit_unknown_characteristic",
            characteristic: char
          )
          return UNKNOWN_SENTINEL
        end

      def resolve_char_value(char, identifier)
        value = identifier[char]
        return MISSING_VALUE_SENTINEL if value.nil? || value.to_s.empty?

        # Normalize endpoint: strip query string
        value = Identifier.normalize_endpoint(value) if char == :endpoint

        # Treat nil and empty-string the same: anonymous traffic must not collide on a shared bucket.
        return nil if value.nil? || value.to_s.empty?

        value.to_s
      end

      def resolve_value(val)
        val.respond_to?(:call) ? val.call : val
      def build_redis_key(call_site, rule_name, char, char_value)
        safe_value = encode_char_value(char_value.to_s)
        "#{REDIS_KEY_PREFIX}:#{call_site}:#{rule_name}:#{char}:#{safe_value}"
      end

      def encode_char_value(value)
@@ -90,14 +189,57 @@ module Labkit
        count
      end

      def log_error(error, identifier)
      def log_rule(rule, rule_name, count, redis_key, exceeded)
        @logger.info(
          message: "rate_limit_check",
          call_site: @call_site,
          rule_name: rule_name,
          action: rule.action.to_s,
          limit: rule.limit,
          period: rule.period,
          count: count,
          matched: true,
          exceeded: exceeded,
          identifier: @identifier.to_h,
          redis_key: redis_key
        )
      end

      def log_skipped_characteristic(rule, rule_name, char)
        @logger.info(
          message: "rate_limit_check",
          call_site: @call_site,
          rule_name: rule_name,
          action: rule.action.to_s,
          limit: rule.limit,
          period: rule.period,
          characteristic: char,
          matched: true,
          skipped: true,
          identifier: @identifier.to_h
        )
      end

      def log_evaluate_error(error)
        @logger.warn(
          message: "rate_limit_error",
          name: @name,
          message: "rate_limit_redis_error",
          call_site: @call_site,
          error: error.class.to_s,
          identifier: identifier&.to_h
          result: "allow"
        )
      end

      def dev_or_test?
        # Memoized: ENV access is not free under concurrency.
        return @dev_or_test unless @dev_or_test.nil?

        env = ENV.fetch("LABKIT_ENV", nil)
        @dev_or_test = env == "test" || env == "development"
      end

      def build_default_logger
        Labkit::Logging::JsonLogger.new($stdout)
      end
    end
  end
end
+18 −1
Original line number Diff line number Diff line
@@ -10,10 +10,27 @@ module Labkit
    # period          - window in seconds; may be a callable (resolved per check)
    # action          - :block (enforce) or :log (count and log, but do not block)
    # characteristics - identifier keys used to build the compound Redis counter key
    #
    # +name+ must be a lowercase alphanumeric-and-underscore string of at most 64
    # characters. It is used as the middle segment of every Redis counter key for
    # this rule, so changing a rule's name mid-window abandons its in-flight counters.
    Rule = Data.define(:name, :match, :limit, :period, :action, :characteristics) do
      RULE_NAME_PATTERN = /\A[a-z0-9_]+\z/
      RULE_NAME_MAX_LENGTH = 64

      def initialize(name:, limit:, period:, characteristics:, match: {}, action: :block)
        raise ArgumentError, "name must be a String or Symbol, got #{name.class}" unless name.is_a?(String) || name.is_a?(Symbol)

        name_str = name.to_s
        raise ArgumentError, "name must not be empty" if name_str.empty?

        if Labkit.dev_or_test?
          raise ArgumentError, "Invalid rule name: #{name.inspect}. Must match /\\A[a-z0-9_]+\\z/" unless RULE_NAME_PATTERN.match?(name_str)
          raise ArgumentError, "Rule name too long: #{name.inspect}. Maximum 64 characters" if name_str.length > RULE_NAME_MAX_LENGTH
        end

        super(
          name: name.to_s.tr(":", "_"),
          name: name_str.freeze,
          match: match.transform_keys(&:to_sym).freeze,
          limit: limit,
          period: period,
+148 −92
Original line number Diff line number Diff line
@@ -7,165 +7,221 @@ RSpec.describe Labkit::RateLimit::Evaluator do
  include StubENV

  let(:redis) { instance_double(Redis) }
  let(:null_logger) { instance_double(Labkit::Logging::JsonLogger, warn: nil) }
  let(:logger) { instance_double(Logger) }
  let(:identifier) { Labkit::RateLimit::Identifier.new(user: 42, ip: "1.2.3.4") }

  def make_rule(name: "default", match: {}, limit: 100, period: 60, action: :block, characteristics: [:user])
  def make_rule(name: "test_rule", match: {}, limit: 100, period: 60, action: :block, characteristics: [:user])
    Labkit::RateLimit::Rule.new(
      name: name, match: match, limit: limit, period: period,
      action: action, characteristics: characteristics
      name: name, match: match, limit: limit,
      period: period, action: action, characteristics: characteristics
    )
  end

  def evaluator(name: "rack_request", rules: [])
    described_class.new(name: name, rules: rules, redis: redis, logger: null_logger)
  def evaluator(call_site: "rack_request", rules: [], id: identifier)
    described_class.new(call_site: call_site, identifier: id, rules: rules, redis: redis, logger: logger)
  end

  before do
    stub_env("RAILS_ENV", "test")
    stub_env("LABKIT_ENV", "test")
    allow(logger).to receive(:info)
    allow(logger).to receive(:warn)
  end

  describe "Scenario Q: Redis key format uses rule name" do
    it "builds a compound key with the rule name" do
      rule = make_rule(name: "unauthenticated_api", characteristics: [:ip])
      id = Labkit::RateLimit::Identifier.new(ip: "1.2.3.4")

      expect(redis).to receive(:incr)
        .with("labkit:rl:rack_request:unauthenticated_api:ip:1.2.3.4")
        .and_return(1)
      expect(redis).to receive(:expire).with("labkit:rl:rack_request:unauthenticated_api:ip:1.2.3.4", 60)
  describe "Redis key format" do
    it "builds the correct key using rule name for a matched rule" do
      rule = make_rule(name: "authenticated_api", characteristics: [:user])
      expect(redis).to receive(:incr).with("labkit:rl:rack_request:authenticated_api:user:42").and_return(1)
      expect(redis).to receive(:expire).with("labkit:rl:rack_request:authenticated_api:user:42", 60)

      evaluator(rules: [rule]).check(id)
      evaluator(rules: [rule]).evaluate
    end

    it "joins multiple characteristics into a single compound key (Scenario B)" do
      rule = make_rule(name: "auth_api", characteristics: [:user, :ip])
      id = Labkit::RateLimit::Identifier.new(user: 42, ip: "1.2.3.4")
    it "uses rule name (not index) to isolate counters between rules" do
      rule_a = make_rule(name: "rule_a", characteristics: [:user])
      rule_b = make_rule(name: "rule_b", characteristics: [:user])

      expect(redis).to receive(:incr)
        .with("labkit:rl:rack_request:auth_api:user:42:ip:1.2.3.4")
        .and_return(1)
      expect(redis).to receive(:expire)
      expect(redis).to receive(:incr).with("labkit:rl:rack_request:rule_a:user:42").and_return(1)
      expect(redis).to receive(:expire).with("labkit:rl:rack_request:rule_a:user:42", 60)
      expect(redis).to receive(:incr).with("labkit:rl:rack_request:rule_b:user:42").and_return(1)
      expect(redis).to receive(:expire).with("labkit:rl:rack_request:rule_b:user:42", 60)

      evaluator(rules: [rule]).check(id)
      evaluator(rules: [rule_a, rule_b]).evaluate
    end

    it "uses SHA-256 for char_value > 200 chars" do
      long_value = "x" * 201
      id = Labkit::RateLimit::Identifier.new(user: long_value)
      rule = make_rule(name: "sha_rule", characteristics: [:user])
      rule = make_rule(name: "test_rule", characteristics: [:user])

      expected_hash = OpenSSL::Digest::SHA256.hexdigest(long_value)
      expect(redis).to receive(:incr)
        .with("labkit:rl:rack_request:sha_rule:user:#{expected_hash}")
        .and_return(1)
      expect(redis).to receive(:incr).with("labkit:rl:rack_request:test_rule:user:#{expected_hash}").and_return(1)
      expect(redis).to receive(:expire)

      evaluator(rules: [rule]).check(id)
      evaluator(rules: [rule], id: id).evaluate
    end

    it "produces different keys for two distinct long values" do
      hash_a = OpenSSL::Digest::SHA256.hexdigest("a#{'x' * 200}")
      hash_b = OpenSSL::Digest::SHA256.hexdigest("b#{'x' * 200}")
      expect(hash_a).not_to eq(hash_b)
    end
  end

  describe "Scenario R: counter TTL matches rule period" do
    it "sets expire on first write (count == 1)" do
      rule = make_rule(name: "ttl_rule", period: 120)
      expect(redis).to receive(:incr).and_return(1)
      expect(redis).to receive(:expire).with(anything, 120)
    it "produces different keys for two distinct long values sharing a prefix" do
      val_a = "a#{'x' * 200}"
      val_b = "b#{'x' * 200}"

      evaluator(rules: [rule]).check(identifier)
      hash_a = OpenSSL::Digest::SHA256.hexdigest(val_a)
      hash_b = OpenSSL::Digest::SHA256.hexdigest(val_b)
      expect(hash_a).not_to eq(hash_b)
    end

    it "does not reset expire on subsequent writes (count > 1)" do
      rule = make_rule(name: "ttl_rule")
    it "does not set expire after first write (count > 1)" do
      rule = make_rule
      expect(redis).to receive(:incr).and_return(5)
      expect(redis).not_to receive(:expire)

      evaluator(rules: [rule]).check(identifier)
    end
      evaluator(rules: [rule]).evaluate
    end

  describe "Scenario C: _unknown_ sentinel for missing characteristic" do
    it "uses _unknown_ sentinel when characteristic value is nil" do
      id = Labkit::RateLimit::Identifier.new(ip: "1.2.3.4")
      rule = make_rule(name: "auth_rule", characteristics: [:user])
    it "strips query strings from endpoint values in keys" do
      id = Labkit::RateLimit::Identifier.new(endpoint: "/api/v4/projects?foo=bar")
      rule = make_rule(name: "test_rule", characteristics: [:endpoint])

      expect(redis).to receive(:incr)
        .with("labkit:rl:rack_request:auth_rule:user:_unknown_")
        .and_return(1)
      expect(redis).to receive(:incr).with("labkit:rl:rack_request:test_rule:endpoint:/api/v4/projects").and_return(1)
      expect(redis).to receive(:expire)

      evaluator(rules: [rule]).check(id)
      evaluator(rules: [rule], id: id).evaluate
    end
  end

    it "uses _unknown_ sentinel when characteristic value is empty string" do
      id = Labkit::RateLimit::Identifier.new(user: "", ip: "1.2.3.4")
      rule = make_rule(name: "auth_rule", characteristics: [:user])
  describe "reordering rules does not move counters" do
    it "uses the same key regardless of rule position in the array" do
      rule_a = make_rule(name: "first", characteristics: [:user])
      rule_b = make_rule(name: "second", characteristics: [:user])

      expect(redis).to receive(:incr)
        .with("labkit:rl:rack_request:auth_rule:user:_unknown_")
        .and_return(1)
      expect(redis).to receive(:expire)
      expect(redis).to receive(:incr).with("labkit:rl:rack_request:first:user:42").twice.and_return(1, 2)
      expect(redis).to receive(:incr).with("labkit:rl:rack_request:second:user:42").twice.and_return(1, 2)
      allow(redis).to receive(:expire)

      evaluator(rules: [rule]).check(id)
      evaluator(rules: [rule_a, rule_b]).evaluate
      evaluator(rules: [rule_b, rule_a]).evaluate
    end
  end

    it "includes _unknown_ in compound key alongside present characteristics" do
      id = Labkit::RateLimit::Identifier.new(ip: "1.2.3.4")
      rule = make_rule(name: "compound", characteristics: [:user, :ip])
  describe "call_site validation" do
    it "raises ArgumentError for invalid call_site in test env" do
      rule = make_rule
      expect { evaluator(call_site: "bad:site", rules: [rule]).evaluate }
        .to raise_error(ArgumentError, /Invalid call_site/)
    end

      expect(redis).to receive(:incr)
        .with("labkit:rl:rack_request:compound:user:_unknown_:ip:1.2.3.4")
        .and_return(1)
      expect(redis).to receive(:expire)
    it "sanitizes invalid call_site in production and WARNs" do
      stub_env("LABKIT_ENV", "production")
      rule = make_rule(characteristics: [:user])
      allow(redis).to receive(:incr).and_return(1)
      allow(redis).to receive(:expire)

      evaluator(rules: [rule]).check(id)
      expect(logger).to receive(:warn).with(hash_including(message: "rate_limit_invalid_call_site"))
      evaluator(call_site: "bad:site", rules: [rule]).evaluate
    end
  end

  describe "characteristic validation" do
    it "raises ArgumentError for unknown characteristic in test env" do
      rule = make_rule(characteristics: [:unknown_thing])
      expect { evaluator(rules: [rule]).evaluate }
        .to raise_error(ArgumentError, /Unknown characteristic: :unknown_thing/)
    end

  describe "Scenario O: arbitrary characteristics accepted" do
    it "does not raise for an unknown characteristic in test env" do
      rule = make_rule(name: "custom", characteristics: [:custom_field])
      id = Labkit::RateLimit::Identifier.new(custom_field: "abc")
    it "uses sentinel value and WARNs for unknown characteristic in production" do
      stub_env("LABKIT_ENV", "production")
      rule = make_rule(name: "test_rule", characteristics: [:unknown_thing])

      expect(redis).to receive(:incr)
        .with("labkit:rl:rack_request:custom:custom_field:abc")
        .with("labkit:rl:rack_request:test_rule:unknown_thing:unknown_characteristic")
        .and_return(1)
      expect(redis).to receive(:expire)
      expect(logger).to receive(:warn).with(hash_including(message: "rate_limit_unknown_characteristic"))

      expect { evaluator(rules: [rule]).check(id) }.not_to raise_error
      evaluator(rules: [rule]).evaluate
    end
  end

  describe "Scenario S: Redis unavailable" do
    it "returns error Result and logs a warning when Redis is unavailable" do
      allow(redis).to receive(:incr).and_raise(RuntimeError, "connection refused")
  describe "missing characteristic value" do
    it "skips the characteristic and does not write to Redis when value is nil" do
      id = Labkit::RateLimit::Identifier.new(ip: "1.2.3.4")
      rule = make_rule(characteristics: [:user])
      expect(redis).not_to receive(:incr)
      expect(logger).to receive(:info).with(hash_including(skipped: true, characteristic: :user))

      logger = instance_double(Labkit::Logging::JsonLogger)
      expect(logger).to receive(:warn).with(
        hash_including(message: "rate_limit_error", error: "RuntimeError")
      )
      result = evaluator(rules: [rule], id: id).evaluate
      expect(result).to eq(:allow)
    end

    it "skips the characteristic when value is an empty string" do
      id = Labkit::RateLimit::Identifier.new(user: "", ip: "1.2.3.4")
      rule = make_rule(characteristics: [:user])
      expect(redis).not_to receive(:incr)
      expect(logger).to receive(:info).with(hash_including(skipped: true, characteristic: :user))

      evaluator(rules: [rule], id: id).evaluate
    end

    it "still increments other characteristics when one is missing" do
      id = Labkit::RateLimit::Identifier.new(ip: "1.2.3.4")
      rule = make_rule(name: "test_rule", characteristics: [:user, :ip])

      keys_seen = []
      allow(redis).to receive(:incr) do |key|
        keys_seen << key
        1
      end
      allow(redis).to receive(:expire)
      allow(logger).to receive(:info)

      rule = make_rule(name: "err_rule")
      result = described_class.new(name: "rack_request", rules: [rule], redis: redis, logger: logger).check(identifier)
      evaluator(rules: [rule], id: id).evaluate

      expect(result.error?).to be(true)
      expect(result.exceeded?).to be(false)
      expect(result.matched?).to be(false)
      expect(keys_seen).to eq(["labkit:rl:rack_request:test_rule:ip:1.2.3.4"])
    end
  end

  describe "non-matching rules" do
    it "does not write to Redis and returns no-match Result" do
    it "does not write to Redis for non-matching rules" do
      rule = make_rule(match: { user: 999 })
      expect(redis).not_to receive(:incr)

      result = evaluator(rules: [rule]).check(identifier)
      expect(result.matched?).to be(false)
      result = evaluator(rules: [rule]).evaluate
      expect(result).to eq(:allow)
    end
  end

  describe "Redis errors" do
    it "returns :allow on Redis error and WARNs" do
      rule = make_rule
      allow(redis).to receive(:incr).and_raise(RuntimeError, "connection refused")
      expect(logger).to receive(:warn).with(hash_including(message: "rate_limit_redis_error"))

      result = evaluator(rules: [rule]).evaluate
      expect(result).to eq(:allow)
    end
  end

  describe "structured logging" do
    it "logs INFO with rule_name (not rule_index) for a matched rule" do
      rule = make_rule(name: "authenticated_api", characteristics: [:user])
      allow(redis).to receive(:incr).and_return(42)
      allow(redis).to receive(:expire)

      expect(logger).to receive(:info) do |msg|
        expect(msg[:message]).to eq("rate_limit_check")
        expect(msg[:call_site]).to eq("rack_request")
        expect(msg[:rule_name]).to eq("authenticated_api")
        expect(msg).not_to have_key(:rule_index)
        expect(msg[:action]).to eq("block")
        expect(msg[:limit]).to eq(100)
        expect(msg[:period]).to eq(60)
        expect(msg[:count]).to eq(42)
        expect(msg[:matched]).to be(true)
        expect(msg).to have_key(:exceeded)
        expect(msg[:identifier]).to be_a(Hash)
        expect(msg[:redis_key]).to eq("labkit:rl:rack_request:authenticated_api:user:42")
      end

      evaluator(rules: [rule]).evaluate
    end
  end
end
+110 −0

File added.

Preview size limit exceeded, changes collapsed.

+265 −150

File changed.

Preview size limit exceeded, changes collapsed.