api_keys 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -0
- data/README.md +851 -25
- data/SECURITY.md +33 -0
- data/app/controllers/api_keys/application_controller.rb +58 -10
- data/app/controllers/api_keys/keys_controller.rb +77 -23
- data/app/controllers/api_keys/security_controller.rb +8 -0
- data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
- data/app/views/api_keys/keys/_form.html.erb +33 -4
- data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
- data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
- data/app/views/api_keys/keys/_key_row.html.erb +21 -35
- data/app/views/api_keys/keys/_key_status.html.erb +10 -0
- data/app/views/api_keys/keys/_keys_table.html.erb +3 -11
- data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
- data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
- data/app/views/api_keys/keys/_show_token.html.erb +10 -47
- data/app/views/api_keys/keys/_token_display.html.erb +11 -0
- data/app/views/api_keys/keys/index.html.erb +40 -8
- data/app/views/api_keys/keys/show.html.erb +2 -2
- data/app/views/api_keys/security/best_practices.html.erb +73 -47
- data/app/views/layouts/api_keys/application.html.erb +267 -14
- data/lib/api_keys/authentication.rb +39 -11
- data/lib/api_keys/configuration.rb +444 -17
- data/lib/api_keys/engine.rb +5 -20
- data/lib/api_keys/errors.rb +73 -0
- data/lib/api_keys/form_builder_extensions.rb +168 -0
- data/lib/api_keys/helpers/expiration_options.rb +139 -0
- data/lib/api_keys/helpers/token_session.rb +203 -0
- data/lib/api_keys/helpers/view_helpers.rb +220 -0
- data/lib/api_keys/jobs/callbacks_job.rb +10 -17
- data/lib/api_keys/jobs/update_stats_job.rb +27 -12
- data/lib/api_keys/models/api_key.rb +452 -21
- data/lib/api_keys/models/concerns/has_api_keys.rb +269 -26
- data/lib/api_keys/services/authenticator.rb +300 -112
- data/lib/api_keys/services/digestor.rb +81 -14
- data/lib/api_keys/services/token_generator.rb +41 -1
- data/lib/api_keys/tenant_resolution.rb +4 -4
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +12 -0
- data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
- data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
- data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
- data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +11 -3
- data/lib/generators/api_keys/templates/initializer.rb +261 -120
- metadata +29 -63
- data/Rakefile +0 -32
data/SECURITY.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
`api_keys` handles authentication credentials. Please report suspected vulnerabilities privately and avoid including real API keys, production data, or customer information in any report.
|
|
4
|
+
|
|
5
|
+
## Supported versions
|
|
6
|
+
|
|
7
|
+
Security fixes are released for the latest published version. The maintained test matrix covers Ruby 3.3, 3.4, and 4.0 with patched Rails 7.2, 8.0, and 8.1 releases. Older Ruby and Rails versions may remain installable for compatibility, but runtimes that no longer receive upstream security fixes are not security-supported.
|
|
8
|
+
|
|
9
|
+
## Reporting a vulnerability
|
|
10
|
+
|
|
11
|
+
Use GitHub's **Report a vulnerability** button on the [`api_keys` security advisories page](https://github.com/rameerez/api_keys/security/advisories) so the report and any proposed fix remain private. If GitHub's private reporting flow is unavailable, email `rubygems@rameerez.com` with the subject `api_keys security report`.
|
|
12
|
+
|
|
13
|
+
Include:
|
|
14
|
+
|
|
15
|
+
- the affected version and environment;
|
|
16
|
+
- a minimal reproduction or proof of concept;
|
|
17
|
+
- the impact you believe is possible; and
|
|
18
|
+
- any suggested mitigation or patch.
|
|
19
|
+
|
|
20
|
+
Do not open a public issue for an undisclosed vulnerability. We will acknowledge the report, investigate it, and coordinate disclosure and credit with you. If the issue affects downstream applications, we will prioritize a patched release and clear upgrade guidance.
|
|
21
|
+
|
|
22
|
+
## Operational security
|
|
23
|
+
|
|
24
|
+
Applications remain responsible for:
|
|
25
|
+
|
|
26
|
+
- trusted TLS/proxy configuration, endpoint authorization, and request rate limiting;
|
|
27
|
+
- encrypted, `Secure`, `HttpOnly`, appropriately `SameSite` session cookies for the one-time dashboard token handoff;
|
|
28
|
+
- database, cache, queue, log, backup, and observability access controls;
|
|
29
|
+
- filtering any query-parameter credential name from application and proxy logs if that opt-in transport is enabled;
|
|
30
|
+
- classifying every permission on a `public: true` key type as safe for an untrusted public client and maintaining a disable/replacement procedure for non-revocable identifiers; and
|
|
31
|
+
- prompt dependency/runtime patching and a durable background-job backend where callbacks or usage statistics are required.
|
|
32
|
+
|
|
33
|
+
The gem bounds authentication work, but applications should still rate-limit credential endpoints. Do not treat a key prefix, last four characters, cache entry, or public key as proof of authorization.
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module ApiKeys
|
|
4
6
|
# Base controller for the ApiKeys engine.
|
|
5
7
|
# Inherits from the host application's configured controller
|
|
6
8
|
# (defaults to ::ApplicationController).
|
|
7
9
|
# Includes common engine functionality.
|
|
8
|
-
class ApplicationController < ApiKeys
|
|
10
|
+
class ApplicationController < ApiKeys.configuration.parent_controller_class
|
|
9
11
|
# Protect from forgery if the parent controller does
|
|
10
12
|
# This ensures CSRF protection behaves consistently with the host app.
|
|
11
13
|
protect_from_forgery with: :exception if respond_to?(:protect_from_forgery)
|
|
@@ -13,28 +15,61 @@ module ApiKeys
|
|
|
13
15
|
# Include the main controller concern which bundles authentication and tenant resolution
|
|
14
16
|
include ApiKeys::Controller
|
|
15
17
|
|
|
18
|
+
# The dashboard renders credential material, so give it an enforcing,
|
|
19
|
+
# self-contained CSP even when the host application has not configured one.
|
|
20
|
+
# Preserve any host directives we do not explicitly tighten.
|
|
21
|
+
before_action :prepare_api_keys_content_security_policy_nonce
|
|
22
|
+
if respond_to?(:content_security_policy)
|
|
23
|
+
content_security_policy do |policy|
|
|
24
|
+
policy.default_src :none
|
|
25
|
+
policy.base_uri :none
|
|
26
|
+
policy.object_src :none
|
|
27
|
+
policy.frame_ancestors :none
|
|
28
|
+
policy.frame_src :none
|
|
29
|
+
policy.form_action :self
|
|
30
|
+
# Rails appends the per-request nonce to these directives. `none` leaves
|
|
31
|
+
# the nonce as the only effective source instead of trusting every
|
|
32
|
+
# same-origin script or stylesheet.
|
|
33
|
+
policy.script_src :none
|
|
34
|
+
policy.style_src :none
|
|
35
|
+
policy.img_src :self, :data
|
|
36
|
+
policy.connect_src :self
|
|
37
|
+
end
|
|
38
|
+
content_security_policy_report_only false
|
|
39
|
+
end
|
|
40
|
+
|
|
16
41
|
# Ensure the owner is authenticated for all actions within this engine
|
|
17
42
|
# This uses the configured authentication method (defaults to authenticate_user!)
|
|
18
43
|
before_action :authenticate_api_keys_owner!
|
|
44
|
+
after_action :set_api_keys_security_headers
|
|
19
45
|
|
|
20
46
|
private
|
|
21
47
|
|
|
48
|
+
def prepare_api_keys_content_security_policy_nonce
|
|
49
|
+
return unless request.respond_to?(:content_security_policy_nonce_generator=)
|
|
50
|
+
|
|
51
|
+
request.content_security_policy_nonce_generator ||= ->(_request) { SecureRandom.base64(16) }
|
|
52
|
+
directives = Array(request.content_security_policy_nonce_directives)
|
|
53
|
+
request.content_security_policy_nonce_directives = directives | %w[script-src style-src]
|
|
54
|
+
request.content_security_policy_nonce
|
|
55
|
+
end
|
|
56
|
+
|
|
22
57
|
# Authenticates the owner accessing the engine.
|
|
23
58
|
# Uses the configured authentication method from ApiKeys.configuration
|
|
24
59
|
def authenticate_api_keys_owner!
|
|
25
60
|
auth_method = ApiKeys.configuration.authenticate_owner_method
|
|
26
61
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
send(auth_method)
|
|
32
|
-
else
|
|
33
|
-
# Fallback: check if owner is present
|
|
34
|
-
unless current_api_keys_owner
|
|
35
|
-
redirect_to main_app.root_path, alert: "You need to sign in before continuing." rescue render plain: "Unauthorized", status: :unauthorized
|
|
62
|
+
if auth_method.present?
|
|
63
|
+
unless respond_to?(auth_method, true)
|
|
64
|
+
log_error "[ApiKeys Security] Configured owner authentication method is unavailable."
|
|
65
|
+
return render_api_keys_unauthorized
|
|
36
66
|
end
|
|
67
|
+
|
|
68
|
+
send(auth_method)
|
|
69
|
+
return if performed?
|
|
37
70
|
end
|
|
71
|
+
|
|
72
|
+
render_api_keys_unauthorized unless current_api_keys_owner
|
|
38
73
|
end
|
|
39
74
|
|
|
40
75
|
# Helper method to access the current owner from the host application.
|
|
@@ -52,5 +87,18 @@ module ApiKeys
|
|
|
52
87
|
# Expose current_api_keys_owner as a helper method for views
|
|
53
88
|
helper_method :current_api_keys_owner
|
|
54
89
|
|
|
90
|
+
def render_api_keys_unauthorized
|
|
91
|
+
render plain: "Unauthorized", status: :unauthorized
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def set_api_keys_security_headers
|
|
95
|
+
response.headers["Cache-Control"] = "no-store, private"
|
|
96
|
+
response.headers["Pragma"] = "no-cache"
|
|
97
|
+
response.headers["Referrer-Policy"] = "no-referrer"
|
|
98
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
99
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
100
|
+
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
|
101
|
+
end
|
|
102
|
+
|
|
55
103
|
end
|
|
56
104
|
end
|
|
@@ -4,13 +4,31 @@ module ApiKeys
|
|
|
4
4
|
# Controller for managing API keys belonging to the current owner.
|
|
5
5
|
class KeysController < ApplicationController
|
|
6
6
|
before_action :set_api_key, only: [:show, :edit, :update, :revoke]
|
|
7
|
+
helper_method :key_types_feature_enabled?
|
|
7
8
|
|
|
8
9
|
# GET /keys
|
|
9
10
|
def index
|
|
10
|
-
#
|
|
11
|
-
|
|
11
|
+
# Start with all keys for the owner
|
|
12
|
+
base_scope = current_api_keys_owner.api_keys
|
|
13
|
+
|
|
14
|
+
# Filter by environment if key_types feature is enabled and cross-environment is disabled
|
|
15
|
+
if key_types_feature_enabled? && !ApiKeys.configuration.dashboard_allow_cross_environment
|
|
16
|
+
current_env = resolve_current_environment
|
|
17
|
+
base_scope = base_scope.where(environment: [current_env.to_s, nil, ""])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Fetch only active keys for the main list, sorted by creation date
|
|
21
|
+
@api_keys = base_scope.active.order(created_at: :desc)
|
|
12
22
|
# Optionally, fetch inactive ones for a separate section or filter
|
|
13
|
-
@inactive_api_keys =
|
|
23
|
+
@inactive_api_keys = base_scope.inactive.order(created_at: :desc)
|
|
24
|
+
|
|
25
|
+
# When key_types feature is enabled, separate publishable and secret keys
|
|
26
|
+
if key_types_feature_enabled?
|
|
27
|
+
@publishable_keys = @api_keys.select(&:public_key_type?)
|
|
28
|
+
@secret_keys = @api_keys.reject(&:public_key_type?)
|
|
29
|
+
@inactive_publishable_keys = @inactive_api_keys.select(&:public_key_type?)
|
|
30
|
+
@inactive_secret_keys = @inactive_api_keys.reject(&:public_key_type?)
|
|
31
|
+
end
|
|
14
32
|
end
|
|
15
33
|
|
|
16
34
|
# GET /keys/:id
|
|
@@ -21,7 +39,7 @@ module ApiKeys
|
|
|
21
39
|
# We need to retrieve the plaintext token stored temporarily
|
|
22
40
|
# after creation. This relies on how we handle creation.
|
|
23
41
|
# We'll likely store it in the session flash or pass it directly.
|
|
24
|
-
@plaintext_token =
|
|
42
|
+
@plaintext_token = ApiKeys::TokenSession.retrieve_once(session, api_key: @api_key)
|
|
25
43
|
unless @plaintext_token
|
|
26
44
|
# If accessed directly without the token, redirect or show an error
|
|
27
45
|
redirect_to keys_path, alert: "API key token can only be shown once immediately after creation."
|
|
@@ -35,21 +53,21 @@ module ApiKeys
|
|
|
35
53
|
|
|
36
54
|
# POST /keys
|
|
37
55
|
def create
|
|
56
|
+
submitted_params = api_key_params
|
|
57
|
+
|
|
38
58
|
# Use the HasApiKeys helper method to create the key
|
|
39
59
|
begin
|
|
40
60
|
# create_api_key! now returns the ApiKey instance
|
|
41
61
|
@api_key = current_api_keys_owner.create_api_key!(
|
|
42
|
-
name:
|
|
43
|
-
scopes:
|
|
44
|
-
expires_at: parse_expiration(
|
|
62
|
+
name: submitted_params[:name],
|
|
63
|
+
scopes: submitted_params[:scopes],
|
|
64
|
+
expires_at: parse_expiration(submitted_params[:expires_at_preset]),
|
|
65
|
+
key_type: submitted_params[:key_type].presence
|
|
45
66
|
# Metadata could be added here if needed
|
|
46
67
|
)
|
|
47
68
|
|
|
48
|
-
# Get the plaintext token from the instance's attr_reader
|
|
49
|
-
plaintext_token = @api_key.token
|
|
50
|
-
|
|
51
69
|
# Store the plaintext token in session to display on the show page
|
|
52
|
-
session
|
|
70
|
+
ApiKeys::TokenSession.store(session, @api_key)
|
|
53
71
|
|
|
54
72
|
redirect_to key_path(@api_key)
|
|
55
73
|
rescue ActiveRecord::RecordInvalid => e
|
|
@@ -57,11 +75,18 @@ module ApiKeys
|
|
|
57
75
|
@api_key = e.record # Get the invalid ApiKey instance
|
|
58
76
|
flash.now[:alert] = "Failed to create API key: #{e.record.errors.full_messages.join(', ')}"
|
|
59
77
|
render :new, status: :unprocessable_entity
|
|
60
|
-
rescue
|
|
61
|
-
flash.now[:alert] = "
|
|
62
|
-
@api_key =
|
|
78
|
+
rescue ArgumentError
|
|
79
|
+
flash.now[:alert] = "Failed to create API key: invalid options."
|
|
80
|
+
@api_key = rebuild_api_key_for_form(submitted_params)
|
|
81
|
+
render :new, status: :unprocessable_entity
|
|
82
|
+
rescue StandardError => error
|
|
83
|
+
log_error "[ApiKeys Dashboard] Unexpected key creation failure (#{error.class}); request ID: #{request.uuid}"
|
|
84
|
+
flash.now[:alert] = "An unexpected error occurred while creating the API key."
|
|
85
|
+
@api_key = rebuild_api_key_for_form(submitted_params)
|
|
63
86
|
render :new, status: :unprocessable_entity
|
|
64
87
|
end
|
|
88
|
+
rescue ActionController::ParameterMissing
|
|
89
|
+
head :bad_request
|
|
65
90
|
end
|
|
66
91
|
|
|
67
92
|
# GET /keys/:id/edit
|
|
@@ -77,16 +102,19 @@ module ApiKeys
|
|
|
77
102
|
flash.now[:alert] = "Failed to update API key: #{@api_key.errors.full_messages.join(', ')}"
|
|
78
103
|
render :edit, status: :unprocessable_entity
|
|
79
104
|
end
|
|
105
|
+
rescue ActionController::ParameterMissing
|
|
106
|
+
head :bad_request
|
|
80
107
|
end
|
|
81
108
|
|
|
82
109
|
# POST /keys/:id/revoke
|
|
83
110
|
def revoke
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
111
|
+
@api_key.revoke!
|
|
112
|
+
redirect_to keys_path, notice: "API key revoked successfully."
|
|
113
|
+
rescue ApiKeys::Errors::KeyNotRevocableError
|
|
114
|
+
redirect_to keys_path, alert: "This API key cannot be revoked."
|
|
115
|
+
rescue StandardError => error
|
|
116
|
+
log_error "[ApiKeys Dashboard] Unexpected key revocation failure (#{error.class}); key ID: #{@api_key&.id}; request ID: #{request.uuid}"
|
|
117
|
+
redirect_to keys_path, alert: "Failed to revoke API key."
|
|
90
118
|
end
|
|
91
119
|
|
|
92
120
|
private
|
|
@@ -100,15 +128,22 @@ module ApiKeys
|
|
|
100
128
|
|
|
101
129
|
# Only allow a list of trusted parameters through for creation.
|
|
102
130
|
# Added :expires_at_preset for the dropdown selector.
|
|
131
|
+
# Added :key_type for the key types feature.
|
|
103
132
|
def api_key_params
|
|
104
|
-
|
|
133
|
+
submitted = params.require(:api_key)
|
|
134
|
+
raise ActionController::ParameterMissing, :api_key unless submitted.respond_to?(:permit)
|
|
135
|
+
|
|
136
|
+
permitted_params = submitted.permit(:name, :expires_at_preset, :key_type, scopes: [])
|
|
105
137
|
permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings
|
|
106
138
|
permitted_params
|
|
107
139
|
end
|
|
108
140
|
|
|
109
141
|
# Only allow updating name and scopes.
|
|
110
142
|
def api_key_update_params
|
|
111
|
-
|
|
143
|
+
submitted = params.require(:api_key)
|
|
144
|
+
raise ActionController::ParameterMissing, :api_key unless submitted.respond_to?(:permit)
|
|
145
|
+
|
|
146
|
+
permitted_params = submitted.permit(:name, scopes: [])
|
|
112
147
|
permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings
|
|
113
148
|
permitted_params
|
|
114
149
|
end
|
|
@@ -122,8 +157,27 @@ module ApiKeys
|
|
|
122
157
|
when "90_days" then 90.days.from_now
|
|
123
158
|
when "365_days" then 365.days.from_now
|
|
124
159
|
when "no_expiration" then nil
|
|
125
|
-
|
|
160
|
+
when nil, "" then nil
|
|
161
|
+
else raise ArgumentError, "Invalid expiration preset"
|
|
126
162
|
end
|
|
127
163
|
end
|
|
164
|
+
|
|
165
|
+
def rebuild_api_key_for_form(submitted_params)
|
|
166
|
+
current_api_keys_owner.api_keys.build(
|
|
167
|
+
name: submitted_params[:name],
|
|
168
|
+
scopes: submitted_params[:scopes]
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Check if key types feature is enabled
|
|
173
|
+
def key_types_feature_enabled?
|
|
174
|
+
ApiKeys.configuration.key_types.present? && ApiKeys.configuration.key_types.any?
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Get the current environment from configuration
|
|
178
|
+
def resolve_current_environment
|
|
179
|
+
env_config = ApiKeys.configuration.current_environment
|
|
180
|
+
env_config.respond_to?(:call) ? env_config.call : env_config
|
|
181
|
+
end
|
|
128
182
|
end
|
|
129
183
|
end
|
|
@@ -6,11 +6,19 @@ module ApiKeys
|
|
|
6
6
|
# Skip the user authentication requirement for these static pages
|
|
7
7
|
# as they contain general information.
|
|
8
8
|
skip_before_action :authenticate_api_keys_owner!, only: [:best_practices]
|
|
9
|
+
helper_method :key_types_feature_enabled?
|
|
9
10
|
|
|
10
11
|
# GET /security/best-practices
|
|
11
12
|
def best_practices
|
|
12
13
|
# Renders app/views/api_keys/security/best_practices.html.erb
|
|
13
14
|
# The view will contain the static content.
|
|
14
15
|
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# Check if key types feature is enabled
|
|
20
|
+
def key_types_feature_enabled?
|
|
21
|
+
ApiKeys.configuration.key_types.present? && ApiKeys.configuration.key_types.any?
|
|
22
|
+
end
|
|
15
23
|
end
|
|
16
24
|
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<%# Partial for displaying empty state when no keys exist %>
|
|
2
|
+
<%# Locals: message (optional) - Custom message to display %>
|
|
3
|
+
|
|
4
|
+
<% message ||= "You don't have any API keys yet!" %>
|
|
5
|
+
|
|
6
|
+
<div class="api-keys-empty-state">
|
|
7
|
+
<h4><%= message %></h4>
|
|
8
|
+
<p>Create your first API key to get started.</p>
|
|
9
|
+
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<%# Shared form for creating and editing API Keys %>
|
|
2
2
|
<%= form_with(model: [:keys, api_key], url: (api_key.persisted? ? key_path(api_key) : keys_path), local: true) do |form| %>
|
|
3
3
|
<% if api_key.errors.any? %>
|
|
4
|
-
<div
|
|
4
|
+
<div class="api-keys-form-errors">
|
|
5
5
|
<strong><%= pluralize(api_key.errors.count, "error") %> prohibited this API key from being saved:</strong>
|
|
6
6
|
<ul>
|
|
7
7
|
<% api_key.errors.full_messages.each do |message| %>
|
|
@@ -13,6 +13,35 @@
|
|
|
13
13
|
|
|
14
14
|
<%# Fields only available on NEW %>
|
|
15
15
|
<% unless api_key.persisted? %>
|
|
16
|
+
<%# Key Type selector (only shown when key_types feature is enabled) %>
|
|
17
|
+
<% if ApiKeys.configuration.key_types.present? && ApiKeys.configuration.key_types.any? %>
|
|
18
|
+
<div>
|
|
19
|
+
<%= form.label :key_type, "Key Type" %>
|
|
20
|
+
<%= form.select :key_type,
|
|
21
|
+
options_for_select(
|
|
22
|
+
ApiKeys.configuration.key_types.map { |type, config|
|
|
23
|
+
label = type.to_s.humanize
|
|
24
|
+
label += " (#{config[:prefix]}_)" if config[:prefix]
|
|
25
|
+
[label, type.to_s]
|
|
26
|
+
},
|
|
27
|
+
api_key.key_type
|
|
28
|
+
),
|
|
29
|
+
{ prompt: "Select key type..." }
|
|
30
|
+
%>
|
|
31
|
+
<% ApiKeys.configuration.key_types.each do |type, config| %>
|
|
32
|
+
<% if config[:permissions] != :all %>
|
|
33
|
+
<small class="api-keys-form-help">
|
|
34
|
+
<strong><%= type.to_s.humanize %>:</strong>
|
|
35
|
+
Limited to <%= config[:permissions]&.join(", ") || "no" %> permissions
|
|
36
|
+
<% unless config.fetch(:revocable, true) %>
|
|
37
|
+
<em>(cannot be revoked)</em>
|
|
38
|
+
<% end %>
|
|
39
|
+
</small>
|
|
40
|
+
<% end %>
|
|
41
|
+
<% end %>
|
|
42
|
+
</div>
|
|
43
|
+
<% end %>
|
|
44
|
+
|
|
16
45
|
<div>
|
|
17
46
|
<%= form.label :name, "Name (optional)" %>
|
|
18
47
|
<%= form.text_field :name, placeholder: "e.g., myproject-production-key" %>
|
|
@@ -52,7 +81,7 @@
|
|
|
52
81
|
scope, # Value submitted when checked
|
|
53
82
|
nil # Value submitted when unchecked (not needed here)
|
|
54
83
|
%>
|
|
55
|
-
<%= form.label "scopes_#{scope.parameterize}",
|
|
84
|
+
<%= form.label "scopes_#{scope.parameterize}", class: "form-check-label" do %><code><%= scope %></code><% end %>
|
|
56
85
|
</div>
|
|
57
86
|
<% end %>
|
|
58
87
|
</div>
|
|
@@ -85,7 +114,7 @@
|
|
|
85
114
|
nil # Value submitted when unchecked (not needed here)
|
|
86
115
|
%>
|
|
87
116
|
<%# Using the scope name directly for the label's `for` attribute requires matching the checkbox ID %>
|
|
88
|
-
<%= form.label "scopes_#{scope.parameterize}_edit",
|
|
117
|
+
<%= form.label "scopes_#{scope.parameterize}_edit", class: "form-check-label" do %><code><%= scope %></code><% end %>
|
|
89
118
|
</div>
|
|
90
119
|
<% end %>
|
|
91
120
|
</div>
|
|
@@ -103,4 +132,4 @@
|
|
|
103
132
|
<%= link_to "Cancel", keys_path %>
|
|
104
133
|
<% end %>
|
|
105
134
|
</div>
|
|
106
|
-
<% end %>
|
|
135
|
+
<% end %>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<%# Partial for displaying key action buttons (edit, revoke) %>
|
|
2
|
+
<%# Locals: key (required) - The ApiKey record %>
|
|
3
|
+
|
|
4
|
+
<% if key.active? %>
|
|
5
|
+
<%= link_to edit_key_path(key), title: "Edit Key", class: "api-keys-action-edit" do %>
|
|
6
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M16.793 2.793a3.121 3.121 0 1 1 4.414 4.414l-8.5 8.5A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l8.5-8.5Zm3 1.414a1.121 1.121 0 0 0-1.586 0L10 12.414V14h1.586l8.207-8.207a1.121 1.121 0 0 0 0-1.586ZM6 5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-4a1 1 0 1 1 2 0v4a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h4a1 1 0 1 1 0 2H6Z" clip-rule="evenodd"></path></svg>
|
|
7
|
+
<% end %>
|
|
8
|
+
|
|
9
|
+
<% if key.revocable? %>
|
|
10
|
+
<%= button_to revoke_key_path(key), title: "Revoke Key", class: "api-keys-action-revoke", data: { turbo_method: :post, turbo_confirm: "Are you sure you want to revoke this key? It will stop working immediately." } do %>
|
|
11
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M10.556 4a1 1 0 0 0-.97.751l-.292 1.14h5.421l-.293-1.14A1 1 0 0 0 13.453 4h-2.897Zm6.224 1.892-.421-1.639A3 3 0 0 0 13.453 2h-2.897A3 3 0 0 0 7.65 4.253l-.421 1.639H4a1 1 0 1 0 0 2h.1l1.215 11.425A3 3 0 0 0 8.3 22h7.4a3 3 0 0 0 2.984-2.683l1.214-11.425H20a1 1 0 1 0 0-2h-3.22Zm1.108 2H6.112l1.192 11.214A1 1 0 0 0 8.3 20h7.4a1 1 0 0 0 .995-.894l1.192-11.214ZM10 10a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z" clip-rule="evenodd"></path></svg>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% else %>
|
|
14
|
+
<span title="This key cannot be revoked" class="api-keys-action-disabled">
|
|
15
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" clip-rule="evenodd"></path></svg>
|
|
16
|
+
</span>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% else %>
|
|
19
|
+
—
|
|
20
|
+
<% end %>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%# Partial for displaying key type and environment badges %>
|
|
2
|
+
<%# Locals: key (required) - The ApiKey record %>
|
|
3
|
+
|
|
4
|
+
<% if key.key_type.present? %>
|
|
5
|
+
<% type_config = key.key_type_config %>
|
|
6
|
+
<% is_publishable = type_config&.dig(:revocable) == false %>
|
|
7
|
+
<span class="api-keys-badge api-keys-badge-type <%= is_publishable ? 'api-keys-badge-publishable' : 'api-keys-badge-secret' %>">
|
|
8
|
+
<%= key.key_type.humanize %>
|
|
9
|
+
</span>
|
|
10
|
+
<% end %>
|
|
11
|
+
|
|
12
|
+
<% if key.environment.present? %>
|
|
13
|
+
<% is_live = key.environment == 'live' %>
|
|
14
|
+
<span class="api-keys-badge api-keys-badge-env <%= is_live ? 'api-keys-badge-live' : 'api-keys-badge-test' %>">
|
|
15
|
+
<%= key.environment.upcase %>
|
|
16
|
+
</span>
|
|
17
|
+
<% end %>
|
|
@@ -1,27 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
<span style="color: red;">[Expired]</span>
|
|
10
|
-
<% end %>
|
|
11
|
-
<%= key.name.presence || "Secret key" %>
|
|
1
|
+
<%# Partial for displaying a single API key row in the table %>
|
|
2
|
+
<%# Locals: key (required) - The ApiKey record %>
|
|
3
|
+
|
|
4
|
+
<tr class="api-keys-row <%= 'api-keys-row-inactive' if defined?(inactive) && inactive %>">
|
|
5
|
+
<td class="api-keys-cell-name">
|
|
6
|
+
<%= render partial: 'api_keys/keys/key_status', locals: { key: key } %>
|
|
7
|
+
<%= key.name.presence || (key.key_type.present? ? "#{key.key_type.humanize} key" : "API key") %>
|
|
8
|
+
<%= render partial: 'api_keys/keys/key_badges', locals: { key: key } %>
|
|
12
9
|
</td>
|
|
13
|
-
<td><code><%= key.masked_token %></code></td>
|
|
14
10
|
|
|
11
|
+
<td class="api-keys-cell-token api-keys-token-cell">
|
|
12
|
+
<%= render partial: 'api_keys/keys/token_display', locals: { key: key } %>
|
|
13
|
+
</td>
|
|
15
14
|
|
|
16
|
-
<td title="<%= key.created_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
|
|
15
|
+
<td class="api-keys-cell-created" title="<%= key.created_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
|
|
17
16
|
<%= time_ago_in_words(key.created_at) %> ago
|
|
18
17
|
</td>
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
<td>
|
|
19
|
+
<td class="api-keys-cell-expires">
|
|
22
20
|
<% if key.expires_at? %>
|
|
23
21
|
<% if key.expired? %>
|
|
24
|
-
<strong
|
|
22
|
+
<strong class="api-keys-expired-text" title="<%= key.expires_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
|
|
25
23
|
Expired <%= time_ago_in_words(key.expires_at) %> ago
|
|
26
24
|
</strong>
|
|
27
25
|
<% else %>
|
|
@@ -34,39 +32,27 @@
|
|
|
34
32
|
<% end %>
|
|
35
33
|
</td>
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
<td>
|
|
35
|
+
<td class="api-keys-cell-last-used">
|
|
39
36
|
<% if key.last_used_at? %>
|
|
40
37
|
<span title="<%= key.last_used_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
|
|
41
38
|
<%= time_ago_in_words(key.last_used_at) %> ago
|
|
42
39
|
</span>
|
|
43
|
-
<%# TODO: Add relative time check (e.g., "within last 3 months") %>
|
|
44
40
|
<% else %>
|
|
45
41
|
<em>Never used</em>
|
|
46
42
|
<% end %>
|
|
47
43
|
</td>
|
|
48
44
|
|
|
49
|
-
|
|
50
|
-
<td>
|
|
45
|
+
<td class="api-keys-cell-scopes">
|
|
51
46
|
<% if key.scopes.present? %>
|
|
52
47
|
<% key.scopes.each do |scope| %>
|
|
53
|
-
<kbd class="tag is-small"><%= scope %></kbd>
|
|
48
|
+
<kbd class="api-keys-scope-tag tag is-small"><%= scope %></kbd>
|
|
54
49
|
<% end %>
|
|
55
50
|
<% else %>
|
|
56
51
|
—
|
|
57
52
|
<% end %>
|
|
58
53
|
</td>
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M16.793 2.793a3.121 3.121 0 1 1 4.414 4.414l-8.5 8.5A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l8.5-8.5Zm3 1.414a1.121 1.121 0 0 0-1.586 0L10 12.414V14h1.586l8.207-8.207a1.121 1.121 0 0 0 0-1.586ZM6 5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-4a1 1 0 1 1 2 0v4a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h4a1 1 0 1 1 0 2H6Z" clip-rule="evenodd"></path></svg>
|
|
63
|
-
<% end %>
|
|
64
|
-
<%= button_to api_keys.revoke_key_path(key), title: "Revoke Key", data: { turbo_method: :post, turbo_confirm: "Are you sure you want to revoke this key? It will stop working immediately." } do %>
|
|
65
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M10.556 4a1 1 0 0 0-.97.751l-.292 1.14h5.421l-.293-1.14A1 1 0 0 0 13.453 4h-2.897Zm6.224 1.892-.421-1.639A3 3 0 0 0 13.453 2h-2.897A3 3 0 0 0 7.65 4.253l-.421 1.639H4a1 1 0 1 0 0 2h.1l1.215 11.425A3 3 0 0 0 8.3 22h7.4a3 3 0 0 0 2.984-2.683l1.214-11.425H20a1 1 0 1 0 0-2h-3.22Zm1.108 2H6.112l1.192 11.214A1 1 0 0 0 8.3 20h7.4a1 1 0 0 0 .995-.894l1.192-11.214ZM10 10a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z" clip-rule="evenodd"></path></svg>
|
|
66
|
-
<% end %>
|
|
67
|
-
<% else %>
|
|
68
|
-
<%# No actions available for inactive/revoked/expired keys %>
|
|
69
|
-
—
|
|
70
|
-
<% end %>
|
|
54
|
+
|
|
55
|
+
<td class="api-keys-cell-actions api-keys-action-buttons">
|
|
56
|
+
<%= render partial: 'api_keys/keys/key_actions', locals: { key: key } %>
|
|
71
57
|
</td>
|
|
72
|
-
</tr>
|
|
58
|
+
</tr>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%# Partial for displaying key status indicator %>
|
|
2
|
+
<%# Locals: key (required) - The ApiKey record %>
|
|
3
|
+
|
|
4
|
+
<% if key.active? %>
|
|
5
|
+
<span class="api-keys-status api-keys-status-active"></span>
|
|
6
|
+
<% elsif key.revoked? %>
|
|
7
|
+
<span class="api-keys-status api-keys-status-revoked">[Revoked]</span>
|
|
8
|
+
<% elsif key.expired? %>
|
|
9
|
+
<span class="api-keys-status api-keys-status-expired">[Expired]</span>
|
|
10
|
+
<% end %>
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<thead>
|
|
13
13
|
<tr>
|
|
14
14
|
<th>Name</th>
|
|
15
|
-
<th>
|
|
15
|
+
<th>API Key</th>
|
|
16
16
|
<th>Created</th>
|
|
17
17
|
<th>Expires</th>
|
|
18
18
|
<th>Last Used</th>
|
|
@@ -28,9 +28,6 @@
|
|
|
28
28
|
|
|
29
29
|
<%# Render inactive keys below, visually distinct %>
|
|
30
30
|
<% if inactive_keys.any? %>
|
|
31
|
-
<%# Optional: Add a visual separator if desired %>
|
|
32
|
-
<%# <tr><td colspan="7" style="border-top: 2px solid #ccc; text-align: center; padding-top: 1em; color: #555;">Inactive Keys</td></tr> %>
|
|
33
|
-
|
|
34
31
|
<% inactive_keys.each do |key| %>
|
|
35
32
|
<%= render partial: 'api_keys/keys/key_row', locals: { key: key, inactive: true } %>
|
|
36
33
|
<% end %>
|
|
@@ -38,15 +35,10 @@
|
|
|
38
35
|
</tbody>
|
|
39
36
|
</table>
|
|
40
37
|
<% else %>
|
|
41
|
-
|
|
42
|
-
<h4>You don't have any API keys yet!</h4>
|
|
43
|
-
<p>Create your first API key to get started.</p>
|
|
44
|
-
<%# Consider adding a primary "Create Key" button here %>
|
|
45
|
-
<%#= link_to "Create New API Key", new_key_path, class: "button primary" %>
|
|
46
|
-
</div>
|
|
38
|
+
<%= render partial: 'api_keys/keys/empty_state' %>
|
|
47
39
|
<% end %>
|
|
48
40
|
</div>
|
|
49
41
|
|
|
50
42
|
</section>
|
|
51
43
|
|
|
52
|
-
<%# The separate inactive keys section is now removed as they are integrated above. %>
|
|
44
|
+
<%# The separate inactive keys section is now removed as they are integrated above. %>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<%# Partial for displaying publishable keys section %>
|
|
2
|
+
<%# Locals: active_keys (Active publishable keys), inactive_keys (Inactive publishable keys) %>
|
|
3
|
+
|
|
4
|
+
<section class="api-keys-section api-keys-publishable-section" aria-labelledby="publishable-keys-heading">
|
|
5
|
+
<h2 id="publishable-keys-heading">Publishable Keys</h2>
|
|
6
|
+
<p class="api-keys-section-description">
|
|
7
|
+
These keys are safe to embed in client-side applications and browser code.
|
|
8
|
+
You can view them anytime.
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<div class="api-keys-table-wrapper">
|
|
12
|
+
<% all_keys = active_keys + inactive_keys %>
|
|
13
|
+
<% if all_keys.any? %>
|
|
14
|
+
<table>
|
|
15
|
+
<thead>
|
|
16
|
+
<tr>
|
|
17
|
+
<th>Name</th>
|
|
18
|
+
<th>API Key</th>
|
|
19
|
+
<th>Created</th>
|
|
20
|
+
<th>Expires</th>
|
|
21
|
+
<th>Last Used</th>
|
|
22
|
+
<th>Permissions</th>
|
|
23
|
+
<th>Actions</th>
|
|
24
|
+
</tr>
|
|
25
|
+
</thead>
|
|
26
|
+
<tbody>
|
|
27
|
+
<% active_keys.each do |key| %>
|
|
28
|
+
<%= render partial: 'api_keys/keys/key_row', locals: { key: key } %>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<% inactive_keys.each do |key| %>
|
|
32
|
+
<%= render partial: 'api_keys/keys/key_row', locals: { key: key, inactive: true } %>
|
|
33
|
+
<% end %>
|
|
34
|
+
</tbody>
|
|
35
|
+
</table>
|
|
36
|
+
<% else %>
|
|
37
|
+
<%= render partial: 'api_keys/keys/empty_state', locals: { message: "No publishable keys yet." } %>
|
|
38
|
+
<% end %>
|
|
39
|
+
</div>
|
|
40
|
+
</section>
|