Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .simplecov
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
175 changes: 154 additions & 21 deletions README.md

Large diffs are not rendered by default.

47 changes: 39 additions & 8 deletions app/controllers/api_keys/keys_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
)

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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<Symbol>]
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
Expand Down
49 changes: 45 additions & 4 deletions app/views/api_keys/keys/_form.html.erb
Original file line number Diff line number Diff line change
@@ -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? %>
<div class="api-keys-form-errors">
<strong><%= pluralize(api_key.errors.count, "error") %> prohibited this API key from being saved:</strong>
Expand Down Expand Up @@ -47,7 +48,7 @@
<%= form.text_field :name, placeholder: "e.g., myproject-production-key" %>
</div>

<div>
<div data-api-keys-expiration>
<%= form.label :expires_at_preset, "Expiration" %>
<%= form.select :expires_at_preset,
options_for_select([
Expand All @@ -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
%>
Expand Down Expand Up @@ -87,6 +88,8 @@
</div>
<% end %>

<%= render "api_keys/keys/restriction_fields", form: form, api_key: api_key %>

<% end %>

<%# Fields editable on EDIT %>
Expand Down Expand Up @@ -119,6 +122,8 @@
<% end %>
</div>
<% end %>

<%= render "api_keys/keys/restriction_fields", form: form, api_key: api_key %>
<% end %>

<div>
Expand All @@ -127,9 +132,45 @@
<%= link_to "Cancel", keys_path %>
<% else %>
<h4><strong>Keep it safe</strong></h4>
<p>Your API key will only be shown once after creation. <strong>Your key cannot be recovered:</strong> copy it immediately and store it securely.</p>
<p>Secret API keys are only shown once after creation. Copy the new key immediately and store it securely.</p>
<%= form.submit "Create API Key" %>
<%= link_to "Cancel", keys_path %>
<% end %>
</div>
<% 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 %>
<script nonce="<%= content_security_policy_nonce %>">
(() => {
const form = document.getElementById("api-keys-key-form");
const keyType = form && form.querySelector("#api_key_key_type");
if (!form || !keyType) return;

const policies = <%= raw json_escape(key_type_policies.to_json) %>;
const updatePolicyFields = () => {
const policy = policies[keyType.value];

form.querySelectorAll("[data-api-keys-restriction-kind]").forEach((container) => {
const enabled = policy && policy.restrictions.includes(container.dataset.apiKeysRestrictionKind);
container.hidden = !enabled;
container.querySelectorAll("input, select, textarea").forEach((input) => { input.disabled = !enabled; });
});

form.querySelectorAll("[data-api-keys-expiration]").forEach((container) => {
const enabled = policy && policy.expirable;
container.hidden = !enabled;
container.querySelectorAll("input, select, textarea").forEach((input) => { input.disabled = !enabled; });
});
};

keyType.addEventListener("change", updatePolicyFields);
updatePolicyFields();
})();
</script>
<% end %>
7 changes: 7 additions & 0 deletions app/views/api_keys/keys/_key_badges.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
</span>
<% end %>

<% if key.restricted? %>
<span class="api-keys-badge api-keys-badge-restricted"
title="Usable only from specific <%= key.restrictions.kinds.map { |kind| kind == :ips ? 'IP addresses' : 'web origins' }.to_sentence %>">
Restricted
</span>
<% end %>

<% if key.environment.present? %>
<% is_live = key.environment == 'live' %>
<span class="api-keys-badge api-keys-badge-env <%= is_live ? 'api-keys-badge-live' : 'api-keys-badge-test' %>">
Expand Down
32 changes: 32 additions & 0 deletions app/views/api_keys/keys/_restriction_fields.html.erb
Original file line number Diff line number Diff line change
@@ -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) %>
<div data-api-keys-restriction-kind="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" %>
<small class="api-keys-form-help">
Leave empty to allow any origin. Requests from a browser must come from one of these hosts.
Use <code>*.example.com</code> to allow every subdomain.
</small>
</div>
<% end %>

<% if allowed_kinds.include?(:ips) %>
<div data-api-keys-restriction-kind="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" %>
<small class="api-keys-form-help">
Leave empty to allow any address. Accepts single IPv4/IPv6 addresses and CIDR ranges.
</small>
</div>
<% end %>
<% end %>
10 changes: 9 additions & 1 deletion app/views/layouts/api_keys/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions lib/api_keys.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion lib/api_keys/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading