From 2fae705c75ca223aae6db8fe588fa313364176de Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:08:06 +0100 Subject: [PATCH 01/11] Request restrictions: lock a key to specific web origins and IP addresses Any key can now carry two allowlists: `allowed_origins` (bare hosts and `*.subdomain` wildcards, matched against the browser's Origin header with a Referer fallback) and `allowed_ips` (IPv4/IPv6, exact addresses or CIDR ranges). Both are enforced inside `Authenticator.call`, alongside environment isolation, so every host application gets the check for free and no endpoint can forget it. The token cache only shortcuts the lookup, so checks always read the current row: tightening a leaked publishable key's origins takes effect on the very next request. Within a list any entry admits the request; across lists every list that has entries must pass. Empty restrictions mean unrestricted, so every existing key behaves exactly as before. Refusals answer 403 with `origin_not_allowed` or `ip_not_allowed`, never echoing the configured allowlist back to the caller, and every failure mode fails closed: a locked list plus an unreadable origin, an unresolvable client IP, or an unparseable stored entry is a refusal. `ApiKeys::Restrictions` owns parsing, normalization, and matching, including the forgiving parsers a dashboard needs, so host applications can delete their own origin parsers. Key types may cap which restriction kinds their keys carry via `restrictions:`, mirroring the `permissions:` scope ceiling, and `config.client_ip_resolver` covers deployments where `remote_ip` is not the truth. Restrictions are deliberately absent from IMMUTABLE_IDENTITY_ATTRIBUTES: they are the one control a non-revocable key's owner still has. New installations get the column from the install generator; existing ones run `rails generate api_keys:add_restrictions`, and writing restrictions without the column raises an error naming that generator. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k --- lib/api_keys.rb | 1 + lib/api_keys/authentication.rb | 8 +- lib/api_keys/configuration.rb | 46 ++- lib/api_keys/engine.rb | 6 + lib/api_keys/errors.rb | 11 + lib/api_keys/models/api_key.rb | 149 ++++++++ lib/api_keys/models/concerns/has_api_keys.rb | 65 +++- lib/api_keys/restrictions.rb | 342 ++++++++++++++++++ lib/api_keys/services/authenticator.rb | 60 ++- .../api_keys/add_restrictions_generator.rb | 57 +++ .../add_restrictions_to_api_keys.rb.erb | 31 ++ .../templates/create_api_keys_table.rb.erb | 4 + .../api_keys/templates/initializer.rb | 37 +- 13 files changed, 800 insertions(+), 17 deletions(-) create mode 100644 lib/api_keys/restrictions.rb create mode 100644 lib/generators/api_keys/add_restrictions_generator.rb create mode 100644 lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb diff --git a/lib/api_keys.rb b/lib/api_keys.rb index 1e55bd5..56ef58c 100644 --- a/lib/api_keys.rb +++ b/lib/api_keys.rb @@ -54,6 +54,7 @@ def reset_configuration! require "api_keys/version" require "api_keys/configuration" # Defines the ApiKeys::Configuration class require "api_keys/errors" # Error classes for key types feature +require "api_keys/restrictions" # Origin/IP request restrictions value object # Files that might depend on ApiKeys.configuration being available require "api_keys/controller" # This can lead to loading jobs, etc. diff --git a/lib/api_keys/authentication.rb b/lib/api_keys/authentication.rb index 1cdd767..0ace51a 100644 --- a/lib/api_keys/authentication.rb +++ b/lib/api_keys/authentication.rb @@ -13,6 +13,11 @@ module Authentication extend ActiveSupport::Concern include ApiKeys::Logging + # Failures where the credential is valid but the request context is refused. + # The key itself is fine, so these answer 403 rather than 401 — the same + # distinction `:missing_scope` already makes. + FORBIDDEN_ERROR_CODES = %i[origin_not_allowed ip_not_allowed].freeze + included do # Helper methods to access the authenticated key and its owner helper_method :current_api_key, :current_api_owner, :current_api_user @@ -88,7 +93,8 @@ def authenticate_api_key!(scope: nil) else # Authentication failed log_debug "[ApiKeys Auth] Authentication failed. Error: #{result.error_code}, Message: #{result.message}" - render_unauthorized(error_code: result.error_code, message: result.message) + status = FORBIDDEN_ERROR_CODES.include?(result.error_code) ? :forbidden : :unauthorized + render_unauthorized(error_code: result.error_code, message: result.message, status: status) end # Enqueue after_authentication callback asynchronously regardless of success/failure diff --git a/lib/api_keys/configuration.rb b/lib/api_keys/configuration.rb index e30eece..fbe5c1c 100644 --- a/lib/api_keys/configuration.rb +++ b/lib/api_keys/configuration.rb @@ -3,6 +3,7 @@ require "active_support/core_ext/numeric/time" require "active_support/core_ext/string/inflections" require "active_support/security_utils" +require_relative "restrictions" module ApiKeys # Defines the configuration options for the ApiKeys gem. @@ -41,6 +42,18 @@ class Configuration # Security attr_reader :https_only_production, :https_strict_mode + # Request Restrictions + # + # @!attribute [rw] client_ip_resolver + # @return [#call] Callable receiving the request and returning the client + # IP address used to evaluate a key's `allowed_ips` list. The default + # honors Rails' trusted-proxy handling via `request.remote_ip`; behind a + # CDN, configure `config.action_dispatch.trusted_proxies` or supply your + # own resolver. + # @example + # config.client_ip_resolver = ->(request) { request.headers["CF-Connecting-IP"].presence || request.remote_ip } + attr_reader :client_ip_resolver + # Tenant Resolution attr_reader :tenant_resolver @@ -84,10 +97,14 @@ class Configuration # - :public [Boolean] If true AND revocable: false, store plaintext token in # metadata so it can be viewed again in dashboard. Use ONLY for publishable # keys that are designed to be embedded in distributed apps. (default: false) + # - :restrictions [Array] Which request-restriction kinds keys of this + # type may carry: any subset of [:origins, :ips]. Omitted means both are + # allowed; `[]` forbids restrictions entirely for this type. # @example # config.key_types = { - # publishable: { prefix: "pk", permissions: %w[read], revocable: false, public: true, limit: 1 }, - # secret: { prefix: "sk", permissions: :all } + # publishable: { prefix: "pk", permissions: %w[read], revocable: false, public: true, limit: 1, + # restrictions: [:origins] }, + # secret: { prefix: "sk", permissions: :all, restrictions: [:ips] } # } # # @!attribute [rw] environments @@ -240,6 +257,12 @@ def tenant_resolver=(value) @tenant_resolver = value end + def client_ip_resolver=(value) + raise ArgumentError, "client_ip_resolver must be callable" unless value.respond_to?(:call) + + @client_ip_resolver = value + end + def secure_compare_proc=(value) raise ArgumentError, "secure_compare_proc must be callable" unless value.respond_to?(:call) @@ -403,6 +426,8 @@ def validate_key_types!(key_types_hash) raise ArgumentError, "Key type '#{name}' limit must be a positive Integer or nil" end + validate_restriction_kinds!(name, type_config[:restrictions]) if type_config.key?(:restrictions) + next unless type_config[:public] == true unless type_config[:revocable] == false @@ -416,6 +441,18 @@ def validate_key_types!(key_types_hash) validate_key_type_prefixes!(key_types_hash) end + # A key type may declare which request-restriction kinds its keys can carry. + # Omitting the setting allows every kind; `[]` forbids all of them. + def validate_restriction_kinds!(name, kinds) + valid = kinds.is_a?(Array) && kinds.all? do |kind| + (kind.is_a?(Symbol) || kind.is_a?(String)) && ApiKeys::Restrictions::KIND_NAMES.include?(kind.to_s) + end + return if valid + + raise ArgumentError, + "Key type '#{name}' restrictions must be an Array containing any of: #{ApiKeys::Restrictions::KIND_NAMES.join(', ')}" + end + def validate_config_name!(value, label) return if valid_config_name?(value) @@ -555,6 +592,11 @@ def set_defaults @https_only_production = true # Warn if used over HTTP in production @https_strict_mode = true # Fail closed if a production request is not HTTPS + # Request Restrictions + # Rails' remote_ip already honors config.action_dispatch.trusted_proxies, + # so the sensible default is simply to trust what Rails resolved. + @client_ip_resolver = ->(request) { request.remote_ip } + # Background Job Queues @stats_job_queue = :default @callbacks_job_queue = :default diff --git a/lib/api_keys/engine.rb b/lib/api_keys/engine.rb index 77cce7c..72c7592 100644 --- a/lib/api_keys/engine.rb +++ b/lib/api_keys/engine.rb @@ -39,6 +39,12 @@ class Engine < ::Rails::Engine ApiKeys::ApiKey.attribute :scopes, json_col_type, default: [] ApiKeys::ApiKey.attribute :metadata, json_col_type, default: {} + # Request restrictions arrived in 0.5.0. Installations that have not + # run `rails generate api_keys:add_restrictions` yet must not gain a + # virtual attribute that silently accepts writes it cannot persist. + if ApiKeys::ApiKey.restrictions_column? + ApiKeys::ApiKey.attribute :restrictions, json_col_type, default: {} + end end end end diff --git a/lib/api_keys/errors.rb b/lib/api_keys/errors.rb index e7b6ea3..6c345c5 100644 --- a/lib/api_keys/errors.rb +++ b/lib/api_keys/errors.rb @@ -69,5 +69,16 @@ def initialize(missing_columns:) ) end end + + # Raised when request restrictions are used but the `restrictions` column is missing + class RestrictionsMigrationRequiredError < BaseError + def initialize(message = nil) + super( + message || + "Request restrictions are configured but the `restrictions` database column is missing. " \ + "Run: rails generate api_keys:add_restrictions && rails db:migrate" + ) + end + end end end diff --git a/lib/api_keys/models/api_key.rb b/lib/api_keys/models/api_key.rb index 9d71ffa..aa542e8 100644 --- a/lib/api_keys/models/api_key.rb +++ b/lib/api_keys/models/api_key.rb @@ -4,6 +4,7 @@ require "json" require_relative "../services/token_generator" require_relative "../services/digestor" +require_relative "../restrictions" module ApiKeys # The core ActiveRecord model representing an API key. @@ -11,6 +12,11 @@ class ApiKey < ActiveRecord::Base MAX_SCOPES = 100 MAX_SCOPE_BYTESIZE = 128 MAX_METADATA_BYTESIZE = 16_384 + MAX_RESTRICTION_ENTRIES = 100 + MAX_RESTRICTION_ENTRY_BYTESIZE = 255 + RESTRICTIONS_COLUMN = "restrictions" + # Deliberately excludes `restrictions`: tightening the origins of a leaked + # publishable key is the one control the owner of a non-revocable key has. IMMUTABLE_IDENTITY_ATTRIBUTES = %w[ token_digest digest_algorithm prefix last4 owner_type owner_id key_type environment ].freeze @@ -45,6 +51,57 @@ def scopes=(value) super(cleaned) end + # == Request Restrictions == + # Where this key may be used from. Reads always answer with a value object, + # so `key.restrictions.origins` works even on a key that has none. + # + # @return [ApiKeys::Restrictions] + def restrictions + return ApiKeys::Restrictions.none unless self.class.restrictions_column? + + ApiKeys::Restrictions.wrap(self[:restrictions]) + end + + # Accepts a Restrictions instance, a hash of lists, or nil. Hashes are + # normalized into the storage shape; anything else is stored untouched so + # the validation, rather than a silent coercion, is what reports it. + def restrictions=(value) + ensure_restrictions_column! + + normalized = if value.nil? || value.is_a?(Hash) || value.is_a?(ApiKeys::Restrictions) + ApiKeys::Restrictions.wrap(value).to_h + else + value + end + super(normalized) + end + + # @return [Array] Allowed web origins (hosts and `*.host` wildcards). + def allowed_origins + restrictions.origins + end + + # @return [Array] Allowed IP addresses and CIDR ranges. + def allowed_ips + restrictions.ips + end + + # Accepts an array or a raw string ("example.com, *.example.com") and + # normalizes it, so host applications never need their own parser. + def allowed_origins=(value) + self.restrictions = restrictions.to_h.merge("origins" => ApiKeys::Restrictions.normalize_origins(value)) + end + + # Accepts an array or a raw string ("203.0.113.7, 10.0.0.0/8"). + def allowed_ips=(value) + self.restrictions = restrictions.to_h.merge("ips" => ApiKeys::Restrictions.normalize_ips(value)) + end + + # @return [Boolean] true when this key carries any request restriction. + def restricted? + restrictions.restricted? + end + # == Validations == validates :token_digest, presence: true, uniqueness: { case_sensitive: true } validates :prefix, presence: true, length: { maximum: 64 } @@ -73,6 +130,8 @@ def scopes=(value) validate :token_digest_matches_algorithm validate :token_identifiers_are_well_formed validate :metadata_is_well_formed + validate :restrictions_are_well_formed + validate :restrictions_respect_key_type, if: -> { key_type.present? } validate :authentication_identity_is_immutable, on: :update # TODO: Add validation for scope string format @@ -103,6 +162,10 @@ def scopes=(value) scope :publishable, -> { where(key_type: "publishable") } scope :secret, -> { where.not(key_type: "publishable") } + # Keys that carry request restrictions, and keys usable from anywhere. + scope :restricted, -> { where.not(restrictions: {}) } + scope :unrestricted, -> { where(restrictions: {}) } + # === Usage Analytics Scopes === # These scopes help admin dashboards analyze API key usage patterns. # Useful for identifying unused keys, high-traffic keys, and stale keys that may need cleanup. @@ -306,8 +369,23 @@ def masked_token # == Class Methods == # Most creation logic is handled by standard ActiveRecord methods + callbacks + # Whether the `restrictions` column exists. Installations that predate + # v0.5.0 keep working untouched until they run the generator. + # @return [Boolean] + def self.restrictions_column? + column_names.include?(RESTRICTIONS_COLUMN) + rescue StandardError + false + end + private + def ensure_restrictions_column! + return if self.class.restrictions_column? + + raise ApiKeys::Errors::RestrictionsMigrationRequiredError + end + # Set defaults for attributes not handled by the `attribute` API in the engine. def set_defaults # NOTE: Defaults for scopes/metadata handled by `attribute` definitions in engine initializer. @@ -467,6 +545,77 @@ def metadata_is_well_formed errors.add(:metadata, "must contain valid JSON data") end + # Restrictions are security policy: a malformed list must fail loudly at + # write time rather than quietly protecting nothing at authentication time. + def restrictions_are_well_formed + return unless self.class.restrictions_column? + + raw = self[:restrictions] + return if raw.nil? + + unless raw.is_a?(Hash) + errors.add(:restrictions, "must be an object") + return + end + + unknown_kinds = raw.keys.map(&:to_s) - ApiKeys::Restrictions::KIND_NAMES + if unknown_kinds.any? + errors.add(:restrictions, "contains unknown restriction kinds: #{unknown_kinds.sort.join(', ')}") + end + + validate_restriction_list(:origins) { |entry| ApiKeys::Restrictions.valid_origin_entry?(entry) } + validate_restriction_list(:ips) { |entry| ApiKeys::Restrictions.valid_ip_entry?(entry) } + end + + def validate_restriction_list(kind) + entries = restrictions.public_send(kind) + + if entries.length > MAX_RESTRICTION_ENTRIES + errors.add(:restrictions, "#{kind} cannot contain more than #{MAX_RESTRICTION_ENTRIES} entries") + end + + if entries.any? { |entry| !valid_restriction_entry_size?(entry) } + errors.add(:restrictions, "#{kind} entries cannot exceed #{MAX_RESTRICTION_ENTRY_BYTESIZE} bytes") + end + + return if entries.all? { |entry| yield(entry) } + + message = if kind == :origins + "origins must be bare hosts, optionally prefixed with a `*.` subdomain wildcard" + else + "ips must be valid IPv4/IPv6 addresses or CIDR ranges" + end + errors.add(:restrictions, message) + end + + # Non-string entries are reported by the shape check below, not here. + def valid_restriction_entry_size?(entry) + return true unless entry.is_a?(String) + return false unless entry.valid_encoding? + + entry.bytesize <= MAX_RESTRICTION_ENTRY_BYTESIZE + rescue ArgumentError + false + end + + # Key types may declare a ceiling on the restriction kinds their keys carry, + # mirroring the way `permissions:` caps scopes. + def restrictions_respect_key_type + exceeded = restrictions.kinds - restriction_ceiling + return if exceeded.empty? + + errors.add(:restrictions, "#{exceeded.sort.join(', ')} are not allowed for #{key_type} keys") + end + + # @return [Array] Restriction kinds this key's type permits. + # An omitted `restrictions:` setting permits every kind. + def restriction_ceiling + config = key_type_config + return ApiKeys::Restrictions::KINDS.dup unless config&.key?(:restrictions) + + Array(config[:restrictions]).map(&:to_sym) + end + def authentication_identity_is_immutable IMMUTABLE_IDENTITY_ATTRIBUTES.each do |attribute_name| next unless will_save_change_to_attribute?(attribute_name) diff --git a/lib/api_keys/models/concerns/has_api_keys.rb b/lib/api_keys/models/concerns/has_api_keys.rb index 9b50240..af0262f 100644 --- a/lib/api_keys/models/concerns/has_api_keys.rb +++ b/lib/api_keys/models/concerns/has_api_keys.rb @@ -219,10 +219,17 @@ def can_create_api_key?(key_type: nil, environment: nil) # Must be defined in ApiKeys.configuration.key_types if provided. # @param environment [Symbol, nil] The environment (e.g., :test, :live). # Defaults to current_environment if key_types feature is enabled. + # @param restrictions [Hash, ApiKeys::Restrictions, nil] Request restrictions in + # storage shape, e.g. { origins: ["example.com"], ips: ["10.0.0.0/8"] }. + # @param allowed_origins [String, Array, nil] Convenience form of the origins list. + # Accepts the raw string a form field submits ("example.com, *.example.com"). + # @param allowed_ips [String, Array, nil] Convenience form of the IP list + # ("203.0.113.7, 10.0.0.0/8"). # @return [ApiKeys::ApiKey] The newly created ApiKey instance. The plaintext token # is available via the `#token` attribute on this instance # *only until it's reloaded*. - def create_api_key!(name: nil, scopes: nil, expires_at: nil, expires_at_preset: nil, metadata: nil, key_type: nil, environment: nil) + def create_api_key!(name: nil, scopes: nil, expires_at: nil, expires_at_preset: nil, metadata: nil, + key_type: nil, environment: nil, restrictions: nil, allowed_origins: nil, allowed_ips: nil) config = ApiKeys.configuration # Parse expires_at_preset if provided (takes precedence over expires_at) @@ -240,6 +247,13 @@ def create_api_key!(name: nil, scopes: nil, expires_at: nil, expires_at_preset: check_required_columns! end + # Requesting restrictions (directly, or through a key type that + # declares a ceiling) requires the column that stores them. + requested_restrictions = build_restrictions(restrictions, allowed_origins, allowed_ips) + if requested_restrictions || restriction_ceilings_configured?(config) + check_restrictions_column! + end + # Use default_key_type if not specified and key_types feature is enabled resolved_key_type = key_type if resolved_key_type.nil? && key_types_feature_enabled?(config) && config.default_key_type.present? @@ -278,16 +292,19 @@ def create_api_key!(name: nil, scopes: nil, expires_at: nil, expires_at_preset: # ApiKey's creation callback locks the owner row before quota validation. # Keep an explicit transaction here so the helper's creation workflow is # a single atomic unit; direct ApiKey.create! calls are protected too. + attributes = { + name: name, + scopes: key_scopes, + expires_at: expires_at, + metadata: metadata || {}, # Ensure metadata is at least an empty hash + key_type: resolved_key_type&.to_s, + environment: resolved_environment&.to_s + # prefix, token_digest, digest_algorithm are set by ApiKey callbacks + } + attributes[:restrictions] = requested_restrictions if requested_restrictions + api_key = self.class.transaction do - self.api_keys.create!( - name: name, - scopes: key_scopes, - expires_at: expires_at, - metadata: metadata || {}, # Ensure metadata is at least an empty hash - key_type: resolved_key_type&.to_s, - environment: resolved_environment&.to_s - # prefix, token_digest, digest_algorithm are set by ApiKey callbacks - ) + self.api_keys.create!(**attributes) end # Return the ApiKey instance itself. @@ -357,6 +374,26 @@ def filter_scopes_by_permissions(scopes, key_type, config) scopes.select { |scope| permissions.include?(scope.to_s) } end + # Merges the three ways a caller can express restrictions into one + # storage hash. Returns nil when the caller asked for none of them, so + # the column keeps its default and legacy installs stay untouched. + # + # @return [Hash, nil] + def build_restrictions(restrictions, allowed_origins, allowed_ips) + return nil if restrictions.nil? && allowed_origins.nil? && allowed_ips.nil? + + attributes = ApiKeys::Restrictions.wrap(restrictions).to_h + attributes["origins"] = ApiKeys::Restrictions.normalize_origins(allowed_origins) unless allowed_origins.nil? + attributes["ips"] = ApiKeys::Restrictions.normalize_ips(allowed_ips) unless allowed_ips.nil? + ApiKeys::Restrictions.wrap(attributes).to_h + end + + # True when any configured key type declares a restriction ceiling. + def restriction_ceilings_configured?(config) + config.key_types.present? && + config.key_types.any? { |_type, settings| settings.is_a?(Hash) && settings.key?(:restrictions) } + end + # Check that required columns exist for key_types feature # Raises MigrationRequiredError if columns are missing def check_required_columns! @@ -370,6 +407,14 @@ def check_required_columns! end end + # Check that the restrictions column exists before writing to it. + # Raises RestrictionsMigrationRequiredError naming the generator. + def check_restrictions_column! + return if ApiKeys::ApiKey.restrictions_column? + + raise ApiKeys::Errors::RestrictionsMigrationRequiredError + end + # Example: Check if the owner has reached their API key limit. # def reached_api_key_limit? # limit = self.class.api_keys_settings[:max_keys] diff --git a/lib/api_keys/restrictions.rb b/lib/api_keys/restrictions.rb new file mode 100644 index 0000000..0f1004d --- /dev/null +++ b/lib/api_keys/restrictions.rb @@ -0,0 +1,342 @@ +# frozen_string_literal: true + +require "ipaddr" +require "uri" +require_relative "logging" + +module ApiKeys + # Value object describing *where* an API key may be used from: a list of web + # origins (hosts, with optional `*.` subdomain wildcards) and a list of IP + # addresses or CIDR ranges. + # + # Restrictions are plain data stored in the `restrictions` JSON column: + # + # { "origins" => ["example.com", "*.example.com"], + # "ips" => ["203.0.113.7", "10.0.0.0/8", "2001:db8::/32"] } + # + # Matching semantics (normative): + # + # - Within a list: OR. Any entry that matches admits the request. + # - Across lists: AND. Every list that is present and non-empty must pass. + # - Empty (or absent) restrictions mean unrestricted. Presence is the toggle. + # - Every failure mode fails closed: a locked list plus an unreadable request + # context refuses the request. + # + # The object is immutable, has no Active Record dependency, and never raises + # on malformed input: `wrap` coerces whatever it is given, and the model's + # validations are what reject nonsense before it reaches the database. + class Restrictions + include ApiKeys::Logging + + # The restriction kinds this gem understands. Anything else stored in the + # column is a validation error rather than a silently ignored key. + KINDS = %i[origins ips].freeze + KIND_NAMES = KINDS.map(&:to_s).freeze + + # Entries are split on commas, whitespace, and newlines so that a single + # text field can hold a whole list ("example.com, *.example.com"). + ENTRY_SEPARATOR = /[\s,;]+/ + + # A bare host, optionally prefixed with a `*.` subdomain wildcard. + # `*` alone is deliberately invalid: an empty list already means "anywhere". + ORIGIN_ENTRY_PATTERN = /\A(?:\*\.)?[a-z0-9_-]+(?:\.[a-z0-9_-]+)*\z/ + + attr_reader :origins, :ips + + class << self + # Coerces anything into a Restrictions instance. Never raises. + # + # @param value [Restrictions, Hash, nil, Object] The stored column value, + # a hash of lists, or an existing instance. + # @return [ApiKeys::Restrictions] + def wrap(value) + return value if value.is_a?(self) + return none if value.nil? + return new(origins: [], ips: [], extras: {}) unless value.is_a?(Hash) + + known, extras = value.partition { |key, _entries| KIND_NAMES.include?(key.to_s) } + known = known.to_h { |key, entries| [key.to_s, entries] } + + new( + origins: coerce_list(known["origins"]), + ips: coerce_list(known["ips"]), + extras: extras.to_h + ) + end + + # The shared empty instance: no origins, no IPs, no restrictions at all. + # @return [ApiKeys::Restrictions] + def none + @none ||= new(origins: [], ips: [], extras: {}).freeze + end + + # Forgiving parser for the raw string a dashboard text field submits. + # Accepts full URLs, bare hosts, commas, newlines, and stray whitespace; + # returns bare lowercase hosts, de-duplicated, order preserved. + # + # normalize_origins("https://Shop.example/, *.app.example\n x") + # # => ["shop.example", "*.app.example", "x"] + # + # @param value [String, Array, nil] Raw user input. + # @return [Array] Normalized origin entries. + def normalize_origins(value) + tokenize(value).filter_map { |token| origin_host(token) }.uniq + end + + # Forgiving parser for IP/CIDR input. Entries that stdlib IPAddr cannot + # parse at all are dropped; everything else is kept verbatim (lowercased) + # so validation, not the parser, is what reports a malformed range. + # + # @param value [String, Array, nil] Raw user input. + # @return [Array] Normalized IP entries. + def normalize_ips(value) + tokenize(value).map(&:downcase).uniq + end + + # Extracts the host the browser claims the request came from: the Origin + # header when present, the Referer header otherwise. Returns nil when + # neither is present or parseable, which callers must treat as a refusal. + # + # @param request [ActionDispatch::Request, #headers, nil] + # @return [String, nil] Bare lowercase host. + def extract_origin_host(request) + headers = request.headers if request.respond_to?(:headers) + return nil unless headers.respond_to?(:[]) + + %w[Origin Referer].each do |header_name| + host = host_from_url(headers[header_name]) + return host if host + end + + nil + rescue StandardError + # A hostile or exotic request object must never take an endpoint down; + # an unreadable origin is simply an origin that matches nothing. + nil + end + + # Splits raw input into candidate entries without interpreting them. + # @api private + def tokenize(value) + entries = case value + when nil then [] + when String then value.split(ENTRY_SEPARATOR) + when Array then value.flat_map { |entry| entry.is_a?(String) ? entry.split(ENTRY_SEPARATOR) : [entry] } + else [value] + end + + entries.filter_map do |entry| + next unless entry.is_a?(String) + + trimmed = entry.strip + trimmed unless trimmed.empty? + end + end + + # Reduces a single user-supplied entry to a bare lowercase host. + # Full URLs give up their host; bare hosts keep everything before the + # first slash, colon, or question mark. Returns nil when nothing is left. + # @api private + def origin_host(entry) + candidate = entry.to_s.strip + return nil if candidate.empty? + + if candidate.include?("//") + host = host_from_url(candidate) + return host + end + + host = candidate.split(%r{[/?#]}).first.to_s + host = host.sub(/:\d*\z/, "") # Strip a trailing port ("example.com:3000"). + host = host.delete_prefix("[").delete_suffix("]") # IPv6 literals. + host = host.downcase + host.empty? ? nil : host + end + + # Pulls the host out of a full URL, tolerating garbage. + # @api private + def host_from_url(value) + return nil unless value.is_a?(String) + + trimmed = value.strip + return nil if trimmed.empty? + + host = URI.parse(trimmed).host + return nil if host.nil? || host.empty? + + host.delete_prefix("[").delete_suffix("]").downcase + rescue URI::Error, ArgumentError + nil + end + + # Coerces one stored list into an array of entries, preserving anything + # that is not a string so validations can report it instead of the value + # disappearing silently. + # @api private + def coerce_list(value) + entries = case value + when nil then [] + when String then value.split(ENTRY_SEPARATOR) + when Array then value + else [value] + end + + entries.filter_map do |entry| + next entry unless entry.is_a?(String) + + trimmed = entry.strip.downcase + trimmed unless trimmed.empty? + end + end + + # Whether a stored origin entry is shaped like a host or `*.host`. + # @api private + def valid_origin_entry?(entry) + entry.is_a?(String) && entry.match?(ORIGIN_ENTRY_PATTERN) + end + + # Whether a stored IP entry is a single address or a CIDR range. + # @api private + def valid_ip_entry?(entry) + parse_ip(entry) ? true : false + end + + # Parses an address or range with stdlib IPAddr. A bare address is a /32 + # (or /128), so `IPAddr#include?` answers exact matches and range matches + # through a single code path. + # @api private + def parse_ip(value) + return nil unless value.is_a?(String) + + trimmed = value.strip + return nil if trimmed.empty? + + address = IPAddr.new(trimmed) + address.ipv6? && address.ipv4_mapped? ? address.native : address + rescue IPAddr::Error, ArgumentError + nil + end + end + + # @param origins [Array] Already-coerced origin entries. + # @param ips [Array] Already-coerced IP entries. + # @param extras [Hash] Unrecognized keys, preserved so validation sees them. + def initialize(origins: [], ips: [], extras: {}) + @origins = origins.freeze + @ips = ips.freeze + @extras = extras.freeze + freeze + end + + # Unrecognized keys found in the stored hash. Their presence is a + # validation error; they are kept so the error can name them. + # @return [Hash] + attr_reader :extras + + # @return [Boolean] true when this key may be used from anywhere. + def unrestricted? + origins.empty? && ips.empty? + end + + # @return [Boolean] true when at least one list is locked. + def restricted? + !unrestricted? + end + + # @return [Array] The restriction kinds actually in use. + def kinds + KINDS.select { |kind| public_send(kind).any? } + end + + # The storage shape: known lists that have entries, plus any unrecognized + # keys exactly as they were found. + # @return [Hash] + def to_h + hash = {} + hash["origins"] = origins.dup if origins.any? + hash["ips"] = ips.dup if ips.any? + hash.merge(extras) + end + + alias as_json to_h + + # Does this request context satisfy every locked list? + # + # @param origin_host [String, nil] Host from Origin/Referer. + # @param ip [String, nil] Client IP address. + # @return [Boolean] + def allows?(origin_host: nil, ip: nil) + origin_allowed?(origin_host) && ip_allowed?(ip) + end + + # @param host [String, nil] Bare host to check. + # @return [Boolean] true when the origins list is empty or one entry matches. + # A locked list plus a nil/blank host refuses: fail closed. + def origin_allowed?(host) + return true if origins.empty? + + candidate = host.to_s.strip.downcase + return false if candidate.empty? + + origins.any? { |entry| origin_entry_matches?(entry, candidate) } + end + + # @param ip [String, nil] Client IP address. + # @return [Boolean] true when the IP list is empty or one entry contains it. + # A locked list plus an unparseable address refuses: fail closed. + def ip_allowed?(ip) + return true if ips.empty? + + address = self.class.parse_ip(ip.is_a?(String) ? ip : ip.to_s) + return false unless address + + ips.any? { |entry| ip_entry_matches?(entry, address) } + end + + def ==(other) + other.is_a?(self.class) && other.to_h == to_h + end + alias eql? == + + def hash + to_h.hash + end + + def inspect + "#<#{self.class.name} origins=#{origins.inspect} ips=#{ips.inspect}>" + end + + private + + # ApiKeys::Logging memoizes its logger in an instance variable, and this + # value object is frozen. Resolve the logger fresh instead. + def logger + defined?(Rails) ? Rails.logger : nil + end + + # `*.example.com` matches any subdomain at any depth, but never the apex — + # Google's rule. List the apex separately when you want both. + def origin_entry_matches?(entry, host) + return false unless entry.is_a?(String) + + if entry.start_with?("*.") + suffix = entry.delete_prefix("*") + host.end_with?(suffix) && host.length > suffix.length + else + entry == host + end + end + + # A stored entry that no longer parses matches nothing and says so once. + # Validation keeps these out; this covers rows written around validations. + def ip_entry_matches?(entry, address) + range = self.class.parse_ip(entry) + unless range + log_warn "[ApiKeys Security] Ignored an unparseable stored IP restriction entry." + return false + end + + range.include?(address) + end + end +end diff --git a/lib/api_keys/services/authenticator.rb b/lib/api_keys/services/authenticator.rb index 1468b8b..b513cd9 100644 --- a/lib/api_keys/services/authenticator.rb +++ b/lib/api_keys/services/authenticator.rb @@ -5,6 +5,7 @@ require "digest" require_relative "../models/api_key" require_relative "../services/digestor" +require_relative "../restrictions" require_relative "../logging" module ApiKeys @@ -82,10 +83,15 @@ def self.call(request) elsif api_key&.active? log_debug "[ApiKeys Auth] Verification successful. Key ID: #{api_key.id}" - # Check environment isolation if enabled + # Check environment isolation, then the key's request restrictions. + # Both run on every request, including token-cache hits: the + # cache only shortcuts the lookup, and the row is reloaded fresh. env_check_result = check_environment_isolation(api_key, config) + restriction_result = env_check_result ? nil : check_request_restrictions(api_key, request, config) if env_check_result env_check_result # Return failure result + elsif restriction_result + restriction_result # Return failure result else # TODO: Optionally update last_used_at and requests_count Result.success(api_key) @@ -427,6 +433,55 @@ def self.check_environment_isolation(api_key, config) nil # Check passed end + # Enforces the key's request restrictions: which web origins and which IP + # addresses may present it. Empty lists mean unrestricted, so every key + # written before this feature existed passes untouched. + # + # Every list that is present must pass (AND across kinds); within a list + # any entry admits the request (OR within a kind). A locked list plus an + # unreadable request context refuses: these checks fail closed. + # + # @return [ApiKeys::Services::Authenticator::Result, nil] Failure, or nil when the check passes. + def self.check_request_restrictions(api_key, request, config) + restrictions = api_key.restrictions + return nil if restrictions.unrestricted? + + if restrictions.origins.any? + origin_host = ApiKeys::Restrictions.extract_origin_host(request) + unless restrictions.origin_allowed?(origin_host) + log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because the request origin is not allowed." + return Result.failure( + error_code: :origin_not_allowed, + message: "This API key is restricted to specific web origins, and this request's origin is not allowed" + ) + end + end + + if restrictions.ips.any? + unless restrictions.ip_allowed?(resolve_client_ip(request, config)) + log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because the request IP address is not allowed." + return Result.failure( + error_code: :ip_not_allowed, + message: "This API key is restricted to specific IP addresses, and this request's address is not allowed" + ) + end + end + + nil # Check passed + end + + # Resolves the client IP through the configured resolver. A resolver that + # blows up yields nil, which an IP-locked key treats as a refusal. + def self.resolve_client_ip(request, config) + resolver = config.client_ip_resolver + return nil unless resolver.respond_to?(:call) + + resolver.call(request) + rescue StandardError => error + log_warn "[ApiKeys Security] Client IP resolution failed (#{error.class}); treating the address as unknown." + nil + end + private_class_method :extract_token, :find_and_verify_key, :find_sha256_key, :find_bcrypt_key, :find_bcrypt_key_for_prefixes, :find_bcrypt_key_by_last4, :find_verified_bcrypt_candidate, @@ -437,7 +492,8 @@ def self.check_environment_isolation(api_key, config) :safe_find_by_id, :sanitize_prefixes, :valid_token?, :production_environment?, :secure_request?, :check_key_type_configuration, :check_environment_configuration, - :check_environment_isolation + :check_environment_isolation, :check_request_restrictions, + :resolve_client_ip end end end diff --git a/lib/generators/api_keys/add_restrictions_generator.rb b/lib/generators/api_keys/add_restrictions_generator.rb new file mode 100644 index 0000000..8b49b9e --- /dev/null +++ b/lib/generators/api_keys/add_restrictions_generator.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails/generators/base" +require "rails/generators/active_record" + +module ApiKeys + module Generators + # Rails generator for adding the `restrictions` column to the api_keys table. + # This generator is for existing installations that want to lock keys to + # specific web origins or IP addresses. New installs get the column from the + # install generator, so they never need to run this. + class AddRestrictionsGenerator < Rails::Generators::Base + include ActiveRecord::Generators::Migration + + source_root File.expand_path("templates", __dir__) + + # Implement the required interface for Rails::Generators::Migration. + def self.next_migration_number(dirname) + next_migration_number = current_migration_number(dirname) + 1 + ActiveRecord::Migration.next_migration_number(next_migration_number) + end + + # Creates the migration file using the template. + def create_migration_file + migration_template "add_restrictions_to_api_keys.rb.erb", + File.join(db_migrate_path, "add_restrictions_to_api_keys.rb") + end + + # Displays helpful information to the user after installation. + def display_post_install_message + say "\n🌐 Request restrictions migration created!", :green + say "\nNext steps:" + say " 1. Run `rails db:migrate` to add the restrictions column." + say "\n 2. Lock a key to the places it may be used from:" + say " user.create_api_key!(name: 'Widget key', allowed_origins: 'example.com, *.example.com')" + say " key.allowed_ips = '203.0.113.7, 10.0.0.0/8'" + say "\n Keys without restrictions keep working from anywhere; presence is the toggle." + say "\n 3. Optionally cap which restriction kinds each key type may carry:" + say " config.key_types = {" + say " publishable: { prefix: 'pk', permissions: %w[read], revocable: false," + say " public: true, restrictions: [:origins] }," + say " secret: { prefix: 'sk', permissions: :all, restrictions: [:ips] }" + say " }" + say "\n 4. Behind a CDN or proxy, make sure the client IP is truthful:" + say " config.action_dispatch.trusted_proxies = ..." + say " # or: config.client_ip_resolver = ->(request) { request.headers['CF-Connecting-IP'] }" + say "\nSee the api_keys README for detailed usage and examples.", :cyan + end + + private + + def migration_version + "[#{ActiveRecord::VERSION::STRING.to_f}]" + end + end + end +end diff --git a/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb b/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb new file mode 100644 index 0000000..0209e8d --- /dev/null +++ b/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Migration to add the `restrictions` column to the api_keys table. +# This enables request restrictions: locking a key to specific web origins +# (with `*.` subdomain wildcards) and/or to specific IP addresses and CIDR +# ranges. +# +# Run this migration if you're upgrading from a version of api_keys that +# didn't have request restrictions support. The column defaults to an empty +# object, which means "unrestricted", so existing keys are unaffected. +class AddRestrictionsToApiKeys < ActiveRecord::Migration<%= migration_version %> + def change + add_column :api_keys, :restrictions, json_column_type, default: {}, null: false + end + + private + + # Helper method to determine the appropriate JSON column type based on the database adapter. + # Uses :jsonb for PostgreSQL for better performance and indexing, :json otherwise. + def json_column_type + # Check connection availability for adapter name inspection + if ActiveRecord::Base.connection.adapter_name.downcase.include?('postgresql') + :jsonb + else + :json + end + rescue ActiveRecord::ConnectionNotEstablished + # Fallback during initial setup or if connection isn't available + :text + end +end diff --git a/lib/generators/api_keys/templates/create_api_keys_table.rb.erb b/lib/generators/api_keys/templates/create_api_keys_table.rb.erb index ebd924b..06defd1 100644 --- a/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +++ b/lib/generators/api_keys/templates/create_api_keys_table.rb.erb @@ -30,6 +30,10 @@ class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %> # Optional freeform metadata for tagging t.send(json_column_type, :metadata, default: {}, null: false) + # Optional request restrictions: which web origins and IP addresses may + # present this key. An empty object means the key is unrestricted. + t.send(json_column_type, :restrictions, default: {}, null: false) + # Optional auto-expiration timestamp t.datetime :expires_at diff --git a/lib/generators/api_keys/templates/initializer.rb b/lib/generators/api_keys/templates/initializer.rb index 28960f1..46a7c37 100644 --- a/lib/generators/api_keys/templates/initializer.rb +++ b/lib/generators/api_keys/templates/initializer.rb @@ -144,6 +144,10 @@ # keys designed to be embedded in distributed apps. Public types must # use a finite, non-empty permissions array (never :all). (default: false) # SECURITY: NEVER set public: true on secret keys! + # - restrictions: Which request-restriction kinds keys of this type may carry: + # any subset of [:origins, :ips]. Omitted = both allowed. + # `restrictions: []` forbids restrictions for this type. + # See "REQUEST RESTRICTIONS" below. # # config.key_types = { # publishable: { @@ -151,11 +155,13 @@ # permissions: %w[read validate], # Can ONLY have these scopes # revocable: false, # Cannot be revoked - protects deployed apps! # public: true, # Store token for later viewing in dashboard - # limit: 1 # Only 1 publishable key per environment + # limit: 1, # Only 1 publishable key per environment + # restrictions: [:origins] # Browser keys lock to domains, not IPs # }, # secret: { # prefix: "sk", # → sk_test_, sk_live_ - # permissions: :all # No scope restrictions + # permissions: :all, # No scope restrictions + # restrictions: [:ips] # Server keys lock to addresses, not domains # # revocable: true (default) # # public: false (default) - NEVER store secret keys! # # limit: nil (default = unlimited) @@ -275,6 +281,33 @@ # Default: true # config.https_strict_mode = true + # ============================================================================ + # REQUEST RESTRICTIONS (origin and IP allowlists) + # ============================================================================ + # + # Any key can be locked to the places it may be used from: + # + # user.create_api_key!(name: "Widget key", allowed_origins: "example.com, *.example.com") + # key.allowed_ips = "203.0.113.7, 10.0.0.0/8" + # + # Origins are matched against the browser's Origin header (falling back to + # Referer); IPs are matched with CIDR support. Within a list any entry + # admits the request; every list that is set must pass. Keys with no + # restrictions work from anywhere, so nothing changes until you opt in. + # Failures answer 403 with `origin_not_allowed` / `ip_not_allowed`. + # + # Requires the restrictions column: + # rails generate api_keys:add_restrictions && rails db:migrate + # ============================================================================ + + # How the client IP is resolved for `allowed_ips` checks. + # The default trusts Rails' own resolution, which honors + # config.action_dispatch.trusted_proxies. Behind a CDN that terminates the + # connection, either configure trusted_proxies or resolve the header yourself. + # Default: ->(request) { request.remote_ip } + # + # config.client_ip_resolver = ->(request) { request.headers["CF-Connecting-IP"].presence || request.remote_ip } + # ============================================================================ # BACKGROUND JOBS & CALLBACKS # ============================================================================ From f6eba42da262358ee473c216fcd0520e3d0e3c8a Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:08:13 +0100 Subject: [PATCH 02/11] Dashboard: edit a key's allowed origins and IP addresses The key form now renders an "Allowed web origins" and an "Allowed IP addresses" text field, each shown only when the key's type permits that restriction kind, and each accepting the raw comma or newline separated string a user actually types. The model normalizes it, so the dashboard carries no parser of its own and invalid entries come back as ordinary validation errors. Keys carrying either list show a "Restricted" badge whose title names the kinds in play. Restriction edits stay permitted on non-revocable keys on purpose: tightening the allowlist is the only control the owner of an unrevocable public key has left. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k --- app/controllers/api_keys/keys_controller.rb | 37 ++++++++++++++++--- app/views/api_keys/keys/_form.html.erb | 4 ++ app/views/api_keys/keys/_key_badges.html.erb | 7 ++++ .../keys/_restriction_fields.html.erb | 32 ++++++++++++++++ .../layouts/api_keys/application.html.erb | 10 ++++- 5 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 app/views/api_keys/keys/_restriction_fields.html.erb diff --git a/app/controllers/api_keys/keys_controller.rb b/app/controllers/api_keys/keys_controller.rb index b57628d..526218b 100644 --- a/app/controllers/api_keys/keys_controller.rb +++ b/app/controllers/api_keys/keys_controller.rb @@ -4,7 +4,7 @@ module ApiKeys # Controller for managing API keys belonging to the current owner. class KeysController < ApplicationController before_action :set_api_key, only: [:show, :edit, :update, :revoke] - helper_method :key_types_feature_enabled? + helper_method :key_types_feature_enabled?, :api_keys_allowed_restriction_kinds # GET /keys def index @@ -62,7 +62,10 @@ def create name: submitted_params[:name], scopes: submitted_params[:scopes], expires_at: parse_expiration(submitted_params[:expires_at_preset]), - key_type: submitted_params[:key_type].presence + key_type: submitted_params[:key_type].presence, + # The model normalizes these raw strings; no parser needed here. + allowed_origins: submitted_params[:allowed_origins], + allowed_ips: submitted_params[:allowed_ips] # Metadata could be added here if needed ) @@ -133,18 +136,23 @@ def api_key_params submitted = params.require(:api_key) raise ActionController::ParameterMissing, :api_key unless submitted.respond_to?(:permit) - permitted_params = submitted.permit(:name, :expires_at_preset, :key_type, scopes: []) + permitted_params = submitted.permit(:name, :expires_at_preset, :key_type, + :allowed_origins, :allowed_ips, scopes: []) permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings permitted_params end - # Only allow updating name and scopes. + # Only allow updating name, scopes, and request restrictions. + # Restriction edits stay available on non-revocable keys on purpose: they + # are the one control the owner of an unrevocable public key still has. def api_key_update_params submitted = params.require(:api_key) raise ActionController::ParameterMissing, :api_key unless submitted.respond_to?(:permit) - permitted_params = submitted.permit(:name, scopes: []) + permitted_params = submitted.permit(:name, :allowed_origins, :allowed_ips, scopes: []) permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings + permitted_params.delete(:allowed_origins) unless ApiKeys::ApiKey.restrictions_column? + permitted_params.delete(:allowed_ips) unless ApiKeys::ApiKey.restrictions_column? permitted_params end @@ -169,6 +177,25 @@ def rebuild_api_key_for_form(submitted_params) ) end + # Which restriction kinds the form may offer for a given key. + # A typed key answers with its own ceiling; an unsaved key that has not + # picked a type yet offers everything any configured type allows. + # + # @param api_key [ApiKeys::ApiKey] + # @return [Array] + def api_keys_allowed_restriction_kinds(api_key) + return restriction_kinds_for(api_key.key_type_config) if api_key.key_type.present? + return ApiKeys::Restrictions::KINDS.dup unless key_types_feature_enabled? + + ApiKeys.configuration.key_types.flat_map { |_type, settings| restriction_kinds_for(settings) }.uniq + end + + def restriction_kinds_for(type_config) + return ApiKeys::Restrictions::KINDS.dup unless type_config.is_a?(Hash) && type_config.key?(:restrictions) + + Array(type_config[:restrictions]).map(&:to_sym) + end + # Check if key types feature is enabled def key_types_feature_enabled? ApiKeys.configuration.key_types.present? && ApiKeys.configuration.key_types.any? diff --git a/app/views/api_keys/keys/_form.html.erb b/app/views/api_keys/keys/_form.html.erb index 0a33a15..4161e55 100644 --- a/app/views/api_keys/keys/_form.html.erb +++ b/app/views/api_keys/keys/_form.html.erb @@ -87,6 +87,8 @@ <% end %> + <%= render "api_keys/keys/restriction_fields", form: form, api_key: api_key %> + <% end %> <%# Fields editable on EDIT %> @@ -119,6 +121,8 @@ <% end %> <% end %> + + <%= render "api_keys/keys/restriction_fields", form: form, api_key: api_key %> <% end %>
diff --git a/app/views/api_keys/keys/_key_badges.html.erb b/app/views/api_keys/keys/_key_badges.html.erb index 151befe..aa9b93d 100644 --- a/app/views/api_keys/keys/_key_badges.html.erb +++ b/app/views/api_keys/keys/_key_badges.html.erb @@ -9,6 +9,13 @@ <% end %> +<% if key.restricted? %> + + Restricted + +<% end %> + <% if key.environment.present? %> <% is_live = key.environment == 'live' %> diff --git a/app/views/api_keys/keys/_restriction_fields.html.erb b/app/views/api_keys/keys/_restriction_fields.html.erb new file mode 100644 index 0000000..adcaf9b --- /dev/null +++ b/app/views/api_keys/keys/_restriction_fields.html.erb @@ -0,0 +1,32 @@ +<%# Optional request restriction fields: where a key may be used from. %> +<%# Locals: form (required), api_key (required) %> +<%# Only rendered when the restrictions column exists and the key's type allows the kind. %> + +<% if ApiKeys::ApiKey.restrictions_column? %> + <% allowed_kinds = api_keys_allowed_restriction_kinds(api_key) %> + + <% if allowed_kinds.include?(:origins) %> +
+ <%= form.label :allowed_origins, "Allowed web origins (optional)" %> + <%= form.text_field :allowed_origins, + value: api_key.allowed_origins.join(", "), + placeholder: "example.com, *.example.com" %> + + Leave empty to allow any origin. Requests from a browser must come from one of these hosts. + Use *.example.com to allow every subdomain. + +
+ <% end %> + + <% if allowed_kinds.include?(:ips) %> +
+ <%= form.label :allowed_ips, "Allowed IP addresses (optional)" %> + <%= form.text_field :allowed_ips, + value: api_key.allowed_ips.join(", "), + placeholder: "203.0.113.7, 10.0.0.0/8" %> + + Leave empty to allow any address. Accepts single IPv4/IPv6 addresses and CIDR ranges. + +
+ <% end %> +<% end %> diff --git a/app/views/layouts/api_keys/application.html.erb b/app/views/layouts/api_keys/application.html.erb index 2a9d61c..f0d42b7 100644 --- a/app/views/layouts/api_keys/application.html.erb +++ b/app/views/layouts/api_keys/application.html.erb @@ -36,6 +36,8 @@ --api-keys-badge-live-color: #155724; --api-keys-badge-test-bg: #f8d7da; --api-keys-badge-test-color: #721c24; + --api-keys-badge-restricted-bg: #e2e3e5; + --api-keys-badge-restricted-color: #383d41; /* Status colors */ --api-keys-status-active-color: green; @@ -152,6 +154,8 @@ --api-keys-badge-live-color: #9ae6b4; --api-keys-badge-test-bg: #742a2a; --api-keys-badge-test-color: #feb2b2; + --api-keys-badge-restricted-bg: #2d3748; + --api-keys-badge-restricted-color: #cbd5e0; } body { @@ -212,7 +216,7 @@ .api-keys-status-active { color: var(--api-keys-status-active-color); } .api-keys-status-revoked { color: var(--api-keys-status-revoked-color); } .api-keys-status-expired { color: var(--api-keys-status-expired-color); } - .api-keys-badge-type, .api-keys-badge-env { + .api-keys-badge-type, .api-keys-badge-env, .api-keys-badge-restricted { margin-left: 0.25rem; padding: 0.15rem 0.4rem; border-radius: 3px; @@ -234,6 +238,10 @@ color: var(--api-keys-badge-test-color); background-color: var(--api-keys-badge-test-bg); } + .api-keys-badge-restricted { + color: var(--api-keys-badge-restricted-color); + background-color: var(--api-keys-badge-restricted-bg); + } .api-keys-button-text { padding-left: 0.2em; From d5423f056fb99dc5ec31229e7f3272aafb91956e Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:08:23 +0100 Subject: [PATCH 03/11] Test the full request-restrictions matrix Two new files. `restrictions_test.rb` pins the value object: exact, case insensitive, port blind and scheme blind host matching; wildcards that match subdomains at any depth but never the apex or a lookalike suffix; IPv4 and IPv6 exact addresses, CIDR boundaries, cross-family refusals, and IPv4-mapped addresses; the forgiving parsers against full URLs, ports, paths, duplicates and junk; wrap idempotence, nil safety, unknown-key preservation, and the Origin/Referer extraction path including garbage headers. `request_restrictions_test.rb` drives the authenticator and the controller concern end to end: unrestricted keys still work from anywhere, locked keys answer 403 with the right error code for a wrong origin, a missing origin, a garbage Origin header, an out-of-range address, an unreadable address, and a resolver that raises; both lists must pass when both are set; a custom client_ip_resolver decides the address; the token cache never stales a tightened allowlist; refusal messages never contain the allowlist and can be translated. Alongside them, the model and configuration surface: normalization of raw strings, per-list editing, the restricted/unrestricted scopes, entry count and size caps, malformed origin and IP rejection, key-type ceilings in all three shapes, and the missing-column guard. The in-memory test schema and the dummy application both gain the column. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k --- ...0825120000_add_restrictions_to_api_keys.rb | 10 + test/dummy/db/schema.rb | 3 +- .../add_restrictions_generator_test.rb | 28 + test/request_restrictions_test.rb | 647 ++++++++++++++++++ test/restrictions_test.rb | 371 ++++++++++ test/test_helper.rb | 2 + 6 files changed, 1060 insertions(+), 1 deletion(-) create mode 100644 test/dummy/db/migrate/20260825120000_add_restrictions_to_api_keys.rb create mode 100644 test/generators/add_restrictions_generator_test.rb create mode 100644 test/request_restrictions_test.rb create mode 100644 test/restrictions_test.rb diff --git a/test/dummy/db/migrate/20260825120000_add_restrictions_to_api_keys.rb b/test/dummy/db/migrate/20260825120000_add_restrictions_to_api_keys.rb new file mode 100644 index 0000000..e8ab5a7 --- /dev/null +++ b/test/dummy/db/migrate/20260825120000_add_restrictions_to_api_keys.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Keep the demo application's schema aligned with the request restrictions +# feature added in api_keys 0.5. Existing downstream applications use the +# corresponding `api_keys:add_restrictions` generator when opting into it. +class AddRestrictionsToApiKeys < ActiveRecord::Migration[8.0] + def change + add_column :api_keys, :restrictions, :json, default: {}, null: false + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index ea51d0e..8d08ef8 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_08_160000) do +ActiveRecord::Schema[8.1].define(version: 2026_08_25_120000) do create_table "api_keys", force: :cascade do |t| t.datetime "created_at", null: false t.string "digest_algorithm", null: false @@ -25,6 +25,7 @@ t.string "owner_type" t.string "prefix", null: false t.bigint "requests_count", default: 0, null: false + t.json "restrictions", default: {}, null: false t.datetime "revoked_at" t.json "scopes", default: [], null: false t.string "token_digest", null: false diff --git a/test/generators/add_restrictions_generator_test.rb b/test/generators/add_restrictions_generator_test.rb new file mode 100644 index 0000000..5ca9e9c --- /dev/null +++ b/test/generators/add_restrictions_generator_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "test_helper" +require "rails/generators/test_case" +require "generators/api_keys/add_restrictions_generator" + +module ApiKeys + module Generators + class AddRestrictionsGeneratorTest < Rails::Generators::TestCase + tests ApiKeys::Generators::AddRestrictionsGenerator + destination File.expand_path("../../tmp/add_restrictions_generator", __dir__) + setup :prepare_destination + + test "generates a restrictions column migration that is unrestricted by default" do + run_generator + + assert_migration "db/migrate/add_restrictions_to_api_keys.rb" do |migration| + assert_includes migration, "class AddRestrictionsToApiKeys < ActiveRecord::Migration[" + assert_includes migration, "add_column :api_keys, :restrictions, json_column_type, default: {}, null: false" + assert_includes migration, "def json_column_type" + assert_includes migration, ":jsonb" + assert_includes migration, "rescue ActiveRecord::ConnectionNotEstablished" + refute_includes migration, "def migration_version" + end + end + end + end +end diff --git a/test/request_restrictions_test.rb b/test/request_restrictions_test.rb new file mode 100644 index 0000000..1c1ece9 --- /dev/null +++ b/test/request_restrictions_test.rb @@ -0,0 +1,647 @@ +# frozen_string_literal: true + +require "test_helper" +require "active_job" + +# End-to-end coverage for request restrictions: the authenticator enforcement +# point, the 403 the controller concern renders, and the model/configuration +# surface that gets restrictions onto a key in the first place. +class RequestRestrictionsTest < ApiKeys::Test + # Stands in for ActionDispatch::Request. The authenticator only ever asks a + # request for headers, query parameters, protocol, uuid, and remote_ip. + class FakeRequest + attr_reader :headers, :query_parameters, :protocol, :uuid, :remote_ip + + def initialize(token: nil, origin: nil, referer: nil, remote_ip: "203.0.113.7", headers: {}) + @headers = {} + @headers["Authorization"] = "Bearer #{token}" if token + @headers["Origin"] = origin unless origin.nil? + @headers["Referer"] = referer unless referer.nil? + @headers.merge!(headers) + @query_parameters = {} + @protocol = "https://" + @remote_ip = remote_ip + @uuid = SecureRandom.uuid + end + end + + # Minimal controller-like object including the authentication concern, so the + # HTTP status mapping is exercised the way a host application sees it. + class FakeController + include ApiKeys::Authentication + + attr_reader :rendered + + def initialize(request) + @request = request + @rendered = nil + end + + attr_reader :request + + def render(json:, status:) + @rendered = { json: json, status: status } + end + end + + # A cache that behaves like Rails.cache so the token-cache hit path is real. + class FakeCache + def initialize + @store = {} + end + + def read(key) + @store[key] + end + + def write(key, value, **_options) + @store[key] = value + true + end + + def delete(key) + @store.delete(key) + end + end + + def setup + super + ApiKeys.configure { |config| config.enable_async_operations = false } + @user = User.create!(name: "Restrictions Owner") + end + + # Creates a key and returns [key, plaintext_token]. + def create_key(**attributes) + key = ApiKeys::ApiKey.create!(owner: @user, name: "Restricted Key", **attributes) + [ApiKeys::ApiKey.find(key.id), key.token] + end + + def authenticate(token:, **request_options) + ApiKeys::Services::Authenticator.call(FakeRequest.new(token: token, **request_options)) + end + + def render_authentication(token:, **request_options) + controller = FakeController.new(FakeRequest.new(token: token, **request_options)) + controller.send(:authenticate_api_key!) + controller + end + + # ============================================================================= + # Authenticator: origins + # ============================================================================= + + test "an unrestricted key authenticates from anywhere" do + _key, token = create_key + + assert authenticate(token: token, origin: "https://anywhere.example").success? + assert authenticate(token: token, origin: nil, remote_ip: "198.51.100.4").success? + end + + test "an origins-locked key authenticates from a matching Origin header" do + _key, token = create_key(allowed_origins: "example.com, *.example.com") + + assert authenticate(token: token, origin: "https://example.com").success? + assert authenticate(token: token, origin: "https://shop.example.com:8443").success? + end + + test "an origins-locked key falls back to the Referer header" do + _key, token = create_key(allowed_origins: "example.com") + + result = authenticate(token: token, referer: "https://example.com/widgets/1") + + assert result.success? + end + + test "an origins-locked key refuses a request from another origin" do + _key, token = create_key(allowed_origins: "example.com") + + result = authenticate(token: token, origin: "https://freeloader.example") + + refute result.success? + assert_equal :origin_not_allowed, result.error_code + end + + test "an origins-locked key refuses a request with no readable origin" do + _key, token = create_key(allowed_origins: "example.com") + + result = authenticate(token: token) + + refute result.success?, "fail closed: no Origin and no Referer means no proof of origin" + assert_equal :origin_not_allowed, result.error_code + end + + test "an origins-locked key refuses a garbage Origin header without raising" do + _key, token = create_key(allowed_origins: "example.com") + + ["null", "%%%", "http://[not a uri]", ""].each do |garbage| + result = authenticate(token: token, origin: garbage) + + refute result.success?, "expected #{garbage.inspect} to be refused" + assert_equal :origin_not_allowed, result.error_code + end + end + + test "a wildcard origin restriction admits subdomains but not the apex" do + _key, token = create_key(allowed_origins: "*.example.com") + + assert authenticate(token: token, origin: "https://a.b.example.com").success? + assert_equal :origin_not_allowed, authenticate(token: token, origin: "https://example.com").error_code + end + + # ============================================================================= + # Authenticator: IPs + # ============================================================================= + + test "an IP-locked key authenticates from inside the configured range" do + _key, token = create_key(allowed_ips: "10.0.0.0/8, 203.0.113.7") + + assert authenticate(token: token, remote_ip: "10.1.2.3").success? + assert authenticate(token: token, remote_ip: "203.0.113.7").success? + end + + test "an IP-locked key refuses an address outside the configured range" do + _key, token = create_key(allowed_ips: "10.0.0.0/8") + + result = authenticate(token: token, remote_ip: "192.0.2.10") + + refute result.success? + assert_equal :ip_not_allowed, result.error_code + end + + test "an IP-locked key refuses an unreadable client address" do + _key, token = create_key(allowed_ips: "10.0.0.0/8") + + assert_equal :ip_not_allowed, authenticate(token: token, remote_ip: nil).error_code + assert_equal :ip_not_allowed, authenticate(token: token, remote_ip: "not-an-ip").error_code + end + + test "the configured client_ip_resolver is what decides the address" do + _key, token = create_key(allowed_ips: "198.51.100.0/24") + ApiKeys.configure do |config| + config.client_ip_resolver = ->(request) { request.headers["CF-Connecting-IP"] } + end + + allowed = authenticate(token: token, remote_ip: "10.0.0.1", headers: { "CF-Connecting-IP" => "198.51.100.9" }) + refused = authenticate(token: token, remote_ip: "198.51.100.9", headers: { "CF-Connecting-IP" => "10.0.0.1" }) + + assert allowed.success?, "the resolver's answer must win over remote_ip" + assert_equal :ip_not_allowed, refused.error_code + end + + test "a client_ip_resolver that blows up fails closed" do + _key, token = create_key(allowed_ips: "10.0.0.0/8") + ApiKeys.configure do |config| + config.client_ip_resolver = ->(_request) { raise "resolver exploded" } + end + + assert_equal :ip_not_allowed, authenticate(token: token, remote_ip: "10.1.2.3").error_code + end + + # ============================================================================= + # Authenticator: combinations and coverage of every key + # ============================================================================= + + test "a key locked on both kinds needs both to match" do + _key, token = create_key(allowed_origins: "example.com", allowed_ips: "10.0.0.0/8") + + assert authenticate(token: token, origin: "https://example.com", remote_ip: "10.1.2.3").success? + assert_equal :ip_not_allowed, + authenticate(token: token, origin: "https://example.com", remote_ip: "192.0.2.1").error_code + assert_equal :origin_not_allowed, + authenticate(token: token, origin: "https://other.example", remote_ip: "10.1.2.3").error_code + end + + test "restrictions are enforced on legacy untyped keys too" do + _key, token = create_key(allowed_origins: "example.com") + + assert_nil ApiKeys::ApiKey.last.key_type + assert_equal :origin_not_allowed, authenticate(token: token, origin: "https://elsewhere.example").error_code + end + + test "restrictions are enforced on typed keys of every type" do + configure_key_types! + publishable = @user.create_api_key!(name: "Widget", key_type: :publishable, allowed_origins: "example.com") + secret = @user.create_api_key!(name: "Server", key_type: :secret, allowed_ips: "10.0.0.0/8") + + assert_equal :origin_not_allowed, authenticate(token: publishable.token, origin: "https://nope.example").error_code + assert_equal :ip_not_allowed, authenticate(token: secret.token, remote_ip: "192.0.2.1").error_code + end + + test "restriction checks survive the token cache and always read the fresh row" do + ApiKeys::Services::Authenticator.stubs(:rails_cache).returns(FakeCache.new) + key, token = create_key(allowed_origins: "example.com") + + assert authenticate(token: token, origin: "https://example.com").success? + + key.update!(allowed_origins: "example.org") + + result = authenticate(token: token, origin: "https://example.com") + + refute result.success?, "a tightened allowlist must take effect on the very next request" + assert_equal :origin_not_allowed, result.error_code + end + + test "an environment mismatch is reported before a restriction failure" do + configure_key_types! + ApiKeys.configure do |config| + config.strict_environment_isolation = true + config.current_environment = -> { :live } + end + key = @user.create_api_key!(name: "Widget", key_type: :publishable, environment: :test, + allowed_origins: "example.com") + + result = authenticate(token: key.token, origin: "https://nope.example") + + assert_equal :environment_mismatch, result.error_code + end + + # ============================================================================= + # Controller concern: status codes and messages + # ============================================================================= + + test "a refused origin answers 403, not 401" do + _key, token = create_key(allowed_origins: "example.com") + + controller = render_authentication(token: token, origin: "https://nope.example") + + assert_equal :forbidden, controller.rendered[:status] + assert_equal :origin_not_allowed, controller.rendered[:json][:error] + assert_nil controller.send(:current_api_key) + end + + test "a refused IP answers 403, not 401" do + _key, token = create_key(allowed_ips: "10.0.0.0/8") + + controller = render_authentication(token: token, remote_ip: "192.0.2.1") + + assert_equal :forbidden, controller.rendered[:status] + assert_equal :ip_not_allowed, controller.rendered[:json][:error] + end + + test "an invalid token still answers 401" do + controller = render_authentication(token: "ak_not_a_real_token") + + assert_equal :unauthorized, controller.rendered[:status] + assert_equal :invalid_token, controller.rendered[:json][:error] + end + + test "an allowed request renders nothing and exposes the key" do + key, token = create_key(allowed_origins: "example.com") + + controller = render_authentication(token: token, origin: "https://example.com") + + assert_nil controller.rendered + assert_equal key, controller.send(:current_api_key) + end + + test "the refusal message never echoes the configured allowlist" do + _key, token = create_key(allowed_origins: "secret-internal.example, *.hidden.example", + allowed_ips: "10.9.8.7") + + origin_message = render_authentication(token: token, origin: "https://nope.example").rendered[:json][:message] + ip_message = render_authentication(token: token, origin: "https://secret-internal.example", + remote_ip: "192.0.2.1").rendered[:json][:message] + + refute_includes origin_message, "secret-internal.example" + refute_includes origin_message, "hidden.example" + refute_includes ip_message, "10.9.8.7" + assert_includes origin_message, "restricted to specific web origins" + assert_includes ip_message, "restricted to specific IP addresses" + end + + test "a host application can translate the refusal message" do + _key, token = create_key(allowed_origins: "example.com") + I18n.backend.store_translations(:en, api_keys: { errors: { origin_not_allowed: "Not from there, sorry" } }) + + controller = render_authentication(token: token, origin: "https://nope.example") + + assert_equal "Not from there, sorry", controller.rendered[:json][:message] + ensure + I18n.backend.reload! + end + + test "forbidden error codes are exactly the request-context refusals" do + assert_equal %i[origin_not_allowed ip_not_allowed], ApiKeys::Authentication::FORBIDDEN_ERROR_CODES + end + + # ============================================================================= + # Model surface + # ============================================================================= + + test "allowed_origins= normalizes the raw string a form submits" do + key, _token = create_key + key.allowed_origins = "https://Shop.example/, *.app.example\n shop.example" + key.save! + + assert_equal ["shop.example", "*.app.example"], key.reload.allowed_origins + assert key.restricted? + end + + test "allowed_ips= normalizes the raw string a form submits" do + key, _token = create_key + key.allowed_ips = "203.0.113.7, 10.0.0.0/8" + key.save! + + assert_equal ["203.0.113.7", "10.0.0.0/8"], key.reload.allowed_ips + end + + test "clearing a list makes the key unrestricted again" do + key, _token = create_key(allowed_origins: "example.com") + key.update!(allowed_origins: "") + + assert_empty key.reload.allowed_origins + refute key.restricted? + assert_equal({}, key.restrictions.to_h) + end + + test "each list is edited independently" do + key, _token = create_key(allowed_origins: "example.com") + key.update!(allowed_ips: "10.0.0.0/8") + + assert_equal ["example.com"], key.reload.allowed_origins + assert_equal ["10.0.0.0/8"], key.allowed_ips + end + + test "restrictions accepts a hash and a value object" do + key, _token = create_key + key.update!(restrictions: { origins: ["example.com"] }) + + assert_equal ["example.com"], key.reload.allowed_origins + + key.update!(restrictions: ApiKeys::Restrictions.wrap("ips" => ["10.0.0.0/8"])) + + assert_equal({ "ips" => ["10.0.0.0/8"] }, key.reload.restrictions.to_h) + end + + test "restricted and unrestricted scopes partition the table" do + restricted, _token = create_key(allowed_origins: "example.com") + unrestricted = ApiKeys::ApiKey.create!(owner: @user, name: "Open Key") + + assert_equal [restricted.id], ApiKeys::ApiKey.restricted.pluck(:id) + assert_equal [unrestricted.id], ApiKeys::ApiKey.unrestricted.pluck(:id) + end + + test "create_api_key! accepts raw origin and IP strings" do + key = @user.create_api_key!(name: "Widget Key", allowed_origins: "example.com, *.example.com", + allowed_ips: "10.0.0.0/8") + + assert_equal ["example.com", "*.example.com"], key.allowed_origins + assert_equal ["10.0.0.0/8"], key.allowed_ips + end + + test "create_api_key! accepts a restrictions hash" do + key = @user.create_api_key!(name: "Widget Key", restrictions: { origins: ["example.com"] }) + + assert_equal ["example.com"], key.allowed_origins + end + + test "create_api_key! leaves keys unrestricted when nothing is asked for" do + key = @user.create_api_key!(name: "Plain Key") + + assert_equal({}, key.restrictions.to_h) + refute key.restricted? + end + + test "restrictions are not part of the immutable authentication identity" do + refute_includes ApiKeys::ApiKey::IMMUTABLE_IDENTITY_ATTRIBUTES, "restrictions" + end + + test "a non-revocable public key can still have its restrictions tightened" do + configure_key_types! + key = @user.create_api_key!(name: "Widget", key_type: :publishable, allowed_origins: "example.com") + + refute key.revocable? + assert key.update(allowed_origins: "example.com, *.example.com"), + "restriction edits are the control a non-revocable key has: #{key.errors.full_messages}" + assert_equal ["example.com", "*.example.com"], key.reload.allowed_origins + end + + # ============================================================================= + # Validations + # ============================================================================= + + test "unknown restriction kinds are rejected" do + key = ApiKeys::ApiKey.new(owner: @user, name: "Bad Key", restrictions: { "countries" => ["ES"] }) + + refute key.valid? + assert_includes key.errors.full_messages.join(" "), "unknown restriction kinds: countries" + end + + test "a restrictions value that is not an object is rejected" do + key = ApiKeys::ApiKey.new(owner: @user, name: "Bad Key") + key.restrictions = "example.com" + + refute key.valid? + assert_includes key.errors.full_messages.join(" "), "must be an object" + end + + test "more than 100 entries in a list are rejected" do + key = ApiKeys::ApiKey.new(owner: @user, name: "Bad Key", + restrictions: { "origins" => Array.new(101) { |index| "host#{index}.example.com" } }) + + refute key.valid? + assert_includes key.errors.full_messages.join(" "), "cannot contain more than 100 entries" + end + + test "exactly 100 entries in a list are accepted" do + key = ApiKeys::ApiKey.new(owner: @user, name: "Big Key", + restrictions: { "origins" => Array.new(100) { |index| "host#{index}.example.com" } }) + + assert key.valid?, key.errors.full_messages.join(", ") + end + + test "oversize entries are rejected" do + key = ApiKeys::ApiKey.new(owner: @user, name: "Bad Key", + restrictions: { "origins" => ["#{'a' * 256}.example.com"] }) + + refute key.valid? + assert_includes key.errors.full_messages.join(" "), "cannot exceed 255 bytes" + end + + test "malformed origin entries are rejected" do + ["*", "*.", "exam ple.com", "example.com/path", "@example.com"].each do |entry| + key = ApiKeys::ApiKey.new(owner: @user, name: "Bad Key", restrictions: { "origins" => [entry] }) + + refute key.valid?, "expected #{entry.inspect} to be rejected" + assert_includes key.errors.full_messages.join(" "), "must be bare hosts" + end + end + + test "malformed IP entries are rejected" do + ["10.0.0.0/99", "999.0.0.1", "example.com", "10.0.0.1-10.0.0.9"].each do |entry| + key = ApiKeys::ApiKey.new(owner: @user, name: "Bad Key", restrictions: { "ips" => [entry] }) + + refute key.valid?, "expected #{entry.inspect} to be rejected" + assert_includes key.errors.full_messages.join(" "), "valid IPv4/IPv6 addresses or CIDR ranges" + end + end + + test "valid origin and IP entries pass validation" do + key = ApiKeys::ApiKey.new(owner: @user, name: "Good Key", + restrictions: { "origins" => ["example.com", "*.example.com", "localhost"], + "ips" => ["203.0.113.7", "10.0.0.0/8", "2001:db8::/32"] }) + + assert key.valid?, key.errors.full_messages.join(", ") + end + + # ============================================================================= + # Key type ceilings + # ============================================================================= + + test "a key type may declare which restriction kinds its keys can carry" do + configure_key_types! + key = @user.api_keys.build(key_type: "publishable", environment: "test", name: "Widget", + restrictions: { "ips" => ["10.0.0.0/8"] }) + + refute key.valid? + assert_includes key.errors.full_messages.join(" "), "ips are not allowed for publishable keys" + end + + test "a key type ceiling allows the kinds it lists" do + configure_key_types! + key = @user.api_keys.build(key_type: "publishable", environment: "test", name: "Widget", + restrictions: { "origins" => ["example.com"] }) + + assert key.valid?, key.errors.full_messages.join(", ") + end + + test "an omitted restrictions ceiling allows every kind" do + ApiKeys.configure do |config| + config.key_types = { standard: { prefix: "std", permissions: :all } } + config.environments = { test: { prefix_segment: "test" } } + config.current_environment = -> { :test } + end + key = @user.api_keys.build(key_type: "standard", environment: "test", name: "Anything", + restrictions: { "origins" => ["example.com"], "ips" => ["10.0.0.0/8"] }) + + assert key.valid?, key.errors.full_messages.join(", ") + end + + test "an empty restrictions ceiling forbids every kind" do + ApiKeys.configure do |config| + config.key_types = { locked: { prefix: "lk", permissions: :all, restrictions: [] } } + config.environments = { test: { prefix_segment: "test" } } + config.current_environment = -> { :test } + end + key = @user.api_keys.build(key_type: "locked", environment: "test", name: "No Restrictions", + restrictions: { "origins" => ["example.com"] }) + + refute key.valid? + assert_includes key.errors.full_messages.join(" "), "origins are not allowed for locked keys" + end + + test "untyped keys are not subject to any ceiling" do + key = ApiKeys::ApiKey.new(owner: @user, name: "Legacy Key", + restrictions: { "origins" => ["example.com"], "ips" => ["10.0.0.0/8"] }) + + assert key.valid?, key.errors.full_messages.join(", ") + end + + # ============================================================================= + # Configuration + # ============================================================================= + + test "client_ip_resolver defaults to the request's remote_ip" do + request = FakeRequest.new(remote_ip: "198.51.100.22") + + assert_equal "198.51.100.22", ApiKeys.configuration.client_ip_resolver.call(request) + end + + test "client_ip_resolver must be callable" do + assert_raises(ArgumentError) { ApiKeys.configuration.client_ip_resolver = "remote_ip" } + assert_raises(ArgumentError) { ApiKeys.configuration.client_ip_resolver = nil } + end + + test "key type restriction ceilings are validated at assignment" do + assert_raises(ArgumentError) do + ApiKeys.configure do |config| + config.key_types = { publishable: { prefix: "pk", permissions: %w[read], restrictions: [:countries] } } + end + end + + assert_raises(ArgumentError) do + ApiKeys.configure do |config| + config.key_types = { publishable: { prefix: "pk", permissions: %w[read], restrictions: :origins } } + end + end + end + + test "key type restriction ceilings accept strings and symbols" do + assert_nothing_raised do + ApiKeys.configure do |config| + config.key_types = { + publishable: { prefix: "pk", permissions: %w[read], restrictions: ["origins"] }, + secret: { prefix: "sk", permissions: :all, restrictions: [:ips] } + } + end + end + end + + # ============================================================================= + # Missing column guard + # ============================================================================= + + test "the restrictions column is detected" do + assert ApiKeys::ApiKey.restrictions_column? + end + + test "writing restrictions without the column raises and names the generator" do + ApiKeys::ApiKey.stubs(:restrictions_column?).returns(false) + + error = assert_raises(ApiKeys::Errors::RestrictionsMigrationRequiredError) do + ApiKeys::ApiKey.new(owner: @user, name: "Key").restrictions = { "origins" => ["example.com"] } + end + + assert_includes error.message, "rails generate api_keys:add_restrictions" + end + + test "create_api_key! with restrictions raises without the column" do + ApiKeys::ApiKey.stubs(:restrictions_column?).returns(false) + + assert_raises(ApiKeys::Errors::RestrictionsMigrationRequiredError) do + @user.create_api_key!(name: "Widget Key", allowed_origins: "example.com") + end + end + + test "configuring a key type ceiling raises without the column" do + configure_key_types! + ApiKeys::ApiKey.stubs(:restrictions_column?).returns(false) + + assert_raises(ApiKeys::Errors::RestrictionsMigrationRequiredError) do + @user.create_api_key!(name: "Widget", key_type: :publishable) + end + end + + test "keys without the column behave as unrestricted" do + key, _token = create_key + ApiKeys::ApiKey.stubs(:restrictions_column?).returns(false) + + assert key.restrictions.unrestricted? + refute key.restricted? + assert_empty key.allowed_origins + assert key.valid?, key.errors.full_messages.join(", ") + end + + private + + def configure_key_types! + ApiKeys.configure do |config| + config.key_types = { + publishable: { + prefix: "pk", + permissions: %w[read], + revocable: false, + public: true, + restrictions: [:origins] + }, + secret: { + prefix: "sk", + permissions: :all, + restrictions: [:ips] + } + } + config.environments = { test: { prefix_segment: "test" }, live: { prefix_segment: "live" } } + config.current_environment = -> { :test } + end + end +end diff --git a/test/restrictions_test.rb b/test/restrictions_test.rb new file mode 100644 index 0000000..7e5085e --- /dev/null +++ b/test/restrictions_test.rb @@ -0,0 +1,371 @@ +# frozen_string_literal: true + +require "test_helper" + +# Unit matrix for the ApiKeys::Restrictions value object: parsing, normalizing, +# and the matching semantics the authenticator relies on. +class RestrictionsTest < ApiKeys::Test + # A minimal stand-in for ActionDispatch::Request: all the value object needs + # is something that answers #headers. + class FakeRequest + attr_reader :headers + + def initialize(headers = {}) + @headers = headers + end + end + + # Records every warning the value object emits. + class RecordingLogger + attr_reader :warnings + + def initialize + @warnings = [] + end + + def warn(message) + @warnings << message + end + + def debug(_message); end + def error(_message); end + end + + def restrictions(origins: [], ips: []) + ApiKeys::Restrictions.wrap("origins" => origins, "ips" => ips) + end + + # ============================================================================= + # Origin matching + # ============================================================================= + + test "an exact host matches itself" do + locked = restrictions(origins: ["example.com"]) + + assert locked.origin_allowed?("example.com") + refute locked.origin_allowed?("other.com") + end + + test "host matching is case-insensitive on both sides" do + locked = restrictions(origins: ["Example.COM"]) + + assert locked.origin_allowed?("EXAMPLE.com") + assert locked.origin_allowed?("example.com") + end + + test "host matching is port-blind and scheme-blind" do + locked = restrictions(origins: ["x.example"]) + request = FakeRequest.new("Origin" => "https://x.example:8443") + + assert locked.origin_allowed?(ApiKeys::Restrictions.extract_origin_host(request)) + assert locked.origin_allowed?(ApiKeys::Restrictions.extract_origin_host(FakeRequest.new("Origin" => "http://x.example"))) + end + + test "a subdomain wildcard matches subdomains at any depth" do + locked = restrictions(origins: ["*.example.com"]) + + assert locked.origin_allowed?("a.example.com") + assert locked.origin_allowed?("a.b.example.com") + end + + test "a subdomain wildcard does not match the apex domain" do + locked = restrictions(origins: ["*.example.com"]) + + refute locked.origin_allowed?("example.com") + end + + test "a subdomain wildcard does not match a lookalike suffix" do + locked = restrictions(origins: ["*.example.com"]) + + refute locked.origin_allowed?("evilexample.com") + refute locked.origin_allowed?("example.com.evil.com") + end + + test "listing the apex alongside the wildcard covers both" do + locked = restrictions(origins: ["example.com", "*.example.com"]) + + assert locked.origin_allowed?("example.com") + assert locked.origin_allowed?("shop.example.com") + refute locked.origin_allowed?("example.org") + end + + test "a bare asterisk is not a valid origin entry" do + refute ApiKeys::Restrictions.valid_origin_entry?("*") + refute ApiKeys::Restrictions.valid_origin_entry?("*.") + refute ApiKeys::Restrictions.valid_origin_entry?("exam ple.com") + refute ApiKeys::Restrictions.valid_origin_entry?("example.com/path") + assert ApiKeys::Restrictions.valid_origin_entry?("example.com") + assert ApiKeys::Restrictions.valid_origin_entry?("*.example.com") + assert ApiKeys::Restrictions.valid_origin_entry?("localhost") + end + + test "a locked origin list refuses a nil or blank host" do + locked = restrictions(origins: ["example.com"]) + + refute locked.origin_allowed?(nil) + refute locked.origin_allowed?("") + refute locked.origin_allowed?(" ") + end + + test "an empty origin list allows every host" do + assert ApiKeys::Restrictions.none.origin_allowed?("anything.com") + assert ApiKeys::Restrictions.none.origin_allowed?(nil) + end + + # ============================================================================= + # normalize_origins: the forgiving parser dashboards submit into + # ============================================================================= + + test "normalize_origins accepts full URLs, wildcards, commas, and newlines" do + assert_equal ["shop.example", "*.app.example", "x"], + ApiKeys::Restrictions.normalize_origins("https://Shop.example/, *.app.example\n x") + end + + test "normalize_origins strips trailing slashes, paths, and ports" do + assert_equal ["example.com"], ApiKeys::Restrictions.normalize_origins("example.com/") + assert_equal ["example.com"], ApiKeys::Restrictions.normalize_origins("example.com:3000") + assert_equal ["example.com"], ApiKeys::Restrictions.normalize_origins("https://example.com/some/path?q=1") + end + + test "normalize_origins de-duplicates entries that normalize to the same host" do + assert_equal ["example.com"], ApiKeys::Restrictions.normalize_origins("Example.com, https://example.com/, example.com") + end + + test "normalize_origins drops entries with nothing host-like in them" do + assert_equal ["example.com"], ApiKeys::Restrictions.normalize_origins("https://, example.com, ") + assert_empty ApiKeys::Restrictions.normalize_origins(nil) + assert_empty ApiKeys::Restrictions.normalize_origins("") + end + + test "normalize_origins accepts arrays as well as raw strings" do + assert_equal ["a.com", "b.com"], ApiKeys::Restrictions.normalize_origins(["A.com", "https://b.com"]) + end + + test "normalize_origins keeps invalid entries for validation to report" do + # Dropping "*" here would silently leave the key unrestricted; validation + # is what tells the user their entry is wrong. + assert_equal ["*"], ApiKeys::Restrictions.normalize_origins("*") + end + + # ============================================================================= + # IP matching + # ============================================================================= + + test "an exact IPv4 address matches itself" do + locked = restrictions(ips: ["203.0.113.7"]) + + assert locked.ip_allowed?("203.0.113.7") + refute locked.ip_allowed?("203.0.113.8") + end + + test "a bare IPv4 address is a /32 and matches nothing else" do + assert_equal 32, ApiKeys::Restrictions.parse_ip("203.0.113.7").prefix + refute restrictions(ips: ["203.0.113.7"]).ip_allowed?("203.0.113.6") + end + + test "an IPv4 CIDR range matches inside its boundaries and refuses outside" do + locked = restrictions(ips: ["10.0.0.0/24"]) + + assert locked.ip_allowed?("10.0.0.0") + assert locked.ip_allowed?("10.0.0.255") + refute locked.ip_allowed?("10.0.1.0") + refute locked.ip_allowed?("9.255.255.255") + end + + test "an exact IPv6 address matches itself" do + locked = restrictions(ips: ["2001:db8::1"]) + + assert locked.ip_allowed?("2001:db8::1") + refute locked.ip_allowed?("2001:db8::2") + end + + test "an IPv6 CIDR range matches addresses inside it" do + locked = restrictions(ips: ["2001:db8::/32"]) + + assert locked.ip_allowed?("2001:db8::1") + assert locked.ip_allowed?("2001:db8:ffff::abcd") + refute locked.ip_allowed?("2001:db9::1") + end + + test "address families never match across each other" do + refute restrictions(ips: ["10.0.0.0/8"]).ip_allowed?("2001:db8::1") + refute restrictions(ips: ["2001:db8::/32"]).ip_allowed?("10.0.0.1") + end + + test "an IPv4-mapped IPv6 address is matched as its IPv4 form" do + assert restrictions(ips: ["203.0.113.0/24"]).ip_allowed?("::ffff:203.0.113.7") + end + + test "a locked IP list refuses a nil or unparseable address" do + locked = restrictions(ips: ["10.0.0.0/8"]) + + refute locked.ip_allowed?(nil) + refute locked.ip_allowed?("") + refute locked.ip_allowed?("not-an-ip") + refute locked.ip_allowed?("10.0.0.1, 10.0.0.2") + end + + test "an empty IP list allows every address" do + assert ApiKeys::Restrictions.none.ip_allowed?("10.0.0.1") + assert ApiKeys::Restrictions.none.ip_allowed?(nil) + end + + test "an unparseable stored IP entry matches nothing and warns once" do + logger = RecordingLogger.new + Rails.stubs(:logger).returns(logger) + + locked = restrictions(ips: ["10.0.0.0/8", "not-an-ip"]) + + assert locked.ip_allowed?("10.1.2.3"), "a valid sibling entry must still match" + refute locked.ip_allowed?("192.0.2.1") + assert logger.warnings.any? { |message| message.include?("unparseable stored IP restriction entry") } + end + + test "normalize_ips splits, downcases, and de-duplicates" do + assert_equal ["203.0.113.7", "10.0.0.0/8"], ApiKeys::Restrictions.normalize_ips("203.0.113.7, 10.0.0.0/8") + assert_equal ["2001:db8::/32"], ApiKeys::Restrictions.normalize_ips("2001:DB8::/32\n2001:db8::/32") + assert_empty ApiKeys::Restrictions.normalize_ips(nil) + end + + test "valid_ip_entry? accepts addresses and ranges and refuses nonsense" do + assert ApiKeys::Restrictions.valid_ip_entry?("203.0.113.7") + assert ApiKeys::Restrictions.valid_ip_entry?("10.0.0.0/8") + assert ApiKeys::Restrictions.valid_ip_entry?("2001:db8::/32") + refute ApiKeys::Restrictions.valid_ip_entry?("10.0.0.0/99") + refute ApiKeys::Restrictions.valid_ip_entry?("999.0.0.1") + refute ApiKeys::Restrictions.valid_ip_entry?("example.com") + refute ApiKeys::Restrictions.valid_ip_entry?(42) + end + + # ============================================================================= + # Combination semantics: OR within a list, AND across lists + # ============================================================================= + + test "within a list any entry admits the request" do + locked = restrictions(origins: ["a.com", "b.com"]) + + assert locked.allows?(origin_host: "a.com") + assert locked.allows?(origin_host: "b.com") + refute locked.allows?(origin_host: "c.com") + end + + test "across lists every locked list must pass" do + locked = restrictions(origins: ["a.com"], ips: ["10.0.0.0/8"]) + + assert locked.allows?(origin_host: "a.com", ip: "10.1.2.3") + refute locked.allows?(origin_host: "a.com", ip: "192.0.2.1"), "the IP list must still be enforced" + refute locked.allows?(origin_host: "b.com", ip: "10.1.2.3"), "the origin list must still be enforced" + refute locked.allows?(origin_host: nil, ip: "10.1.2.3"), "an origin-locked key is unusable without an origin" + end + + test "an unrestricted key allows any context at all" do + assert ApiKeys::Restrictions.none.allows?(origin_host: nil, ip: nil) + end + + # ============================================================================= + # wrap, to_h, and general shape + # ============================================================================= + + test "unrestricted? is true only when both lists are empty" do + assert ApiKeys::Restrictions.none.unrestricted? + assert ApiKeys::Restrictions.wrap({}).unrestricted? + refute restrictions(origins: ["a.com"]).unrestricted? + refute restrictions(ips: ["10.0.0.1"]).unrestricted? + assert restrictions(origins: ["a.com"]).restricted? + end + + test "kinds names only the lists actually in use" do + assert_empty ApiKeys::Restrictions.none.kinds + assert_equal [:origins], restrictions(origins: ["a.com"]).kinds + assert_equal [:ips], restrictions(ips: ["10.0.0.1"]).kinds + assert_equal [:origins, :ips], restrictions(origins: ["a.com"], ips: ["10.0.0.1"]).kinds + end + + test "to_h drops empty lists" do + assert_equal({}, ApiKeys::Restrictions.none.to_h) + assert_equal({ "origins" => ["a.com"] }, restrictions(origins: ["a.com"]).to_h) + assert_equal({ "ips" => ["10.0.0.1"] }, restrictions(ips: ["10.0.0.1"]).to_h) + end + + test "wrap is nil-safe and idempotent" do + assert ApiKeys::Restrictions.wrap(nil).unrestricted? + + wrapped = restrictions(origins: ["a.com"]) + assert_same wrapped, ApiKeys::Restrictions.wrap(wrapped) + assert_equal wrapped, ApiKeys::Restrictions.wrap(wrapped.to_h) + end + + test "wrap normalizes symbol keys, raw strings, and stray whitespace" do + wrapped = ApiKeys::Restrictions.wrap(origins: " Example.com , *.example.com ", ips: ["10.0.0.0/8"]) + + assert_equal ["example.com", "*.example.com"], wrapped.origins + assert_equal ["10.0.0.0/8"], wrapped.ips + end + + test "wrap never raises on values that are not hashes" do + assert ApiKeys::Restrictions.wrap("garbage").unrestricted? + assert ApiKeys::Restrictions.wrap([1, 2, 3]).unrestricted? + assert ApiKeys::Restrictions.wrap(42).unrestricted? + end + + test "wrap keeps unknown keys so validation can name them" do + wrapped = ApiKeys::Restrictions.wrap("origins" => ["a.com"], "countries" => ["ES"]) + + assert_equal({ "origins" => ["a.com"], "countries" => ["ES"] }, wrapped.to_h) + assert_equal({ "countries" => ["ES"] }, wrapped.extras) + end + + test "wrap keeps non-string entries so validation can reject them" do + wrapped = ApiKeys::Restrictions.wrap("origins" => [42]) + + assert_equal [42], wrapped.origins + refute wrapped.origin_allowed?("42"), "a non-string entry can never match a host" + end + + test "equality and inspect never leak beyond the two lists" do + assert_equal restrictions(origins: ["a.com"]), restrictions(origins: ["a.com"]) + refute_equal restrictions(origins: ["a.com"]), restrictions(origins: ["b.com"]) + refute_equal restrictions(origins: ["a.com"]), "a.com" + assert_includes restrictions(origins: ["a.com"]).inspect, "a.com" + end + + # ============================================================================= + # extract_origin_host + # ============================================================================= + + test "extract_origin_host reads the Origin header first" do + request = FakeRequest.new("Origin" => "https://Shop.example.com", "Referer" => "https://other.com/page") + + assert_equal "shop.example.com", ApiKeys::Restrictions.extract_origin_host(request) + end + + test "extract_origin_host falls back to the Referer header" do + request = FakeRequest.new("Referer" => "https://shop.example.com/products/1?utm=x") + + assert_equal "shop.example.com", ApiKeys::Restrictions.extract_origin_host(request) + end + + test "extract_origin_host ignores a port" do + request = FakeRequest.new("Origin" => "https://x.example:8443") + + assert_equal "x.example", ApiKeys::Restrictions.extract_origin_host(request) + end + + test "extract_origin_host returns nil when no header carries an origin" do + assert_nil ApiKeys::Restrictions.extract_origin_host(FakeRequest.new) + assert_nil ApiKeys::Restrictions.extract_origin_host(FakeRequest.new("Origin" => "")) + assert_nil ApiKeys::Restrictions.extract_origin_host(nil) + end + + test "extract_origin_host returns nil for unparseable or opaque origins" do + assert_nil ApiKeys::Restrictions.extract_origin_host(FakeRequest.new("Origin" => "null")) + assert_nil ApiKeys::Restrictions.extract_origin_host(FakeRequest.new("Origin" => "http://[not a uri]")) + assert_nil ApiKeys::Restrictions.extract_origin_host(FakeRequest.new("Origin" => "%%%")) + assert_nil ApiKeys::Restrictions.extract_origin_host(FakeRequest.new("Origin" => 42)) + end + + test "extract_origin_host survives a request object that cannot answer headers" do + assert_nil ApiKeys::Restrictions.extract_origin_host(Object.new) + assert_nil ApiKeys::Restrictions.extract_origin_host(FakeRequest.new(Object.new)) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 75a7961..e807efd 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -69,6 +69,7 @@ def helper_method(*); end unless method_defined?(:helper_method) t.references :owner, polymorphic: true, null: true # type: :bigint assumed by default t.text :scopes, default: "[]", null: false # Use text for SQLite JSON t.text :metadata, default: "{}", null: false # Use text for SQLite JSON + t.text :restrictions, default: "{}", null: false # Origin/IP request restrictions t.datetime :expires_at t.datetime :last_used_at t.bigint :requests_count, default: 0, null: false @@ -95,6 +96,7 @@ def helper_method(*); end unless method_defined?(:helper_method) json_col_type = :json ApiKeys::ApiKey.attribute :scopes, json_col_type, default: [] ApiKeys::ApiKey.attribute :metadata, json_col_type, default: {} +ApiKeys::ApiKey.attribute :restrictions, json_col_type, default: {} puts "Database schema loaded." From c9c133abbba1dc436570396cf94e760e128d479d Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:08:28 +0100 Subject: [PATCH 04/11] Document request restrictions and bump to 0.5.0 README gains a top-level "Restrict where a key can be used (origins and IPs)" section: the pitch, the upgrade generator, usage, a semantics table (OR within a list, AND across lists, empty means unrestricted, the wildcard and fail-closed rules, the 403 error codes), per-key-type ceilings, the Cloudflare client_ip_resolver example, and the security notes on what browser-enforced headers can and cannot prove. The key types, dashboard, model scopes, instance methods, and upgrade sections point at it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k --- CHANGELOG.md | 17 ++++++ README.md | 128 +++++++++++++++++++++++++++++++++++++++- lib/api_keys/version.rb | 2 +- 3 files changed, 143 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc1a592..4baffee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## [0.5.0] - 2026-08-25 + +### Added + +- Request restrictions: per-key `allowed_origins` (exact hosts and `*.subdomain` wildcards, matched against the browser's Origin with Referer fallback) and `allowed_ips` (IPv4/IPv6, exact or CIDR). Enforced inside `Authenticator.call` for every key on every request, including token-cache hits, so no controller can forget to check and a tightened allowlist takes effect on the next call. Failures answer 403 with `origin_not_allowed` / `ip_not_allowed`. Empty restrictions mean unrestricted, so existing keys are unaffected. +- `ApiKeys::Restrictions` value object: normalization, matching, and the forgiving parsers dashboards want (`.normalize_origins`, `.normalize_ips`, `.extract_origin_host`). Host applications can delete their own origin parsers. +- Per-key-type restriction ceilings via `key_types[...][:restrictions]`, mirroring the way `permissions:` caps scopes. Omitted allows both kinds; `[]` forbids restrictions for that type. +- `config.client_ip_resolver` (defaults to `request.remote_ip`, which honors Rails' trusted proxies). +- `rails generate api_keys:add_restrictions` for existing installations; new installs create the column from the start. +- Dashboard: origin and IP fields on the key form (shown per the key type's ceiling) and a "Restricted" badge. Restriction edits stay available on non-revocable keys, which is the one control the owner of an unrevocable public key has. +- Model surface: `restrictions`, `restricted?`, `allowed_origins`/`allowed_ips` readers and raw-string writers, `restricted`/`unrestricted` scopes, and `create_api_key!(restrictions:, allowed_origins:, allowed_ips:)`. + +### Security + +- Restriction failures never echo the configured allowlist back to the caller. +- Every restriction failure mode fails closed: a locked list plus an unreadable origin, an unresolvable client IP, or an unparseable stored entry refuses the request. + ## [0.4.3] - 2026-08-24 - Republish of 0.4.2 with a clean package: the 0.4.2 gem shipped carrying a stray 200 KB `api_keys-0.4.1.gem` blob at its root (committed by accident during the release, harmless but dead weight). No code changes. Prefer this over 0.4.2. diff --git a/README.md b/README.md index 63d61ea..9da97f1 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,15 @@ rails db:migrate The generated migration is idempotent and uses a concurrent PostgreSQL index where supported. +To lock keys to specific web origins or IP addresses (see [Restrict where a key can be used](#restrict-where-a-key-can-be-used-origins-and-ips)), add the restrictions column: + +```bash +rails generate api_keys:add_restrictions +rails db:migrate +``` + +New installations get this column from the start, so this is only for upgrades. + ## Quick Start Just add `has_api_keys` to your desired model. For example, if you want your `User` records to have API keys, you'd have: @@ -152,6 +161,7 @@ Once configured, your users can: - set expiration dates - attach scopes / permissions to individual keys - add and edit the key names +- lock a key to specific web origins or IP addresses - revoke instantly - see the status of all their keys @@ -354,6 +364,10 @@ Filter keys by type and status: @org.api_keys.expired # Past expiration date @org.api_keys.revoked # Manually revoked +# By request restrictions +@org.api_keys.restricted # Locked to specific origins and/or IPs +@org.api_keys.unrestricted # Usable from anywhere + # Chain them @org.api_keys.publishable.active @org.api_keys.secret.inactive.order(created_at: :desc) @@ -382,7 +396,9 @@ current_org.can_create_api_key?(key_type: :publishable) expires_at: 30.days.from_now, # Explicit date expires_at_preset: "30_days", # OR use preset (takes precedence) environment: :live, # Defaults to current_environment - metadata: { team: "backend" } # Optional JSON metadata + metadata: { team: "backend" }, # Optional JSON metadata + allowed_origins: "example.com", # Optional: lock to web origins + allowed_ips: "10.0.0.0/8" # Optional: lock to IP addresses ) ``` @@ -418,6 +434,12 @@ Methods available on `ApiKeys::ApiKey` instances: @api_key.scopes # => ["read", "write"] @api_key.allows_scope?("read") # => true +# Request restrictions (where the key may be used from) +@api_key.allowed_origins # => ["example.com", "*.example.com"] +@api_key.allowed_ips # => ["203.0.113.7", "10.0.0.0/8"] +@api_key.restricted? # => true if either list has entries +@api_key.restrictions # => ApiKeys::Restrictions value object + # Metadata @api_key.name # => "Production Server" @api_key.created_at @@ -1075,6 +1097,8 @@ When you distribute software with an embedded API key, that key can potentially - **Secret keys** (`sk_test_...`, `sk_live_...`): Sensitive server-side credentials whose exact access depends on their scopes. They can be revoked anytime. +Publishable keys pair naturally with [request restrictions](#restrict-where-a-key-can-be-used-origins-and-ips): lock them to your customers' domains so a lifted key is useless on anyone else's site. + ### Configuration Enable key types in your initializer: @@ -1087,12 +1111,15 @@ ApiKeys.configure do |config| prefix: "pk", # Token prefix → pk_test_, pk_live_ permissions: %w[read validate], # Scope ceiling (max permissions allowed) revocable: false, # Cannot be revoked or deleted - limit: 1 # Max 1 per owner per environment + limit: 1, # Max 1 per owner per environment + restrictions: [:origins] # May be locked to domains, not to IPs }, secret: { prefix: "sk", - permissions: :all # No scope restrictions + permissions: :all, # No scope restrictions + restrictions: [:ips] # May be locked to IPs, not to domains # revocable defaults to true, limit defaults to nil (unlimited) + # restrictions defaults to both kinds allowed } } @@ -1279,6 +1306,101 @@ rails db:migrate Existing keys without `key_type`/`environment` continue to work normally (backwards compatible). +## Restrict where a key can be used (origins and IPs) + +A publishable key lives in your customer's page source, in plain sight. Without any control over *where* it can be used, anyone can lift it and use it from their own website. Lock the key to your customers' domains and a stolen key is useless anywhere else. The same applies to secret keys on the server side: lock them to the addresses your customer's servers actually call from. + +Any key can carry two lists: + +- **Allowed web origins**: bare hosts, matched against the browser's `Origin` header (falling back to `Referer`). Supports `*.` subdomain wildcards. +- **Allowed IP addresses**: single IPv4/IPv6 addresses or CIDR ranges. + +Both are enforced inside the gem, on every authenticated request, for every key. There is no controller to opt in and no endpoint that can forget. + +### Upgrading an existing installation + +New installations already have the column. To add it to an existing app: + +```bash +rails generate api_keys:add_restrictions +rails db:migrate +``` + +### Usage + +```ruby +# At creation time +key = user.create_api_key!( + name: "Widget key", + key_type: :publishable, + allowed_origins: "example.com, *.example.com" +) + +# Or any time after: raw strings are parsed and normalized for you +key.allowed_ips = "203.0.113.7, 10.0.0.0/8" +key.save! + +key.allowed_origins # => ["example.com", "*.example.com"] +key.allowed_ips # => ["203.0.113.7", "10.0.0.0/8"] +key.restricted? # => true +``` + +Origin input is deliberately forgiving: full URLs, trailing slashes, ports, commas, and newlines are all accepted and reduced to bare lowercase hosts. `https://Shop.example/` becomes `shop.example`. Your dashboard never needs its own parser. + +### Semantics + +| Rule | Behavior | +|---|---| +| Within one list | **OR** — any entry that matches admits the request | +| Across both lists | **AND** — every list that has entries must pass | +| Empty (or absent) lists | **Unrestricted** — presence is the toggle, so existing keys are unaffected | +| `example.com` | Matches that exact host. Case-insensitive, port-blind, scheme-blind | +| `*.example.com` | Matches `a.example.com` and `a.b.example.com`, but **not** the apex `example.com`. List both to cover both | +| `*` alone | Invalid. An empty list already means "anywhere" | +| IP entries | `203.0.113.7` matches exactly; `10.0.0.0/8` and `2001:db8::/32` match their whole range | +| No readable origin on an origins-locked key | **Refused.** Every failure mode fails closed | +| Refusal response | `403 Forbidden` with `origin_not_allowed` or `ip_not_allowed` | + +An origins-locked key is therefore unusable from origin-less server code, which is exactly the point of locking a browser key. + +### Per-key-type restriction ceilings + +Key types can cap which kinds of restrictions their keys may carry, the same way `permissions:` caps scopes: + +```ruby +config.key_types = { + publishable: { prefix: "pk", permissions: %w[read], revocable: false, public: true, + restrictions: [:origins] }, # Browser keys lock to domains + secret: { prefix: "sk", permissions: :all, + restrictions: [:ips] } # Server keys lock to addresses +} +``` + +Omitting `restrictions:` allows both kinds. `restrictions: []` forbids restrictions for that type. A key carrying a kind its type forbids fails validation. + +### Resolving the client IP + +IP checks use `request.remote_ip`, which honors Rails' `config.action_dispatch.trusted_proxies`. If you sit behind a CDN, either configure trusted proxies or tell the gem how to find the real address: + +```ruby +# config/initializers/api_keys.rb +config.client_ip_resolver = ->(request) do + request.headers["CF-Connecting-IP"].presence || request.remote_ip +end +``` + +### Dashboard + +The mounted dashboard renders an "Allowed web origins" and an "Allowed IP addresses" field on the key form (only for the kinds the key's type permits), and a **Restricted** badge next to keys that carry either. Restriction edits stay available on non-revocable keys on purpose: tightening the allowlist is the one control the owner of an unrevocable public key still has. + +### Security notes + +- `Origin` and `Referer` are **browser-enforced** headers. They are trustworthy coming from a real browser and trivially forged by `curl`. Origin restrictions are a browser-context control: they stop a lifted public key from working on someone else's *website*. They are not secrecy. Pair them with keys that cannot spend anything dangerous. +- IP restrictions inherit the truthfulness of `request.remote_ip`. Behind a proxy or CDN, configure `trusted_proxies` or `client_ip_resolver`, or the address you match against is your proxy's. +- Everything fails closed: a locked list plus an unreadable request context is a refusal, never a pass. +- Refusals never echo the configured allowlist back to the caller. Reflecting your domains to an unauthenticated attacker would be a reconnaissance gift. If you want a more explicit message, override it through i18n (`api_keys.errors.origin_not_allowed`). +- Restriction checks read the current database row on every request, cache or no cache. Tightening the origins of a leaked publishable key takes effect on the very next call. + ## Enterprise-ready by design The `api_keys` gem ships with: diff --git a/lib/api_keys/version.rb b/lib/api_keys/version.rb index 1fbb78c..32975a1 100644 --- a/lib/api_keys/version.rb +++ b/lib/api_keys/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module ApiKeys - VERSION = "0.4.3" + VERSION = "0.5.0" end From c959f41bbf26b8fc6b648001b48891e741329188 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:11:43 +0100 Subject: [PATCH 05/11] Cover the value object's last defensive branches Three tests for paths the matrix left untouched: a headers object that raises when read (extract_origin_host must answer nil, not propagate), a scalar where a list was expected, and the equality/hash contract that lets a Restrictions instance serve as a hash key. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k --- test/restrictions_test.rb | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/restrictions_test.rb b/test/restrictions_test.rb index 7e5085e..6b23732 100644 --- a/test/restrictions_test.rb +++ b/test/restrictions_test.rb @@ -322,6 +322,22 @@ def restrictions(origins: [], ips: []) refute wrapped.origin_allowed?("42"), "a non-string entry can never match a host" end + test "wrap coerces a scalar list value into a single entry" do + assert_equal [42], ApiKeys::Restrictions.wrap("origins" => 42).origins + assert_empty ApiKeys::Restrictions.normalize_origins(42), "a scalar is not host-like, so nothing survives" + assert_empty ApiKeys::Restrictions.normalize_ips(42) + end + + test "equal restrictions hash alike, so they work as hash keys" do + counts = Hash.new(0) + counts[restrictions(origins: ["a.com"])] += 1 + counts[restrictions(origins: ["a.com"])] += 1 + counts[restrictions(origins: ["b.com"])] += 1 + + assert_equal 2, counts.size + assert_equal 2, counts[restrictions(origins: ["a.com"])] + end + test "equality and inspect never leak beyond the two lists" do assert_equal restrictions(origins: ["a.com"]), restrictions(origins: ["a.com"]) refute_equal restrictions(origins: ["a.com"]), restrictions(origins: ["b.com"]) @@ -368,4 +384,13 @@ def restrictions(origins: [], ips: []) assert_nil ApiKeys::Restrictions.extract_origin_host(Object.new) assert_nil ApiKeys::Restrictions.extract_origin_host(FakeRequest.new(Object.new)) end + + test "extract_origin_host survives headers that raise when read" do + exploding_headers = Object.new + def exploding_headers.[](_name) + raise IOError, "headers unavailable" + end + + assert_nil ApiKeys::Restrictions.extract_origin_host(FakeRequest.new(exploding_headers)) + end end From 184adc9a2a29b6e913551bfdd0f3e7c07382aa46 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:14:27 +0100 Subject: [PATCH 06/11] Render the dashboard's restriction fields under test The key form and the badge partial had no view-level coverage, so an ERB mistake in either could have shipped. These tests render new and edit, submit create and update through the controller, and assert what the user sees: the two fields appear, an existing key's list comes back in the field, a submitted raw string is normalized on the way in, a malformed entry is refused without overwriting what was there, and the "Restricted" badge shows up for restricted keys only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k --- test/controllers/keys_controller_test.rb | 68 ++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/test/controllers/keys_controller_test.rb b/test/controllers/keys_controller_test.rb index b547d8f..c1231b3 100644 --- a/test/controllers/keys_controller_test.rb +++ b/test/controllers/keys_controller_test.rb @@ -189,6 +189,74 @@ def setup assert_match(//i, response.body) end + test "the new key form offers the request restriction fields" do + get :new + + assert_response :success + assert_includes response.body, "Allowed web origins" + assert_includes response.body, "api_key[allowed_origins]" + assert_includes response.body, "Allowed IP addresses" + assert_includes response.body, "api_key[allowed_ips]" + end + + test "the edit form shows a key's current restrictions" do + key = @user.create_api_key!(name: "Widget", allowed_origins: "example.com, *.example.com") + + get :edit, params: { id: key.id } + + assert_response :success + assert_includes response.body, "example.com, *.example.com" + end + + test "create locks the new key to the submitted origins and addresses" do + post :create, params: { api_key: { name: "Widget Key", allowed_origins: "https://Example.com/, *.example.com", + allowed_ips: "10.0.0.0/8" } } + + key = @user.api_keys.order(:created_at).last + assert_redirected_to key_path(key) + assert_equal ["example.com", "*.example.com"], key.allowed_origins + assert_equal ["10.0.0.0/8"], key.allowed_ips + end + + test "update can tighten and clear a key's restrictions" do + key = @user.create_api_key!(name: "Widget", allowed_origins: "example.com") + + patch :update, params: { id: key.id, api_key: { name: "Widget", allowed_origins: "shop.example.com" } } + + assert_redirected_to keys_path + assert_equal ["shop.example.com"], key.reload.allowed_origins + + patch :update, params: { id: key.id, api_key: { name: "Widget", allowed_origins: "" } } + + refute key.reload.restricted? + end + + test "update rejects a malformed restriction entry without saving it" do + key = @user.create_api_key!(name: "Widget", allowed_origins: "example.com") + + patch :update, params: { id: key.id, api_key: { name: "Widget", allowed_origins: "*" } } + + assert_response :unprocessable_entity + assert_includes flash[:alert], "bare hosts" + assert_equal ["example.com"], key.reload.allowed_origins + end + + test "the restricted badge appears only for keys that carry restrictions" do + @user.create_api_key!(name: "Locked", allowed_origins: "example.com") + + get :index + + assert_response :success + assert_includes response.body, "api-keys-badge api-keys-badge-restricted" + + ApiKeys::ApiKey.delete_all + @user.create_api_key!(name: "Open") + + get :index + + refute_includes response.body, "api-keys-badge api-keys-badge-restricted" + end + test "malformed create payloads return bad request without entering error rendering" do post :create, params: { api_key: "not-an-object" } From fb1d893a7b7d7d54b20cd77a81ac1f2597f0452a Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:39:58 +0100 Subject: [PATCH 07/11] Validation message without backticks: hosts shown by example The origins message surfaces verbatim in host-app form errors, where backtick markup reads as noise. example.com or *.example.com says the same thing by showing instead of describing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k --- lib/api_keys/models/api_key.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/api_keys/models/api_key.rb b/lib/api_keys/models/api_key.rb index aa542e8..c08996f 100644 --- a/lib/api_keys/models/api_key.rb +++ b/lib/api_keys/models/api_key.rb @@ -581,7 +581,7 @@ def validate_restriction_list(kind) return if entries.all? { |entry| yield(entry) } message = if kind == :origins - "origins must be bare hosts, optionally prefixed with a `*.` subdomain wildcard" + "origins must be bare hosts like example.com or *.example.com" else "ips must be valid IPv4/IPv6 addresses or CIDR ranges" end From 8d83c30541c3405df02942a1ce3da134a7d8bb6b Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:16:36 +0100 Subject: [PATCH 08/11] A policy refusal names the key it refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The after_authentication callback context carried api_key_id on success and on scope refusals, but not on environment or restriction refusals: Result.failure dropped the key even when the authenticator had already identified it. From the outside, a key under a misconfigured origin lock was indistinguishable from a key nobody ever tried — last_used_at stays nil (stats only run on success, deliberately, so refused traffic cannot masquerade as integration), and the refusal callback was anonymous, so a host app could not even count refusals per key. Failure results from every identified-but-refused path (environment isolation in all its failure modes, origin and IP restrictions) now carry the key; the callback context picks it up unchanged. Lookup failures still have no key to name. Fail-once verified: the new attribution test fails against the previous authenticator. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k --- CHANGELOG.md | 1 + README.md | 2 +- lib/api_keys/services/authenticator.rb | 27 ++++++++++++++++++-------- test/request_restrictions_test.rb | 22 +++++++++++++++++++++ 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4baffee..c969361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - `rails generate api_keys:add_restrictions` for existing installations; new installs create the column from the start. - Dashboard: origin and IP fields on the key form (shown per the key type's ceiling) and a "Restricted" badge. Restriction edits stay available on non-revocable keys, which is the one control the owner of an unrevocable public key has. - Model surface: `restrictions`, `restricted?`, `allowed_origins`/`allowed_ips` readers and raw-string writers, `restricted`/`unrestricted` scopes, and `create_api_key!(restrictions:, allowed_origins:, allowed_ips:)`. +- Refusal attribution: when an identified key is refused by policy (environment isolation, origin or IP restrictions), the `after_authentication` callback context now carries its `api_key_id`, as scope refusals always did. A key under a misconfigured lock no longer looks identical to a key nobody ever tried. ### Security diff --git a/README.md b/README.md index 9da97f1..f85e129 100644 --- a/README.md +++ b/README.md @@ -1064,7 +1064,7 @@ end This is especially useful if you want to build custom monitoring, usage tracking or auditing systems on top of the `api_keys` gem. -The `before_authentication` context contains `request_uuid`. The `after_authentication` context contains `success`, `error_code`, `api_key_id`, and, when scopes were requested, `required_scope_check`. Jobs are asynchronous, so “before” means it is enqueued before verification; queue execution order is not guaranteed. Configure a persistent Active Job backend and the callback queue appropriate for your application. +The `before_authentication` context contains `request_uuid`. The `after_authentication` context contains `success`, `error_code`, `api_key_id`, and, when scopes were requested, `required_scope_check`. `api_key_id` is present on success and on every refusal where the key was identified but a policy said no (missing scope, environment isolation, origin or IP restrictions), so refused traffic stays attributable to the key that sent it; only lookup failures leave it `nil`. Jobs are asynchronous, so “before” means it is enqueued before verification; queue execution order is not guaranteed. Configure a persistent Active Job backend and the callback queue appropriate for your application. The downside of this, of course, is that callbacks will only work if you have a valid, well-configured Active Job backend for your Rails app, like Sidekiq or [`solid_queue`](https://github.com/rails/solid_queue/), which comes by default in Rails 8. If Active Job is not well configured, well, your callbacks just won't get executed. diff --git a/lib/api_keys/services/authenticator.rb b/lib/api_keys/services/authenticator.rb index b513cd9..c7e9cd5 100644 --- a/lib/api_keys/services/authenticator.rb +++ b/lib/api_keys/services/authenticator.rb @@ -26,8 +26,13 @@ def self.success(api_key) new(success?: true, api_key: api_key) end - def self.failure(error_code:, message:) - new(success?: false, error_code: error_code, message: message) + # `api_key` is present when the key WAS identified and a policy check + # refused it (environment isolation, request restrictions): the + # after_authentication callback then reports WHICH key was refused, + # exactly as it already does for scope refusals. Lookup failures have + # no key to name, so they leave it nil. + def self.failure(error_code:, message:, api_key: nil) + new(success?: false, error_code: error_code, message: message, api_key: api_key) end # Do not delegate to Struct's default inspection: it recursively inspects @@ -392,7 +397,8 @@ def self.check_environment_isolation(api_key, config) if key_env.blank? return Result.failure( error_code: :environment_misconfigured, - message: "API key environment could not be verified" + message: "API key environment could not be verified", + api_key: api_key ) end @@ -404,7 +410,8 @@ def self.check_environment_isolation(api_key, config) log_warn "[ApiKeys Security] Current environment resolution failed (#{error.class})." return Result.failure( error_code: :environment_misconfigured, - message: "API key environment could not be verified" + message: "API key environment could not be verified", + api_key: api_key ) end @@ -418,7 +425,8 @@ def self.check_environment_isolation(api_key, config) log_warn "[ApiKeys Security] Strict environment isolation is enabled, but current_environment resolved to blank." return Result.failure( error_code: :environment_misconfigured, - message: "API key environment could not be verified" + message: "API key environment could not be verified", + api_key: api_key ) end @@ -426,7 +434,8 @@ def self.check_environment_isolation(api_key, config) log_debug "[ApiKeys Auth] Environment mismatch for key ID #{api_key.id}." return Result.failure( error_code: :environment_mismatch, - message: "API key cannot be used in this environment" + message: "API key cannot be used in this environment", + api_key: api_key ) end @@ -452,7 +461,8 @@ def self.check_request_restrictions(api_key, request, config) log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because the request origin is not allowed." return Result.failure( error_code: :origin_not_allowed, - message: "This API key is restricted to specific web origins, and this request's origin is not allowed" + message: "This API key is restricted to specific web origins, and this request's origin is not allowed", + api_key: api_key ) end end @@ -462,7 +472,8 @@ def self.check_request_restrictions(api_key, request, config) log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because the request IP address is not allowed." return Result.failure( error_code: :ip_not_allowed, - message: "This API key is restricted to specific IP addresses, and this request's address is not allowed" + message: "This API key is restricted to specific IP addresses, and this request's address is not allowed", + api_key: api_key ) end end diff --git a/test/request_restrictions_test.rb b/test/request_restrictions_test.rb index 1c1ece9..a2dae26 100644 --- a/test/request_restrictions_test.rb +++ b/test/request_restrictions_test.rb @@ -255,6 +255,28 @@ def render_authentication(token:, **request_options) assert_equal :environment_mismatch, result.error_code end + test "a policy refusal names the key it refused" do + # The key WAS identified; a policy said no. The result carries the key so + # the after_authentication callback can attribute the refusal — a locked + # key under a misconfigured origin must not look identical to a key + # nobody ever tried. Scope refusals already behave this way. + origin_key, origin_token = create_key(allowed_origins: "example.com") + ip_key, ip_token = create_key(allowed_ips: "203.0.113.0/24") + + origin_result = authenticate(token: origin_token, origin: "https://freeloader.example") + ip_result = authenticate(token: ip_token, remote_ip: "198.51.100.7") + + assert_equal origin_key.id, origin_result.api_key&.id + assert_equal ip_key.id, ip_result.api_key&.id + end + + test "a lookup failure has no key to name" do + result = authenticate(token: "vdb_sk_never_minted") + + refute result.success? + assert_nil result.api_key + end + # ============================================================================= # Controller concern: status codes and messages # ============================================================================= From e7bc7012a538c80c4b9a47c391bfe4c28dcfe164 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:57:11 +0100 Subject: [PATCH 09/11] Harden request restrictions and public key lifecycle --- .simplecov | 4 +- CHANGELOG.md | 10 +- README.md | 36 ++--- app/controllers/api_keys/keys_controller.rb | 30 ++-- app/views/api_keys/keys/_form.html.erb | 45 +++++- .../keys/_restriction_fields.html.erb | 4 +- lib/api_keys/authentication.rb | 2 +- lib/api_keys/configuration.rb | 19 ++- lib/api_keys/models/api_key.rb | 77 ++++++---- lib/api_keys/models/concerns/has_api_keys.rb | 4 + lib/api_keys/restrictions.rb | 139 +++++++++++------- lib/api_keys/services/authenticator.rb | 17 ++- .../api_keys/add_key_types_generator.rb | 2 +- .../api_keys/add_restrictions_generator.rb | 6 +- .../add_restrictions_to_api_keys.rb.erb | 25 +++- .../templates/create_api_keys_table.rb.erb | 18 +++ .../api_keys/templates/initializer.rb | 16 +- test/configuration_test.rb | 6 +- test/controllers/keys_controller_test.rb | 44 ++++++ test/dummy/Gemfile.docker.lock | 16 +- test/form_builder_extensions_test.rb | 95 ++++++++++++ .../add_restrictions_generator_test.rb | 21 +++ test/generators/install_generator_test.rb | 2 + test/key_types_test.rb | 62 +++++--- test/request_restrictions_test.rb | 106 ++++++++++++- test/restrictions_test.rb | 52 +++++-- test/services/authenticator_test.rb | 7 +- 27 files changed, 671 insertions(+), 194 deletions(-) create mode 100644 test/form_builder_extensions_test.rb diff --git a/.simplecov b/.simplecov index b09a9bb..d9c3627 100644 --- a/.simplecov +++ b/.simplecov @@ -9,10 +9,10 @@ SimpleCov.configure do formatter SimpleCov::Formatter::SimpleFormatter # Track coverage for the lib directory (gem source code) - skip "/test/" + add_filter "/test/" # Track the lib and app directories - cover "{lib,app}/**/*.rb" + track_files "{lib,app}/**/*.rb" # Enable branch coverage for more detailed metrics enable_coverage :branch diff --git a/CHANGELOG.md b/CHANGELOG.md index c969361..edef074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,18 @@ - Per-key-type restriction ceilings via `key_types[...][:restrictions]`, mirroring the way `permissions:` caps scopes. Omitted allows both kinds; `[]` forbids restrictions for that type. - `config.client_ip_resolver` (defaults to `request.remote_ip`, which honors Rails' trusted proxies). - `rails generate api_keys:add_restrictions` for existing installations; new installs create the column from the start. -- Dashboard: origin and IP fields on the key form (shown per the key type's ceiling) and a "Restricted" badge. Restriction edits stay available on non-revocable keys, which is the one control the owner of an unrevocable public key has. +- Dashboard: origin and IP fields on the key form (shown dynamically per the selected key type's ceiling), expiration only for expirable key types, preserved form values after errors, and a "Restricted" badge. - Model surface: `restrictions`, `restricted?`, `allowed_origins`/`allowed_ips` readers and raw-string writers, `restricted`/`unrestricted` scopes, and `create_api_key!(restrictions:, allowed_origins:, allowed_ips:)`. -- Refusal attribution: when an identified key is refused by policy (environment isolation, origin or IP restrictions), the `after_authentication` callback context now carries its `api_key_id`, as scope refusals always did. A key under a misconfigured lock no longer looks identical to a key nobody ever tried. +- Refusal attribution: every failure after a key has been identified (revoked, expired, type/environment configuration, isolation, and request restrictions) carries its `api_key_id`; lookup failures do not. + +### Changed + +- `public: true` is now independent of `revocable:`. Public key types remain subject to a finite non-empty permission ceiling, but can use the normal rotation, revocation, deletion, and expiration lifecycle. ### Security - Restriction failures never echo the configured allowlist back to the caller. -- Every restriction failure mode fails closed: a locked list plus an unreadable origin, an unresolvable client IP, or an unparseable stored entry refuses the request. +- Every restriction failure mode fails closed: a locked list plus an unreadable origin, an unresolvable client IP, an unknown kind, a scalar policy, or an unparseable stored entry refuses the request. Generated migrations add a database check that the policy is a JSON object where the adapter supports it. ## [0.4.3] - 2026-08-24 diff --git a/README.md b/README.md index f85e129..c59daaf 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ > [!TIP] > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=api_keys)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=api_keys)! -`api_keys` makes it simple to add secure, production-ready API key authentication to any Rails app. Generate keys, restrict scopes, auto-expire tokens, revoke tokens, and gate endpoints. It also provides a self-serve dashboard for users to issue and manage their own API keys. Secret tokens are hashed and shown only once. Plaintext is stored only for a key type that you explicitly mark as public, non-revocable, and limited to a finite permission set. +`api_keys` makes it simple to add secure, production-ready API key authentication to any Rails app. Generate keys, restrict scopes, auto-expire tokens, revoke tokens, and gate endpoints. It also provides a self-serve dashboard for users to issue and manage their own API keys. Secret tokens are hashed and shown only once. Plaintext is stored only for a key type that you explicitly mark as public and limit to a finite permission set. [ 🟢 [Live interactive demo website](https://apikeys.rameerez.com) ] @@ -1093,7 +1093,7 @@ For applications that distribute software with embedded API keys (desktop apps, When you distribute software with an embedded API key, that key can potentially be extracted by malicious users. Key types solve this by letting you create: -- **Publishable keys** (`pk_test_...`, `pk_live_...`): Intentionally exposed identifiers. Embed them only when every configured permission is safe for an untrusted public client; assume anyone can extract and abuse them. They cannot be revoked individually. +- **Publishable keys** (`pk_test_...`, `pk_live_...`): Intentionally exposed identifiers. Embed them only when every configured permission is safe for an untrusted public client; assume anyone can extract and abuse them. They may be revoked, rotated, and expired unless you explicitly configure `revocable: false`. - **Secret keys** (`sk_test_...`, `sk_live_...`): Sensitive server-side credentials whose exact access depends on their scopes. They can be revoked anytime. @@ -1110,7 +1110,7 @@ ApiKeys.configure do |config| publishable: { prefix: "pk", # Token prefix → pk_test_, pk_live_ permissions: %w[read validate], # Scope ceiling (max permissions allowed) - revocable: false, # Cannot be revoked or deleted + public: true, # Store token so it remains viewable limit: 1, # Max 1 per owner per environment restrictions: [:origins] # May be locked to domains, not to IPs }, @@ -1196,11 +1196,11 @@ Deleting the owning record still cascades deletion to all of its API keys, inclu ### Public Keys (Viewable Tokens) -#### The Problem: Non-Revocable Key Lockout +#### Why Public Tokens Are Viewable -Non-revocable keys create a potential UX nightmare: if a user creates a publishable key, doesn't copy it immediately, and closes the page—they're locked out. The token is gone forever (we only store the hash), and they can't delete the key to create a new one (it's non-revocable). They're stuck with a useless key slot they can never use or remove. +Ordinary secret keys cannot be recovered after their one-time display. That is the right default for confidential credentials, but it provides no secrecy benefit for a token deliberately embedded in public client code. It can also lock an owner out when a non-revocable public key is combined with `limit: 1`. -This is especially problematic when combined with `limit: 1`, which restricts users to a single publishable key per environment. A user who loses their token would be permanently locked out of creating publishable keys. +Public keys solve that display problem independently of lifecycle policy: they can be revocable (the default) or explicitly non-revocable. #### The Solution: Storing Public Keys @@ -1213,7 +1213,6 @@ config.key_types = { publishable: { prefix: "pk", permissions: %w[read validate], - revocable: false, public: true, # Store token for later viewing limit: 1 }, @@ -1228,16 +1227,15 @@ config.key_types = { #### Security constraints > [!IMPORTANT] -> The `public` option only works when all of these conditions are met: +> The `public` option only works when both of these conditions are met: > - `public: true` is set in the key type configuration -> - `revocable: false` is set (non-revocable keys only) > - `permissions` is a finite, non-empty array (never `:all`) These checks are deliberate safety measures: -1. **Configuration is validated early** — Public types must explicitly be non-revocable and have a finite, non-empty permission ceiling. +1. **Configuration is validated early** — Public types must have a finite, non-empty permission ceiling. -2. **Revocable keys are NEVER stored** — If a key can be revoked, users can always delete it and create a new one. There's no lockout risk, so no need to store the token. +2. **Revocability is independent** — Public keys may remain viewable while also being revocable and expirable. `revocable: false` is available only when a permanently deployed identifier is truly required. 3. **Your application defines what is public** — The gem cannot infer the business impact of a permission name. Only mark a type public when every permission in its ceiling is safe for an unauthenticated client to possess. @@ -1359,7 +1357,7 @@ Origin input is deliberately forgiving: full URLs, trailing slashes, ports, comm | `*` alone | Invalid. An empty list already means "anywhere" | | IP entries | `203.0.113.7` matches exactly; `10.0.0.0/8` and `2001:db8::/32` match their whole range | | No readable origin on an origins-locked key | **Refused.** Every failure mode fails closed | -| Refusal response | `403 Forbidden` with `origin_not_allowed` or `ip_not_allowed` | +| Refusal response | `403 Forbidden` with `origin_not_allowed`, `ip_not_allowed`, or `restriction_misconfigured` for damaged policy data | An origins-locked key is therefore unusable from origin-less server code, which is exactly the point of locking a browser key. @@ -1369,7 +1367,7 @@ Key types can cap which kinds of restrictions their keys may carry, the same way ```ruby config.key_types = { - publishable: { prefix: "pk", permissions: %w[read], revocable: false, public: true, + publishable: { prefix: "pk", permissions: %w[read], public: true, restrictions: [:origins] }, # Browser keys lock to domains secret: { prefix: "sk", permissions: :all, restrictions: [:ips] } # Server keys lock to addresses @@ -1380,24 +1378,26 @@ Omitting `restrictions:` allows both kinds. `restrictions: []` forbids restricti ### Resolving the client IP -IP checks use `request.remote_ip`, which honors Rails' `config.action_dispatch.trusted_proxies`. If you sit behind a CDN, either configure trusted proxies or tell the gem how to find the real address: +IP checks use `request.remote_ip`, which honors Rails' `config.action_dispatch.trusted_proxies`. Configure that Rails setting for your reverse proxy or CDN and keep the default resolver whenever possible. + +Only read a vendor header directly when your network ingress rejects requests that did not come through that vendor. Otherwise a client can send the same header and choose the address your allowlist sees: ```ruby # config/initializers/api_keys.rb config.client_ip_resolver = ->(request) do - request.headers["CF-Connecting-IP"].presence || request.remote_ip + request.headers.fetch("CF-Connecting-IP") end ``` ### Dashboard -The mounted dashboard renders an "Allowed web origins" and an "Allowed IP addresses" field on the key form (only for the kinds the key's type permits), and a **Restricted** badge next to keys that carry either. Restriction edits stay available on non-revocable keys on purpose: tightening the allowlist is the one control the owner of an unrevocable public key still has. +The mounted dashboard renders only the expiration and request-restriction fields supported by the selected key type, and a **Restricted** badge next to keys carrying a policy. Restriction edits remain available on non-revocable keys so an owner can still tighten an allowlist. ### Security notes - `Origin` and `Referer` are **browser-enforced** headers. They are trustworthy coming from a real browser and trivially forged by `curl`. Origin restrictions are a browser-context control: they stop a lifted public key from working on someone else's *website*. They are not secrecy. Pair them with keys that cannot spend anything dangerous. -- IP restrictions inherit the truthfulness of `request.remote_ip`. Behind a proxy or CDN, configure `trusted_proxies` or `client_ip_resolver`, or the address you match against is your proxy's. -- Everything fails closed: a locked list plus an unreadable request context is a refusal, never a pass. +- IP restrictions inherit the truthfulness of their resolver. Configure Rails' `trusted_proxies`; only trust a CDN-supplied header when direct access to the origin is blocked, or callers can spoof the address being checked. +- Everything fails closed: a locked list plus an unreadable request context, an unknown policy kind, or malformed stored policy data is a refusal, never a pass. - Refusals never echo the configured allowlist back to the caller. Reflecting your domains to an unauthenticated attacker would be a reconnaissance gift. If you want a more explicit message, override it through i18n (`api_keys.errors.origin_not_allowed`). - Restriction checks read the current database row on every request, cache or no cache. Tightening the origins of a leaked publishable key takes effect on the very next call. diff --git a/app/controllers/api_keys/keys_controller.rb b/app/controllers/api_keys/keys_controller.rb index 526218b..86fbc07 100644 --- a/app/controllers/api_keys/keys_controller.rb +++ b/app/controllers/api_keys/keys_controller.rb @@ -48,7 +48,7 @@ def show # GET /keys/new def new - @api_key = current_api_keys_owner.api_keys.build + @api_key = current_api_keys_owner.api_keys.build(key_type: ApiKeys.configuration.default_key_type) end # POST /keys @@ -76,6 +76,7 @@ def create rescue ActiveRecord::RecordInvalid => e # If create! fails due to validation (e.g., quota exceeded) @api_key = e.record # Get the invalid ApiKey instance + @api_key.expires_at_preset = submitted_params[:expires_at_preset] flash.now[:alert] = "Failed to create API key: #{e.record.errors.full_messages.join(', ')}" render :new, status: :unprocessable_entity rescue ArgumentError @@ -143,8 +144,8 @@ def api_key_params end # Only allow updating name, scopes, and request restrictions. - # Restriction edits stay available on non-revocable keys on purpose: they - # are the one control the owner of an unrevocable public key still has. + # Restriction edits stay available on non-revocable keys so an owner can + # still tighten the policy even when lifecycle operations are disabled. def api_key_update_params submitted = params.require(:api_key) raise ActionController::ParameterMissing, :api_key unless submitted.respond_to?(:permit) @@ -171,10 +172,17 @@ def parse_expiration(preset) end def rebuild_api_key_for_form(submitted_params) - current_api_keys_owner.api_keys.build( + api_key = current_api_keys_owner.api_keys.build( name: submitted_params[:name], - scopes: submitted_params[:scopes] + scopes: submitted_params[:scopes], + key_type: submitted_params[:key_type] ) + api_key.expires_at_preset = submitted_params[:expires_at_preset] + if ApiKeys::ApiKey.restrictions_column? + api_key.allowed_origins = submitted_params[:allowed_origins] unless submitted_params[:allowed_origins].nil? + api_key.allowed_ips = submitted_params[:allowed_ips] unless submitted_params[:allowed_ips].nil? + end + api_key end # Which restriction kinds the form may offer for a given key. @@ -184,16 +192,12 @@ def rebuild_api_key_for_form(submitted_params) # @param api_key [ApiKeys::ApiKey] # @return [Array] def api_keys_allowed_restriction_kinds(api_key) - return restriction_kinds_for(api_key.key_type_config) if api_key.key_type.present? + return api_key.allowed_restriction_kinds if api_key.persisted? return ApiKeys::Restrictions::KINDS.dup unless key_types_feature_enabled? - ApiKeys.configuration.key_types.flat_map { |_type, settings| restriction_kinds_for(settings) }.uniq - end - - def restriction_kinds_for(type_config) - return ApiKeys::Restrictions::KINDS.dup unless type_config.is_a?(Hash) && type_config.key?(:restrictions) - - Array(type_config[:restrictions]).map(&:to_sym) + ApiKeys.configuration.key_types.flat_map do |_type, settings| + ApiKeys::ApiKey.restriction_kinds_for(settings) + end.uniq end # Check if key types feature is enabled diff --git a/app/views/api_keys/keys/_form.html.erb b/app/views/api_keys/keys/_form.html.erb index 4161e55..78078bb 100644 --- a/app/views/api_keys/keys/_form.html.erb +++ b/app/views/api_keys/keys/_form.html.erb @@ -1,5 +1,6 @@ <%# Shared form for creating and editing API Keys %> -<%= form_with(model: [:keys, api_key], url: (api_key.persisted? ? key_path(api_key) : keys_path), local: true) do |form| %> +<%= form_with(model: [:keys, api_key], url: (api_key.persisted? ? key_path(api_key) : keys_path), + local: true, html: { id: "api-keys-key-form" }) do |form| %> <% if api_key.errors.any? %>
<%= pluralize(api_key.errors.count, "error") %> prohibited this API key from being saved: @@ -47,7 +48,7 @@ <%= form.text_field :name, placeholder: "e.g., myproject-production-key" %>
-
+
<%= form.label :expires_at_preset, "Expiration" %> <%= form.select :expires_at_preset, options_for_select([ @@ -57,7 +58,7 @@ ["60 days", "60_days"], ["90 days", "90_days"], ["365 days", "365_days"] # Common presets - ], api_key.expires_at.present? ? nil : "no_expiration"), # Default selection + ], api_key.expires_at_preset.presence || (api_key.expires_at.present? ? nil : "no_expiration")), # Default selection {}, # html options {} # data attributes %> @@ -131,9 +132,45 @@ <%= link_to "Cancel", keys_path %> <% else %>

Keep it safe

-

Your API key will only be shown once after creation. Your key cannot be recovered: copy it immediately and store it securely.

+

Secret API keys are only shown once after creation. Copy the new key immediately and store it securely.

<%= form.submit "Create API Key" %> <%= link_to "Cancel", keys_path %> <% end %>
<% end %> + +<% if !api_key.persisted? && ApiKeys.configuration.key_types.present? %> + <% key_type_policies = ApiKeys.configuration.key_types.to_h do |type, config| %> + <% [type.to_s, { + restrictions: ApiKeys::ApiKey.restriction_kinds_for(config).map(&:to_s), + expirable: ApiKeys::ApiKey.revocable_for(config) + }] %> + <% end %> + +<% end %> diff --git a/app/views/api_keys/keys/_restriction_fields.html.erb b/app/views/api_keys/keys/_restriction_fields.html.erb index adcaf9b..d2c05f9 100644 --- a/app/views/api_keys/keys/_restriction_fields.html.erb +++ b/app/views/api_keys/keys/_restriction_fields.html.erb @@ -6,7 +6,7 @@ <% allowed_kinds = api_keys_allowed_restriction_kinds(api_key) %> <% if allowed_kinds.include?(:origins) %> -
+
<%= form.label :allowed_origins, "Allowed web origins (optional)" %> <%= form.text_field :allowed_origins, value: api_key.allowed_origins.join(", "), @@ -19,7 +19,7 @@ <% end %> <% if allowed_kinds.include?(:ips) %> -
+
<%= form.label :allowed_ips, "Allowed IP addresses (optional)" %> <%= form.text_field :allowed_ips, value: api_key.allowed_ips.join(", "), diff --git a/lib/api_keys/authentication.rb b/lib/api_keys/authentication.rb index 0ace51a..3471a05 100644 --- a/lib/api_keys/authentication.rb +++ b/lib/api_keys/authentication.rb @@ -16,7 +16,7 @@ module Authentication # Failures where the credential is valid but the request context is refused. # The key itself is fine, so these answer 403 rather than 401 — the same # distinction `:missing_scope` already makes. - FORBIDDEN_ERROR_CODES = %i[origin_not_allowed ip_not_allowed].freeze + FORBIDDEN_ERROR_CODES = %i[origin_not_allowed ip_not_allowed restriction_misconfigured].freeze included do # Helper methods to access the authenticated key and its owner diff --git a/lib/api_keys/configuration.rb b/lib/api_keys/configuration.rb index fbe5c1c..f9bde24 100644 --- a/lib/api_keys/configuration.rb +++ b/lib/api_keys/configuration.rb @@ -48,10 +48,11 @@ class Configuration # @return [#call] Callable receiving the request and returning the client # IP address used to evaluate a key's `allowed_ips` list. The default # honors Rails' trusted-proxy handling via `request.remote_ip`; behind a - # CDN, configure `config.action_dispatch.trusted_proxies` or supply your - # own resolver. + # CDN, configure `config.action_dispatch.trusted_proxies` whenever + # possible. A resolver that trusts a vendor header is safe only when + # network ingress rejects requests that bypass that vendor. # @example - # config.client_ip_resolver = ->(request) { request.headers["CF-Connecting-IP"].presence || request.remote_ip } + # config.client_ip_resolver = ->(request) { request.headers.fetch("CF-Connecting-IP") } attr_reader :client_ip_resolver # Tenant Resolution @@ -94,15 +95,16 @@ class Configuration # - :permissions [Array, :all] Scope ceiling for this type # - :revocable [Boolean] Whether keys can be revoked (default: true) # - :limit [Integer, nil] Max keys per owner per environment (nil = unlimited) - # - :public [Boolean] If true AND revocable: false, store plaintext token in - # metadata so it can be viewed again in dashboard. Use ONLY for publishable - # keys that are designed to be embedded in distributed apps. (default: false) + # - :public [Boolean] If true, store the plaintext token in metadata so it + # can be viewed again in the dashboard. Use ONLY for publishable keys + # designed to be embedded in distributed apps. Public types must have a + # finite, non-empty permissions list. (default: false) # - :restrictions [Array] Which request-restriction kinds keys of this # type may carry: any subset of [:origins, :ips]. Omitted means both are # allowed; `[]` forbids restrictions entirely for this type. # @example # config.key_types = { - # publishable: { prefix: "pk", permissions: %w[read], revocable: false, public: true, limit: 1, + # publishable: { prefix: "pk", permissions: %w[read], public: true, limit: 1, # restrictions: [:origins] }, # secret: { prefix: "sk", permissions: :all, restrictions: [:ips] } # } @@ -430,9 +432,6 @@ def validate_key_types!(key_types_hash) next unless type_config[:public] == true - unless type_config[:revocable] == false - raise ArgumentError, "Public key type '#{name}' must explicitly set revocable: false" - end unless permissions.is_a?(Array) && permissions.any? raise ArgumentError, "Public key type '#{name}' must have a finite, non-empty permissions list" end diff --git a/lib/api_keys/models/api_key.rb b/lib/api_keys/models/api_key.rb index c08996f..52c5950 100644 --- a/lib/api_keys/models/api_key.rb +++ b/lib/api_keys/models/api_key.rb @@ -15,8 +15,8 @@ class ApiKey < ActiveRecord::Base MAX_RESTRICTION_ENTRIES = 100 MAX_RESTRICTION_ENTRY_BYTESIZE = 255 RESTRICTIONS_COLUMN = "restrictions" - # Deliberately excludes `restrictions`: tightening the origins of a leaked - # publishable key is the one control the owner of a non-revocable key has. + # Deliberately excludes `restrictions`: owners must be able to tighten a + # request policy even when the key itself is non-revocable. IMMUTABLE_IDENTITY_ATTRIBUTES = %w[ token_digest digest_algorithm prefix last4 owner_type owner_id key_type environment ].freeze @@ -32,6 +32,7 @@ class ApiKey < ActiveRecord::Base # == Attributes & Serialization == # Expose the plaintext token only immediately after creation attr_reader :token + attr_accessor :expires_at_preset # JSON attributes (:scopes, :metadata) are defined in the engine initializer # using ActiveSupport.on_load(:active_record) to ensure DB connection is ready. @@ -68,8 +69,13 @@ def restrictions def restrictions=(value) ensure_restrictions_column! - normalized = if value.nil? || value.is_a?(Hash) || value.is_a?(ApiKeys::Restrictions) - ApiKeys::Restrictions.wrap(value).to_h + normalized = if value.nil? + {} + elsif value.is_a?(Hash) + wrapped = ApiKeys::Restrictions.wrap(value) + wrapped.malformed? ? value : wrapped.to_h + elsif value.is_a?(ApiKeys::Restrictions) + value.malformed? ? { "__malformed__" => true } : value.to_h else value end @@ -160,11 +166,15 @@ def restricted? # .publishable returns only keys with key_type: "publishable" # .secret returns keys that are NOT publishable (includes legacy keys with nil/blank key_type) scope :publishable, -> { where(key_type: "publishable") } - scope :secret, -> { where.not(key_type: "publishable") } + # SQL `!=` excludes NULL, so include pre-key-types rows explicitly. Those + # legacy credentials have always had secret-key capabilities. + scope :secret, -> { + where(key_type: nil).or(where.not(key_type: "publishable")) + } # Keys that carry request restrictions, and keys usable from anywhere. - scope :restricted, -> { where.not(restrictions: {}) } - scope :unrestricted, -> { where(restrictions: {}) } + scope :restricted, -> { where.not(restrictions: [nil, {}]) } + scope :unrestricted, -> { where(restrictions: [nil, {}]) } # === Usage Analytics Scopes === # These scopes help admin dashboards analyze API key usage patterns. @@ -264,9 +274,12 @@ def serializable_hash(options = nil) # Keys with a key_type check the configuration def revocable? return true if key_type.blank? - config = key_type_config - return false if config.nil? - config.fetch(:revocable, true) + self.class.revocable_for(key_type_config) + end + + # Non-revocable keys are permanent by design; other key types may expire. + def expirable? + revocable? end # Returns the configuration hash for this key's type @@ -283,17 +296,17 @@ def environment_config configured_pair&.last end - # Returns true if this key type is configured as public AND non-revocable. - # Only these keys have their plaintext token stored in metadata for later viewing. + # Returns true if this key type is explicitly configured as public. + # Only these keys have their plaintext token stored for later viewing. # This is used for publishable keys that are designed to be embedded in distributed apps. def public_key_type? return false if key_type.blank? config = key_type_config return false if config.nil? - config[:public] == true && config[:revocable] == false + config[:public] == true end - # Returns the stored plaintext token for public, non-revocable keys. + # Returns the stored plaintext token for public keys. # Returns nil for all other key types (the token is only available at creation time). # @return [String, nil] The full plaintext token, or nil if not stored def viewable_token @@ -378,6 +391,24 @@ def self.restrictions_column? false end + # Single source of truth for a key type's request-restriction ceiling. + # Omitting the setting allows every supported restriction kind. + def self.restriction_kinds_for(type_config) + return ApiKeys::Restrictions::KINDS.dup unless type_config.is_a?(Hash) && type_config.key?(:restrictions) + + Array(type_config[:restrictions]).map(&:to_sym) + end + + # Single source of truth for lifecycle policy in model and dashboard code. + def self.revocable_for(type_config) + type_config.is_a?(Hash) && type_config.fetch(:revocable, true) + end + + # @return [Array] Restriction kinds this key's type permits. + def allowed_restriction_kinds + self.class.restriction_kinds_for(key_type_config) + end + private def ensure_restrictions_column! @@ -468,10 +499,11 @@ def generate_token_and_digest self.expires_at = ApiKeys.configuration.expire_after.from_now end - # Store plaintext token in metadata for public, non-revocable keys. + # Store plaintext token in metadata for explicitly public keys. # This allows users to view the token again in the dashboard. # SECURITY: Only do this for keys explicitly configured as public: true - # AND revocable: false (e.g., publishable keys for distributed apps). + # Public key types must have a finite permission ceiling, but may be + # revocable and expirable like any other credential. if public_key_type? self.metadata = (self.metadata || {}).merge("token" => @token) end @@ -558,6 +590,8 @@ def restrictions_are_well_formed return end + errors.add(:restrictions, "must contain valid restriction data") if restrictions.malformed? && raw.keys.empty? + unknown_kinds = raw.keys.map(&:to_s) - ApiKeys::Restrictions::KIND_NAMES if unknown_kinds.any? errors.add(:restrictions, "contains unknown restriction kinds: #{unknown_kinds.sort.join(', ')}") @@ -601,21 +635,12 @@ def valid_restriction_entry_size?(entry) # Key types may declare a ceiling on the restriction kinds their keys carry, # mirroring the way `permissions:` caps scopes. def restrictions_respect_key_type - exceeded = restrictions.kinds - restriction_ceiling + exceeded = restrictions.kinds - allowed_restriction_kinds return if exceeded.empty? errors.add(:restrictions, "#{exceeded.sort.join(', ')} are not allowed for #{key_type} keys") end - # @return [Array] Restriction kinds this key's type permits. - # An omitted `restrictions:` setting permits every kind. - def restriction_ceiling - config = key_type_config - return ApiKeys::Restrictions::KINDS.dup unless config&.key?(:restrictions) - - Array(config[:restrictions]).map(&:to_sym) - end - def authentication_identity_is_immutable IMMUTABLE_IDENTITY_ATTRIBUTES.each do |attribute_name| next unless will_save_change_to_attribute?(attribute_name) diff --git a/lib/api_keys/models/concerns/has_api_keys.rb b/lib/api_keys/models/concerns/has_api_keys.rb index af0262f..e2b2386 100644 --- a/lib/api_keys/models/concerns/has_api_keys.rb +++ b/lib/api_keys/models/concerns/has_api_keys.rb @@ -382,6 +382,10 @@ def filter_scopes_by_permissions(scopes, key_type, config) def build_restrictions(restrictions, allowed_origins, allowed_ips) return nil if restrictions.nil? && allowed_origins.nil? && allowed_ips.nil? + unless restrictions.nil? || restrictions.is_a?(Hash) || restrictions.is_a?(ApiKeys::Restrictions) + raise ArgumentError, "restrictions must be a Hash or ApiKeys::Restrictions" + end + attributes = ApiKeys::Restrictions.wrap(restrictions).to_h attributes["origins"] = ApiKeys::Restrictions.normalize_origins(allowed_origins) unless allowed_origins.nil? attributes["ips"] = ApiKeys::Restrictions.normalize_ips(allowed_ips) unless allowed_ips.nil? diff --git a/lib/api_keys/restrictions.rb b/lib/api_keys/restrictions.rb index 0f1004d..ea4f850 100644 --- a/lib/api_keys/restrictions.rb +++ b/lib/api_keys/restrictions.rb @@ -2,7 +2,6 @@ require "ipaddr" require "uri" -require_relative "logging" module ApiKeys # Value object describing *where* an API key may be used from: a list of web @@ -22,12 +21,10 @@ module ApiKeys # - Every failure mode fails closed: a locked list plus an unreadable request # context refuses the request. # - # The object is immutable, has no Active Record dependency, and never raises - # on malformed input: `wrap` coerces whatever it is given, and the model's - # validations are what reject nonsense before it reaches the database. + # The object is immutable and has no Active Record dependency. Malformed + # persisted values are represented explicitly and deny authentication; model + # validations keep them out during ordinary writes. class Restrictions - include ApiKeys::Logging - # The restriction kinds this gem understands. Anything else stored in the # column is a validation error rather than a silently ignored key. KINDS = %i[origins ips].freeze @@ -39,9 +36,10 @@ class Restrictions # A bare host, optionally prefixed with a `*.` subdomain wildcard. # `*` alone is deliberately invalid: an empty list already means "anywhere". - ORIGIN_ENTRY_PATTERN = /\A(?:\*\.)?[a-z0-9_-]+(?:\.[a-z0-9_-]+)*\z/ + DNS_LABEL = /[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?/ + ORIGIN_ENTRY_PATTERN = /\A(?:\*\.)?#{DNS_LABEL}(?:\.#{DNS_LABEL})*\z/ - attr_reader :origins, :ips + attr_reader :origins, :ips, :extras class << self # Coerces anything into a Restrictions instance. Never raises. @@ -52,7 +50,7 @@ class << self def wrap(value) return value if value.is_a?(self) return none if value.nil? - return new(origins: [], ips: [], extras: {}) unless value.is_a?(Hash) + return new(origins: [], ips: [], extras: {}, malformed: true) unless value.is_a?(Hash) known, extras = value.partition { |key, _entries| KIND_NAMES.include?(key.to_s) } known = known.to_h { |key, entries| [key.to_s, entries] } @@ -62,6 +60,11 @@ def wrap(value) ips: coerce_list(known["ips"]), extras: extras.to_h ) + rescue StandardError + # Stored policy is untrusted input. Preserve the core invariant even if + # an exotic object raises while being coerced: malformed never means + # unrestricted. + new(origins: [], ips: [], extras: {}, malformed: true) end # The shared empty instance: no origins, no IPs, no restrictions at all. @@ -77,20 +80,26 @@ def none # normalize_origins("https://Shop.example/, *.app.example\n x") # # => ["shop.example", "*.app.example", "x"] # + # Non-string entries are preserved so validation can report malformed + # programmatic input instead of silently erasing a requested policy. # @param value [String, Array, nil] Raw user input. - # @return [Array] Normalized origin entries. + # @return [Array] Normalized origin entries. def normalize_origins(value) - tokenize(value).filter_map { |token| origin_host(token) }.uniq + tokenize(value).map do |token| + next token unless token.is_a?(String) + + origin_host(token) || token.strip.downcase + end.uniq end - # Forgiving parser for IP/CIDR input. Entries that stdlib IPAddr cannot - # parse at all are dropped; everything else is kept verbatim (lowercased) - # so validation, not the parser, is what reports a malformed range. + # Forgiving parser for IP/CIDR input. String entries are kept verbatim + # (lowercased) so validation, not the parser, reports malformed ranges. + # Non-string entries are likewise preserved for validation. # # @param value [String, Array, nil] Raw user input. # @return [Array] Normalized IP entries. def normalize_ips(value) - tokenize(value).map(&:downcase).uniq + tokenize(value).map { |entry| entry.is_a?(String) ? entry.downcase : entry }.uniq end # Extracts the host the browser claims the request came from: the Origin @@ -103,12 +112,15 @@ def extract_origin_host(request) headers = request.headers if request.respond_to?(:headers) return nil unless headers.respond_to?(:[]) - %w[Origin Referer].each do |header_name| - host = host_from_url(headers[header_name]) - return host if host + origin = headers["Origin"] + unless origin.nil? || (origin.is_a?(String) && origin.strip.empty?) + # Origin has precedence over Referer. A present-but-invalid Origin + # (including the browser's opaque `null` origin) must not be rescued + # by a friendlier Referer value. + return host_from_url(origin) end - nil + host_from_url(headers["Referer"]) rescue StandardError # A hostile or exotic request object must never take an endpoint down; # an unreadable origin is simply an origin that matches nothing. @@ -126,7 +138,7 @@ def tokenize(value) end entries.filter_map do |entry| - next unless entry.is_a?(String) + next entry unless entry.is_a?(String) trimmed = entry.strip trimmed unless trimmed.empty? @@ -146,6 +158,15 @@ def origin_host(entry) return host end + if (address = parse_ip(candidate)) && !candidate.include?("/") + return address.to_s.downcase + end + + if candidate.start_with?("[") + host = host_from_url("http://#{candidate}") + return host if host + end + host = candidate.split(%r{[/?#]}).first.to_s host = host.sub(/:\d*\z/, "") # Strip a trailing port ("example.com:3000"). host = host.delete_prefix("[").delete_suffix("]") # IPv6 literals. @@ -186,13 +207,20 @@ def coerce_list(value) trimmed = entry.strip.downcase trimmed unless trimmed.empty? + rescue ArgumentError + entry end end # Whether a stored origin entry is shaped like a host or `*.host`. # @api private def valid_origin_entry?(entry) - entry.is_a?(String) && entry.match?(ORIGIN_ENTRY_PATTERN) + return false unless entry.is_a?(String) + return true if !entry.include?("/") && parse_ip(entry) + + entry.bytesize <= 253 && entry.match?(ORIGIN_ENTRY_PATTERN) + rescue ArgumentError + false end # Whether a stored IP entry is a single address or a CIDR range. @@ -213,7 +241,7 @@ def parse_ip(value) address = IPAddr.new(trimmed) address.ipv6? && address.ipv4_mapped? ? address.native : address - rescue IPAddr::Error, ArgumentError + rescue IPAddr::Error nil end end @@ -221,21 +249,26 @@ def parse_ip(value) # @param origins [Array] Already-coerced origin entries. # @param ips [Array] Already-coerced IP entries. # @param extras [Hash] Unrecognized keys, preserved so validation sees them. - def initialize(origins: [], ips: [], extras: {}) - @origins = origins.freeze - @ips = ips.freeze - @extras = extras.freeze + # @param malformed [Boolean] Whether coercion itself found an invalid shape. + def initialize(origins: [], ips: [], extras: {}, malformed: false) + @origins = deep_copy(origins, freeze_copy: true) + @ips = deep_copy(ips, freeze_copy: true) + @extras = deep_copy(extras, freeze_copy: true) + @malformed = malformed || @extras.any? || + @origins.any? { |entry| !self.class.valid_origin_entry?(entry) } || + @ips.any? { |entry| !self.class.valid_ip_entry?(entry) } freeze end - # Unrecognized keys found in the stored hash. Their presence is a - # validation error; they are kept so the error can name them. - # @return [Hash] - attr_reader :extras + # Malformed data can only arrive through validation-bypassing writes or a + # damaged database. Authentication always denies it. + def malformed? + @malformed + end # @return [Boolean] true when this key may be used from anywhere. def unrestricted? - origins.empty? && ips.empty? + !malformed? && origins.empty? && ips.empty? end # @return [Boolean] true when at least one list is locked. @@ -253,9 +286,9 @@ def kinds # @return [Hash] def to_h hash = {} - hash["origins"] = origins.dup if origins.any? - hash["ips"] = ips.dup if ips.any? - hash.merge(extras) + hash["origins"] = deep_copy(origins) if origins.any? + hash["ips"] = deep_copy(ips) if ips.any? + hash.merge(deep_copy(extras)) end alias as_json to_h @@ -266,17 +299,19 @@ def to_h # @param ip [String, nil] Client IP address. # @return [Boolean] def allows?(origin_host: nil, ip: nil) - origin_allowed?(origin_host) && ip_allowed?(ip) + !malformed? && origin_allowed?(origin_host) && ip_allowed?(ip) end # @param host [String, nil] Bare host to check. # @return [Boolean] true when the origins list is empty or one entry matches. # A locked list plus a nil/blank host refuses: fail closed. def origin_allowed?(host) + return false if malformed? return true if origins.empty? candidate = host.to_s.strip.downcase return false if candidate.empty? + candidate = self.class.parse_ip(candidate)&.to_s || candidate origins.any? { |entry| origin_entry_matches?(entry, candidate) } end @@ -285,6 +320,7 @@ def origin_allowed?(host) # @return [Boolean] true when the IP list is empty or one entry contains it. # A locked list plus an unparseable address refuses: fail closed. def ip_allowed?(ip) + return false if malformed? return true if ips.empty? address = self.class.parse_ip(ip.is_a?(String) ? ip : ip.to_s) @@ -303,22 +339,31 @@ def hash end def inspect - "#<#{self.class.name} origins=#{origins.inspect} ips=#{ips.inspect}>" + "#<#{self.class.name} origins=#{origins.inspect} ips=#{ips.inspect} malformed=#{malformed?.inspect}>" end private - # ApiKeys::Logging memoizes its logger in an instance variable, and this - # value object is frozen. Resolve the logger fresh instead. - def logger - defined?(Rails) ? Rails.logger : nil + def deep_copy(value, freeze_copy: false) + copy = case value + when Hash + value.to_h do |key, entry| + [deep_copy(key, freeze_copy: freeze_copy), deep_copy(entry, freeze_copy: freeze_copy)] + end + when Array + value.map { |entry| deep_copy(entry, freeze_copy: freeze_copy) } + when String + value.dup + else + value + end + copy.freeze if freeze_copy + copy end # `*.example.com` matches any subdomain at any depth, but never the apex — # Google's rule. List the apex separately when you want both. def origin_entry_matches?(entry, host) - return false unless entry.is_a?(String) - if entry.start_with?("*.") suffix = entry.delete_prefix("*") host.end_with?(suffix) && host.length > suffix.length @@ -327,16 +372,8 @@ def origin_entry_matches?(entry, host) end end - # A stored entry that no longer parses matches nothing and says so once. - # Validation keeps these out; this covers rows written around validations. def ip_entry_matches?(entry, address) - range = self.class.parse_ip(entry) - unless range - log_warn "[ApiKeys Security] Ignored an unparseable stored IP restriction entry." - return false - end - - range.include?(address) + self.class.parse_ip(entry).include?(address) end end end diff --git a/lib/api_keys/services/authenticator.rb b/lib/api_keys/services/authenticator.rb index c7e9cd5..38765e6 100644 --- a/lib/api_keys/services/authenticator.rb +++ b/lib/api_keys/services/authenticator.rb @@ -103,10 +103,10 @@ def self.call(request) end elsif api_key&.revoked? log_debug "[ApiKeys Auth] Verification failed: Key revoked. Key ID: #{api_key.id}" - Result.failure(error_code: :revoked_key, message: "API key has been revoked") + Result.failure(error_code: :revoked_key, message: "API key has been revoked", api_key: api_key) elsif api_key&.expired? log_debug "[ApiKeys Auth] Verification failed: Key expired. Key ID: #{api_key.id}" - Result.failure(error_code: :expired_key, message: "API key has expired") + Result.failure(error_code: :expired_key, message: "API key has expired", api_key: api_key) else # Not found, mismatch, or inactive log_debug "[ApiKeys Auth] Verification failed: Token invalid or key not found." Result.failure(error_code: :invalid_token, message: "API token is invalid") @@ -368,7 +368,7 @@ def self.check_key_type_configuration(api_key, config) return nil if configured log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because its key type is not configured." - Result.failure(error_code: :unknown_key_type, message: "API key type is not configured") + Result.failure(error_code: :unknown_key_type, message: "API key type is not configured", api_key: api_key) end def self.check_environment_configuration(api_key, config) @@ -382,7 +382,7 @@ def self.check_environment_configuration(api_key, config) return nil if configured log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because its environment is not configured." - Result.failure(error_code: :unknown_environment, message: "API key environment is not configured") + Result.failure(error_code: :unknown_environment, message: "API key environment is not configured", api_key: api_key) end # Check if the API key's environment matches the current environment @@ -455,6 +455,15 @@ def self.check_request_restrictions(api_key, request, config) restrictions = api_key.restrictions return nil if restrictions.unrestricted? + if restrictions.malformed? + log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because its stored request restrictions are malformed." + return Result.failure( + error_code: :restriction_misconfigured, + message: "This API key's request restrictions could not be verified", + api_key: api_key + ) + end + if restrictions.origins.any? origin_host = ApiKeys::Restrictions.extract_origin_host(request) unless restrictions.origin_allowed?(origin_host) diff --git a/lib/generators/api_keys/add_key_types_generator.rb b/lib/generators/api_keys/add_key_types_generator.rb index f89a622..0fd8e3a 100644 --- a/lib/generators/api_keys/add_key_types_generator.rb +++ b/lib/generators/api_keys/add_key_types_generator.rb @@ -35,7 +35,7 @@ def display_post_install_message say " publishable: {" say " prefix: 'pk'," say " permissions: %w[read validate]," - say " revocable: false," + say " public: true," say " limit: 1" say " }," say " secret: {" diff --git a/lib/generators/api_keys/add_restrictions_generator.rb b/lib/generators/api_keys/add_restrictions_generator.rb index 8b49b9e..237229b 100644 --- a/lib/generators/api_keys/add_restrictions_generator.rb +++ b/lib/generators/api_keys/add_restrictions_generator.rb @@ -37,13 +37,13 @@ def display_post_install_message say "\n Keys without restrictions keep working from anywhere; presence is the toggle." say "\n 3. Optionally cap which restriction kinds each key type may carry:" say " config.key_types = {" - say " publishable: { prefix: 'pk', permissions: %w[read], revocable: false," - say " public: true, restrictions: [:origins] }," + say " publishable: { prefix: 'pk', permissions: %w[read], public: true," + say " restrictions: [:origins] }," say " secret: { prefix: 'sk', permissions: :all, restrictions: [:ips] }" say " }" say "\n 4. Behind a CDN or proxy, make sure the client IP is truthful:" say " config.action_dispatch.trusted_proxies = ..." - say " # or: config.client_ip_resolver = ->(request) { request.headers['CF-Connecting-IP'] }" + say " # Trust a vendor header only when ingress blocks requests that bypass that vendor." say "\nSee the api_keys README for detailed usage and examples.", :cyan end diff --git a/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb b/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb index 0209e8d..8df6254 100644 --- a/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb +++ b/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb @@ -9,8 +9,15 @@ # didn't have request restrictions support. The column defaults to an empty # object, which means "unrestricted", so existing keys are unaffected. class AddRestrictionsToApiKeys < ActiveRecord::Migration<%= migration_version %> - def change + CONSTRAINT_NAME = "api_keys_restrictions_is_object" + + def up add_column :api_keys, :restrictions, json_column_type, default: {}, null: false + add_restrictions_object_constraint + end + + def down + remove_column :api_keys, :restrictions end private @@ -28,4 +35,20 @@ class AddRestrictionsToApiKeys < ActiveRecord::Migration<%= migration_version %> # Fallback during initial setup or if connection isn't available :text end + + # JSON columns can also hold arrays and scalars. The application requires an + # object so damaged or validation-bypassing writes cannot erase a policy. + def add_restrictions_object_constraint + return unless connection.supports_check_constraints? + + expression = case connection.adapter_name.downcase + when /postgres/ + "jsonb_typeof(restrictions) = 'object'" + when /sqlite/ + "json_valid(restrictions) AND json_type(restrictions) = 'object'" + when /mysql|trilogy/ + "JSON_TYPE(restrictions) = 'OBJECT'" + end + add_check_constraint :api_keys, expression, name: CONSTRAINT_NAME if expression + end end diff --git a/lib/generators/api_keys/templates/create_api_keys_table.rb.erb b/lib/generators/api_keys/templates/create_api_keys_table.rb.erb index 06defd1..f37669d 100644 --- a/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +++ b/lib/generators/api_keys/templates/create_api_keys_table.rb.erb @@ -2,6 +2,8 @@ # Migration responsible for creating the core api_keys table. class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %> + RESTRICTIONS_CONSTRAINT_NAME = "api_keys_restrictions_is_object" + def change primary_key_type, foreign_key_type = primary_and_foreign_key_types @@ -71,6 +73,8 @@ class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %> t.index :environment t.index [:owner_type, :owner_id, :key_type, :environment], name: "index_api_keys_owner_type_env" end + + add_restrictions_object_constraint end private @@ -98,4 +102,18 @@ class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %> # Fallback during initial setup or if connection isn't available :text end + + def add_restrictions_object_constraint + return unless connection.supports_check_constraints? + + expression = case connection.adapter_name.downcase + when /postgres/ + "jsonb_typeof(restrictions) = 'object'" + when /sqlite/ + "json_valid(restrictions) AND json_type(restrictions) = 'object'" + when /mysql|trilogy/ + "JSON_TYPE(restrictions) = 'OBJECT'" + end + add_check_constraint :api_keys, expression, name: RESTRICTIONS_CONSTRAINT_NAME if expression + end end diff --git a/lib/generators/api_keys/templates/initializer.rb b/lib/generators/api_keys/templates/initializer.rb index 46a7c37..eb40681 100644 --- a/lib/generators/api_keys/templates/initializer.rb +++ b/lib/generators/api_keys/templates/initializer.rb @@ -139,9 +139,9 @@ # - permissions: Scope ceiling - array of allowed scopes, or :all for unrestricted # - revocable: Whether keys of this type can be revoked/deleted (default: true) # - limit: Max keys of this type per owner per environment (nil = unlimited) - # - public: If true AND revocable: false, stores plaintext token in metadata - # so it can be viewed again in the dashboard. Use ONLY for publishable - # keys designed to be embedded in distributed apps. Public types must + # - public: If true, stores plaintext token in metadata so it can be viewed + # again in the dashboard. Use ONLY for publishable keys designed + # to be embedded in distributed apps. Public types must # use a finite, non-empty permissions array (never :all). (default: false) # SECURITY: NEVER set public: true on secret keys! # - restrictions: Which request-restriction kinds keys of this type may carry: @@ -153,7 +153,6 @@ # publishable: { # prefix: "pk", # → pk_test_, pk_live_ # permissions: %w[read validate], # Can ONLY have these scopes - # revocable: false, # Cannot be revoked - protects deployed apps! # public: true, # Store token for later viewing in dashboard # limit: 1, # Only 1 publishable key per environment # restrictions: [:origins] # Browser keys lock to domains, not IPs @@ -294,7 +293,8 @@ # Referer); IPs are matched with CIDR support. Within a list any entry # admits the request; every list that is set must pass. Keys with no # restrictions work from anywhere, so nothing changes until you opt in. - # Failures answer 403 with `origin_not_allowed` / `ip_not_allowed`. + # Policy mismatches answer 403 with `origin_not_allowed` / `ip_not_allowed`. + # Malformed stored policy also fails closed with `restriction_misconfigured`. # # Requires the restrictions column: # rails generate api_keys:add_restrictions && rails db:migrate @@ -303,10 +303,12 @@ # How the client IP is resolved for `allowed_ips` checks. # The default trusts Rails' own resolution, which honors # config.action_dispatch.trusted_proxies. Behind a CDN that terminates the - # connection, either configure trusted_proxies or resolve the header yourself. + # connection, configure trusted_proxies whenever possible. Trust a vendor + # header directly only when your network ingress rejects requests that + # bypass that vendor; otherwise callers can spoof the address being checked. # Default: ->(request) { request.remote_ip } # - # config.client_ip_resolver = ->(request) { request.headers["CF-Connecting-IP"].presence || request.remote_ip } + # config.client_ip_resolver = ->(request) { request.headers.fetch("CF-Connecting-IP") } # ============================================================================ # BACKGROUND JOBS & CALLBACKS diff --git a/test/configuration_test.rb b/test/configuration_test.rb index fb51a74..119d7cb 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -51,10 +51,10 @@ class ConfigurationTest < ApiKeys::Test assert_equal "tenant_", ApiKeys.configuration.resolved_token_prefix end - test "public key types must be explicitly non-revocable and least privilege" do - assert_raises(ArgumentError) do + test "public key types must be least privilege but may be revocable" do + assert_nothing_raised do ApiKeys.configuration.key_types = { - unsafe: { prefix: "pk", permissions: %w[read], public: true, revocable: true } + safe: { prefix: "pk", permissions: %w[read], public: true, revocable: true } } end diff --git a/test/controllers/keys_controller_test.rb b/test/controllers/keys_controller_test.rb index c1231b3..37f7334 100644 --- a/test/controllers/keys_controller_test.rb +++ b/test/controllers/keys_controller_test.rb @@ -199,6 +199,50 @@ def setup assert_includes response.body, "api_key[allowed_ips]" end + test "the new key form derives dynamic fields from key type policy" do + ApiKeys.configure do |config| + config.key_types = { + browser: { prefix: "pk", permissions: %w[read], public: true, restrictions: [:origins] }, + permanent_server: { prefix: "sk", permissions: :all, revocable: false, restrictions: [:ips] } + } + config.environments = { test: { prefix_segment: "test" } } + config.current_environment = :test + end + + get :new + + assert_response :success + assert_includes response.body, 'data-api-keys-restriction-kind="origins"' + assert_includes response.body, 'data-api-keys-restriction-kind="ips"' + assert_includes response.body, '"browser":{"restrictions":["origins"],"expirable":true}' + assert_includes response.body, '"permanent_server":{"restrictions":["ips"],"expirable":false}' + assert_match(/