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 fc1a592..edef074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +## [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 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: 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, 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 - 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..e0a8083 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) ] @@ -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 @@ -1042,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. @@ -1071,10 +1093,12 @@ 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. +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: @@ -1086,13 +1110,16 @@ 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 - limit: 1 # Max 1 per owner per environment + 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 }, 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 } } @@ -1116,7 +1143,7 @@ end ### Creating Typed Keys ```ruby -# Create a publishable key (limited permissions, cannot be revoked) +# Create a publishable key (limited permissions, viewable and revocable) pk = user.create_api_key!( name: "Production App", key_type: :publishable, @@ -1154,14 +1181,25 @@ sk.scopes # => ["read", "validate", "issue_license", "admin"] ### Non-Revocable Keys -Keys with `revocable: false` protect against accidental deletion: +Keys with `revocable: false` protect against accidental deletion. Configure +that lifecycle explicitly on the key type that needs it: ```ruby -pk = user.create_api_key!(key_type: :publishable) +ApiKeys.configure do |config| + config.key_types = { + permanent_server: { + prefix: "skp", + permissions: :all, + revocable: false + } + } +end -pk.revocable? # => false -pk.revoke! # Raises ApiKeys::Errors::KeyNotRevocableError -pk.destroy! # Raises ApiKeys::Errors::KeyNotRevocableError +key = user.create_api_key!(key_type: :permanent_server) + +key.revocable? # => false +key.revoke! # Raises ApiKeys::Errors::KeyNotRevocableError +key.destroy! # Raises ApiKeys::Errors::KeyNotRevocableError ``` The dashboard UI automatically hides the revoke button for non-revocable keys. @@ -1169,11 +1207,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 @@ -1186,7 +1224,6 @@ config.key_types = { publishable: { prefix: "pk", permissions: %w[read validate], - revocable: false, public: true, # Store token for later viewing limit: 1 }, @@ -1201,16 +1238,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. @@ -1279,6 +1315,103 @@ 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`, `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. + +### 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], 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`. 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.fetch("CF-Connecting-IP") +end +``` + +### Dashboard + +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 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. + ## Enterprise-ready by design The `api_keys` gem ships with: diff --git a/app/controllers/api_keys/keys_controller.rb b/app/controllers/api_keys/keys_controller.rb index b57628d..86fbc07 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 @@ -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 @@ -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 ) @@ -73,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 @@ -133,18 +137,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 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) - 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 @@ -163,10 +172,32 @@ 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. + # 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 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 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 0a33a15..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 %> @@ -87,6 +88,8 @@
<% end %> + <%= render "api_keys/keys/restriction_fields", form: form, api_key: api_key %> + <% end %> <%# Fields editable on EDIT %> @@ -119,6 +122,8 @@ <% end %>
<% end %> + + <%= render "api_keys/keys/restriction_fields", form: form, api_key: api_key %> <% end %>
@@ -127,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/_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..d2c05f9 --- /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; 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..3471a05 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 restriction_misconfigured].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..f9bde24 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,19 @@ 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` 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.fetch("CF-Connecting-IP") } + attr_reader :client_ip_resolver + # Tenant Resolution attr_reader :tenant_resolver @@ -81,13 +95,18 @@ 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 }, - # secret: { prefix: "sk", permissions: :all } + # publishable: { prefix: "pk", permissions: %w[read], public: true, limit: 1, + # restrictions: [:origins] }, + # secret: { prefix: "sk", permissions: :all, restrictions: [:ips] } # } # # @!attribute [rw] environments @@ -240,6 +259,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,11 +428,10 @@ 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 - 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 @@ -416,6 +440,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 +591,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..52c5950 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`: 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 @@ -26,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. @@ -45,6 +52,62 @@ 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? + {} + 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 + 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 +136,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 @@ -101,7 +166,15 @@ def scopes=(value) # .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: [nil, {}]) } + scope :unrestricted, -> { where(restrictions: [nil, {}]) } # === Usage Analytics Scopes === # These scopes help admin dashboards analyze API key usage patterns. @@ -201,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 @@ -220,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 @@ -306,8 +382,41 @@ 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 + + # 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! + 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. @@ -390,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 @@ -467,6 +577,70 @@ 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 + + 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(', ')}") + 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 like example.com or *.example.com" + 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 - allowed_restriction_kinds + return if exceeded.empty? + + errors.add(:restrictions, "#{exceeded.sort.join(', ')} are not allowed for #{key_type} keys") + 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..e2b2386 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,30 @@ 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? + + 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? + 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 +411,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..ea4f850 --- /dev/null +++ b/lib/api_keys/restrictions.rb @@ -0,0 +1,379 @@ +# frozen_string_literal: true + +require "ipaddr" +require "uri" + +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 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 + # 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". + 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, :extras + + 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: {}, 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] } + + new( + origins: coerce_list(known["origins"]), + 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. + # @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"] + # + # 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. + def normalize_origins(value) + 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. 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 { |entry| entry.is_a?(String) ? entry.downcase : entry }.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?(:[]) + + 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 + + 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. + 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 entry 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 + + 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. + 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? + rescue ArgumentError + entry + end + end + + # Whether a stored origin entry is shaped like a host or `*.host`. + # @api private + def valid_origin_entry?(entry) + 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. + # @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 + 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. + # @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 + + # 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? + !malformed? && 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"] = 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 + + # 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) + !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 + + # @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 false if malformed? + 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} malformed=#{malformed?.inspect}>" + end + + private + + 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) + if entry.start_with?("*.") + suffix = entry.delete_prefix("*") + host.end_with?(suffix) && host.length > suffix.length + else + entry == host + end + end + + def ip_entry_matches?(entry, 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 1468b8b..38765e6 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 @@ -25,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 @@ -82,20 +88,25 @@ 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) 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") @@ -357,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) @@ -371,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 @@ -386,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 @@ -398,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 @@ -412,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 @@ -420,13 +434,74 @@ 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 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.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) + 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", + api_key: api_key + ) + 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", + api_key: api_key + ) + 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 +512,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/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 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 new file mode 100644 index 0000000..237229b --- /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], 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 " # 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 + + 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..8df6254 --- /dev/null +++ b/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb @@ -0,0 +1,54 @@ +# 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 %> + 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 + + # 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 + + # 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 ebd924b..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 @@ -30,6 +32,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 @@ -67,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 @@ -94,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 28960f1..eb40681 100644 --- a/lib/generators/api_keys/templates/initializer.rb +++ b/lib/generators/api_keys/templates/initializer.rb @@ -139,23 +139,28 @@ # - 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: + # any subset of [:origins, :ips]. Omitted = both allowed. + # `restrictions: []` forbids restrictions for this type. + # See "REQUEST RESTRICTIONS" below. # # config.key_types = { # 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 + # 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 +280,36 @@ # 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. + # 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 + # ============================================================================ + + # 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, 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.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 b547d8f..37f7334 100644 --- a/test/controllers/keys_controller_test.rb +++ b/test/controllers/keys_controller_test.rb @@ -189,6 +189,118 @@ 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 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(/