api_keys 0.6.0 → 0.7.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 +32 -0
- data/README.md +276 -1
- data/lib/api_keys/authentication.rb +51 -0
- data/lib/api_keys/configuration.rb +56 -0
- data/lib/api_keys/device_flow.rb +146 -0
- data/lib/api_keys/engine.rb +9 -0
- data/lib/api_keys/errors.rb +11 -0
- data/lib/api_keys/helpers/view_helpers.rb +2 -1
- data/lib/api_keys/idempotency.rb +155 -0
- data/lib/api_keys/models/api_key.rb +30 -3
- data/lib/api_keys/models/concerns/has_api_keys.rb +9 -1
- data/lib/api_keys/models/device_authorization.rb +333 -0
- data/lib/api_keys/models/idempotency_key.rb +184 -0
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +6 -0
- data/lib/generators/api_keys/add_created_by_generator.rb +50 -0
- data/lib/generators/api_keys/add_device_authorizations_generator.rb +51 -0
- data/lib/generators/api_keys/add_idempotency_keys_generator.rb +52 -0
- data/lib/generators/api_keys/templates/add_created_by_to_api_keys.rb.erb +33 -0
- data/lib/generators/api_keys/templates/create_api_keys_device_authorizations.rb.erb +70 -0
- data/lib/generators/api_keys/templates/create_api_keys_idempotency_keys.rb.erb +52 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +4 -0
- data/lib/generators/api_keys/templates/initializer.rb +18 -0
- metadata +11 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module ApiKeys
|
|
6
|
+
# The two endpoints of the OAuth 2.0 Device Authorization Grant (RFC 8628)
|
|
7
|
+
# for an API controller of the host app: `create` is the device
|
|
8
|
+
# authorization endpoint (§3.1, §3.2), and `token` is the token endpoint
|
|
9
|
+
# that the client polls (§3.4, §3.5). The host routes them and names the
|
|
10
|
+
# page where a person approves (ApiKeys::DeviceAuthorization#approve!):
|
|
11
|
+
#
|
|
12
|
+
# class Api::DeviceAuthorizationsController < ActionController::API
|
|
13
|
+
# include ApiKeys::DeviceFlow
|
|
14
|
+
#
|
|
15
|
+
# private
|
|
16
|
+
#
|
|
17
|
+
# def device_verification_uri(user_code = nil)
|
|
18
|
+
# device_approval_url(code: user_code)
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# post "api/device", to: "api/device_authorizations#create"
|
|
23
|
+
# post "api/token", to: "api/device_authorizations#token"
|
|
24
|
+
#
|
|
25
|
+
# Neither request carries a key. Errors follow RFC 6749 §5.2
|
|
26
|
+
# (`{ error, error_description }`, status 400), as OAuth device clients
|
|
27
|
+
# expect, and no answer may be cached (RFC 6749 §5.1).
|
|
28
|
+
module DeviceFlow
|
|
29
|
+
extend ActiveSupport::Concern
|
|
30
|
+
|
|
31
|
+
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" # RFC 8628 §3.4
|
|
32
|
+
|
|
33
|
+
# Override any of them with I18n: api_keys.device_flow.errors.<error>.
|
|
34
|
+
ERROR_DESCRIPTIONS = {
|
|
35
|
+
"authorization_pending" => "The person has not decided yet. Poll again after the interval.",
|
|
36
|
+
"slow_down" => "Polled too soon. Wait 5 seconds longer between polls from now on.",
|
|
37
|
+
"access_denied" => "The person denied the request.",
|
|
38
|
+
"expired_token" => "The codes expired. Ask for new ones.",
|
|
39
|
+
"invalid_grant" => "This device code is unknown, was used already, or belongs to another client_id.",
|
|
40
|
+
"unsupported_grant_type" => "Send grant_type=#{GRANT_TYPE}.",
|
|
41
|
+
"invalid_scope" => "Ask for scopes among: %{scopes}.",
|
|
42
|
+
# The token endpoint's invalid_scope (RFC 6749 §5.2: "exceeds the scope
|
|
43
|
+
# granted by the resource owner").
|
|
44
|
+
"scope_exceeds_approval" => "The app can no longer make the key the person approved. Ask for new codes.",
|
|
45
|
+
"invalid_request" => "Send client_id, the client's name, and device_name, the device's name: " \
|
|
46
|
+
"at most #{ApiKeys::DeviceAuthorization::MAX_NAME_LENGTH} characters each."
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# RFC 8628 §3.1 and §3.2. `scope` lists the scopes, space-separated
|
|
50
|
+
# (RFC 6749 §3.3). A client may ask for any of `config.default_scopes`
|
|
51
|
+
# that a key of `device_key_type` grants. A type whose scopes follow
|
|
52
|
+
# its ceiling grants the whole ceiling, so the request asks for all of it.
|
|
53
|
+
def create
|
|
54
|
+
key_type = device_key_type
|
|
55
|
+
ApiKeys::DeviceAuthorization.check_key_type!(key_type)
|
|
56
|
+
scopes = params.fetch(:scope) { device_default_scope }.to_s.split.uniq
|
|
57
|
+
if scopes.empty? || (scopes - ApiKeys::DeviceAuthorization.grantable_scopes(key_type)).any?
|
|
58
|
+
return render_oauth_error("invalid_scope")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
authorization = ApiKeys::DeviceAuthorization.request!(
|
|
62
|
+
client_id: params[:client_id].to_s.strip, device_name: params[:device_name].to_s.strip.presence,
|
|
63
|
+
scopes: scopes, key_type: key_type, requested_from: ApiKeys.configuration.client_ip_resolver.call(request)
|
|
64
|
+
)
|
|
65
|
+
render_without_cache json: {
|
|
66
|
+
device_code: authorization.device_code, user_code: authorization.user_code,
|
|
67
|
+
verification_uri: device_verification_uri,
|
|
68
|
+
verification_uri_complete: device_verification_uri(authorization.user_code),
|
|
69
|
+
expires_in: ApiKeys::DeviceAuthorization::LIFETIME.to_i, interval: authorization.interval
|
|
70
|
+
}
|
|
71
|
+
rescue ActiveRecord::RecordInvalid => error
|
|
72
|
+
render_oauth_error(error.record.errors.include?(:scopes) ? "invalid_scope" : "invalid_request")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# RFC 8628 §3.4 and §3.5. The answer that carries the token is the only
|
|
76
|
+
# one there is. A poll from another client_id changes nothing, so the
|
|
77
|
+
# grant waits for its own client.
|
|
78
|
+
def token
|
|
79
|
+
return render_oauth_error("unsupported_grant_type") unless params[:grant_type] == GRANT_TYPE
|
|
80
|
+
|
|
81
|
+
authorization = ApiKeys::DeviceAuthorization.find_by_device_code(params[:device_code])
|
|
82
|
+
return render_oauth_error("invalid_grant") unless authorization
|
|
83
|
+
|
|
84
|
+
poll = nil
|
|
85
|
+
body = ApiKeys::DeviceAuthorization.transaction do
|
|
86
|
+
poll = authorization.poll!(client_id: params[:client_id])
|
|
87
|
+
device_token_response(authorization, poll.api_key) if poll.api_key
|
|
88
|
+
end
|
|
89
|
+
if poll.error == "invalid_scope"
|
|
90
|
+
return render_oauth_error("invalid_scope", oauth_error_description("scope_exceeds_approval"))
|
|
91
|
+
end
|
|
92
|
+
return render_oauth_error(poll.error) if poll.error
|
|
93
|
+
|
|
94
|
+
render_without_cache json: body
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# The approval page of the host app, with the user code filled in when
|
|
100
|
+
# one is given (`verification_uri_complete`, RFC 8628 §3.3.1).
|
|
101
|
+
def device_verification_uri(_user_code = nil)
|
|
102
|
+
raise NotImplementedError,
|
|
103
|
+
"#{self.class.name} must define device_verification_uri(user_code = nil): the URL of its approval page"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The type of the keys this flow makes. With key types configured, it
|
|
107
|
+
# must name one; the default is `config.default_key_type`.
|
|
108
|
+
def device_key_type
|
|
109
|
+
ApiKeys.configuration.default_key_type
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The scope a request without `scope` asks for, space-separated. With
|
|
113
|
+
# nil (the default) such a request fails with invalid_scope, as RFC 6749
|
|
114
|
+
# §3.3 allows.
|
|
115
|
+
def device_default_scope
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The token response (RFC 6749 §5.1). It runs in the transaction that
|
|
120
|
+
# creates the key, so a row it writes (an audit entry) commits with the
|
|
121
|
+
# key, or neither does. Override it to add fields, and call super.
|
|
122
|
+
def device_token_response(_authorization, api_key)
|
|
123
|
+
{ access_token: api_key.token, token_type: "Bearer", scope: api_key.scopes.join(" ") }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# An error response (RFC 6749 §5.2). A rate limit's `with:` can use it:
|
|
127
|
+
# rate_limit to: 20, within: 1.hour, only: :create,
|
|
128
|
+
# with: -> { render_oauth_error("slow_down", "Too many requests.", status: :too_many_requests) }
|
|
129
|
+
def render_oauth_error(error, description = nil, status: :bad_request)
|
|
130
|
+
description ||= oauth_error_description(error)
|
|
131
|
+
render_without_cache json: { error: error, error_description: description.presence }.compact, status: status
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def oauth_error_description(name)
|
|
135
|
+
scopes = ApiKeys::DeviceAuthorization.grantable_scopes(device_key_type).join(" ")
|
|
136
|
+
I18n.t("api_keys.device_flow.errors.#{name}", default: ERROR_DESCRIPTIONS[name].to_s, scopes: scopes)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# RFC 6749 §5.1: an answer that carries a credential is never cached.
|
|
140
|
+
def render_without_cache(**options)
|
|
141
|
+
response.headers["Cache-Control"] = "no-store"
|
|
142
|
+
response.headers["Pragma"] = "no-cache"
|
|
143
|
+
render(**options)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
data/lib/api_keys/engine.rb
CHANGED
|
@@ -14,6 +14,15 @@ module ApiKeys
|
|
|
14
14
|
# value takes precedence over this fallback.
|
|
15
15
|
config.parent_controller = nil
|
|
16
16
|
|
|
17
|
+
# The device flow's codes are in its requests: a device code redeems a
|
|
18
|
+
# key, and a user code lets a person approve one. Request logs show
|
|
19
|
+
# neither, nor a token (RFC 8628 §5.1, RFC 6749 §10.3).
|
|
20
|
+
FILTERED_PARAMETERS = %i[device_code user_code token].freeze
|
|
21
|
+
|
|
22
|
+
initializer "api_keys.filter_parameters" do |app|
|
|
23
|
+
app.config.filter_parameters |= FILTERED_PARAMETERS
|
|
24
|
+
end
|
|
25
|
+
|
|
17
26
|
# Add has_api_keys method to ActiveRecord::Base
|
|
18
27
|
initializer "api_keys.active_record" do
|
|
19
28
|
ActiveSupport.on_load(:active_record) do
|
data/lib/api_keys/errors.rb
CHANGED
|
@@ -80,5 +80,16 @@ module ApiKeys
|
|
|
80
80
|
)
|
|
81
81
|
end
|
|
82
82
|
end
|
|
83
|
+
|
|
84
|
+
# Raised when a key is given a `created_by` but the columns are missing
|
|
85
|
+
class CreatedByMigrationRequiredError < BaseError
|
|
86
|
+
def initialize(message = nil)
|
|
87
|
+
super(
|
|
88
|
+
message ||
|
|
89
|
+
"A key was given a created_by, but the created_by columns are missing from api_keys. " \
|
|
90
|
+
"Run: rails generate api_keys:add_created_by && rails db:migrate"
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
83
94
|
end
|
|
84
95
|
end
|
|
@@ -31,7 +31,8 @@ module ApiKeys
|
|
|
31
31
|
"origin_not_allowed" => "the request came from a web origin the key does not allow",
|
|
32
32
|
"ip_not_allowed" => "the request came from an IP address the key does not allow",
|
|
33
33
|
"restriction_misconfigured" => "the key's stored request restrictions could not be verified",
|
|
34
|
-
"missing_scope" => "the key lacks a permission the endpoint requires"
|
|
34
|
+
"missing_scope" => "the key lacks a permission the endpoint requires",
|
|
35
|
+
"untrusted_key" => "the person behind the key no longer stands behind it"
|
|
35
36
|
}.freeze
|
|
36
37
|
UNKNOWN_REFUSAL_REASON = "the request was refused for an unrecognized reason"
|
|
37
38
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require_relative "authentication"
|
|
5
|
+
|
|
6
|
+
module ApiKeys
|
|
7
|
+
# Writes that run once per Idempotency-Key, for an API controller that
|
|
8
|
+
# authenticates with `authenticate_api_key!`
|
|
9
|
+
# (draft-ietf-httpapi-idempotency-key-header, ApiKeys::IdempotencyKey):
|
|
10
|
+
#
|
|
11
|
+
# class Api::BaseController < ActionController::API
|
|
12
|
+
# include ApiKeys::Controller
|
|
13
|
+
# include ApiKeys::Idempotency
|
|
14
|
+
#
|
|
15
|
+
# before_action :authenticate_api_key!
|
|
16
|
+
# around_action :run_once_per_idempotency_key
|
|
17
|
+
#
|
|
18
|
+
# private
|
|
19
|
+
#
|
|
20
|
+
# # The JSON for a record: the first answer and every retry use it.
|
|
21
|
+
# def resource_json(record, **options)
|
|
22
|
+
# record.as_json(only: %i[id name])
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# # Whether this API key may read the record now. A retry answers only
|
|
26
|
+
# # records it may read: a record moved to another owner is not found.
|
|
27
|
+
# def idempotent_replay_allowed?(record)
|
|
28
|
+
# record.owner == current_api_owner
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# class Api::ProjectsController < Api::BaseController
|
|
33
|
+
# def create
|
|
34
|
+
# render_resource current_api_owner.projects.create!(project_params), status: :created
|
|
35
|
+
# end
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# The around_action runs outside any transaction the app opens: the key's
|
|
39
|
+
# claim must commit on its own (ApiKeys::IdempotencyKey.hold refuses).
|
|
40
|
+
#
|
|
41
|
+
# Every request but GET and HEAD needs the header. A write answers with
|
|
42
|
+
# `render_resource`, so the key keeps the record it made or changed, and a
|
|
43
|
+
# retry with the same key and the same request answers that record as it
|
|
44
|
+
# is now, with `Idempotent-Replayed: true`. Refusals render through
|
|
45
|
+
# `render_unauthorized`, like the gem's other refusals:
|
|
46
|
+
#
|
|
47
|
+
# 400 idempotency_key_required no header, or longer than 255 characters
|
|
48
|
+
# 409 idempotency_key_in_use a request with this key still runs
|
|
49
|
+
# 422 idempotency_key_reused the key was used for another request
|
|
50
|
+
module Idempotency
|
|
51
|
+
extend ActiveSupport::Concern
|
|
52
|
+
include ApiKeys::Authentication
|
|
53
|
+
|
|
54
|
+
REPLAYED_HEADER = "Idempotent-Replayed"
|
|
55
|
+
# The methods a host defines: the gem cannot know how to render a record,
|
|
56
|
+
# or who may read it.
|
|
57
|
+
HOST_METHODS = %i[resource_json idempotent_replay_allowed?].freeze
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# The around_action. A write and its answer commit together; a refused
|
|
62
|
+
# write (any status but 2xx) or an error commits nothing and leaves the
|
|
63
|
+
# key free for the next attempt.
|
|
64
|
+
def run_once_per_idempotency_key
|
|
65
|
+
return yield if reading_request?
|
|
66
|
+
raise ApiKeys::Error, "run authenticate_api_key! before run_once_per_idempotency_key" unless current_api_key
|
|
67
|
+
|
|
68
|
+
require_idempotency_host_methods!
|
|
69
|
+
|
|
70
|
+
key = request.headers[ApiKeys::IdempotencyKey::HEADER].to_s
|
|
71
|
+
if key.blank? || key.length > ApiKeys::IdempotencyKey::MAX_LENGTH
|
|
72
|
+
return render_unauthorized(
|
|
73
|
+
error_code: :idempotency_key_required, status: :bad_request,
|
|
74
|
+
message: "Send an Idempotency-Key header with every write: a unique text of " \
|
|
75
|
+
"1 to #{ApiKeys::IdempotencyKey::MAX_LENGTH} characters, the same when you retry."
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
ApiKeys::IdempotencyKey.hold(current_api_key, key) do |record|
|
|
80
|
+
next replay_idempotent_request(record) if record.answered?
|
|
81
|
+
|
|
82
|
+
record.update!(request_digest: idempotency_request_digest)
|
|
83
|
+
yield
|
|
84
|
+
response.successful? ? record.answer!(response.status, @api_keys_idempotent_resource) : raise(ActiveRecord::Rollback)
|
|
85
|
+
end
|
|
86
|
+
rescue ApiKeys::IdempotencyKey::Busy
|
|
87
|
+
render_unauthorized(error_code: :idempotency_key_in_use, status: :conflict,
|
|
88
|
+
message: "A request with this Idempotency-Key is still running. Retry in a moment.")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A write's answer: the record it made or changed, as `resource_json`
|
|
92
|
+
# renders it. The key keeps the record, so a retry answers the same way.
|
|
93
|
+
# Options go to `resource_json` for this answer only: a secret shown
|
|
94
|
+
# once is never in a retry's answer.
|
|
95
|
+
def render_resource(record, status: :ok, **options)
|
|
96
|
+
@api_keys_idempotent_resource = record
|
|
97
|
+
render json: resource_json(record, **options), status: status
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The JSON for a record. Define it in the host controller.
|
|
101
|
+
def resource_json(_record, **_options)
|
|
102
|
+
raise NotImplementedError,
|
|
103
|
+
"#{self.class.name} must define resource_json(record, **options) for ApiKeys::Idempotency"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Whether current_api_key may read +record+ now. Define it in the host
|
|
107
|
+
# controller. A retry runs no action, so this is the only check before a
|
|
108
|
+
# record's current state goes back to the client.
|
|
109
|
+
def idempotent_replay_allowed?(_record)
|
|
110
|
+
raise NotImplementedError,
|
|
111
|
+
"#{self.class.name} must define idempotent_replay_allowed?(record) for ApiKeys::Idempotency: " \
|
|
112
|
+
"whether current_api_key may read the record that a retry answers with"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Both host methods are checked on the first write, not on the first
|
|
116
|
+
# retry, so a missing one shows in development and tests.
|
|
117
|
+
def require_idempotency_host_methods!
|
|
118
|
+
missing = HOST_METHODS.select { |name| method(name).owner == ApiKeys::Idempotency }
|
|
119
|
+
return if missing.empty?
|
|
120
|
+
|
|
121
|
+
raise NotImplementedError, "#{self.class.name} must define #{missing.join(' and ')} for ApiKeys::Idempotency: " \
|
|
122
|
+
"resource_json(record, **options) renders a record, and " \
|
|
123
|
+
"idempotent_replay_allowed?(record) answers whether current_api_key may read it"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The answer to a retry: the record as it is now, never a stored body,
|
|
127
|
+
# so it is current and holds no secret. A record that no longer exists,
|
|
128
|
+
# or that this API key may no longer read, is not found
|
|
129
|
+
# (ActiveRecord::RecordNotFound, which Rails answers 404).
|
|
130
|
+
def replay_idempotent_request(record)
|
|
131
|
+
if record.request_digest != idempotency_request_digest
|
|
132
|
+
return render_unauthorized(error_code: :idempotency_key_reused, status: 422,
|
|
133
|
+
message: "This Idempotency-Key was used for another request. Use a new key.")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
resource = record.resource
|
|
137
|
+
unless resource && idempotent_replay_allowed?(resource)
|
|
138
|
+
raise ActiveRecord::RecordNotFound, "the record of this Idempotency-Key is not found for this API key"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
response.headers[REPLAYED_HEADER] = "true"
|
|
142
|
+
render json: resource_json(resource), status: record.response_status
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The request a key stands for: the method, the path, the query, and
|
|
146
|
+
# the body. The query and the body count apart, so a name that moves
|
|
147
|
+
# from one to the other makes another request.
|
|
148
|
+
def idempotency_request_digest
|
|
149
|
+
@api_keys_idempotency_request_digest ||= ApiKeys::IdempotencyKey.digest_of(
|
|
150
|
+
request.request_method, request.path,
|
|
151
|
+
{ "query" => request.query_parameters, "body" => request.request_parameters }
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -10,6 +10,9 @@ module ApiKeys
|
|
|
10
10
|
# The core ActiveRecord model representing an API key.
|
|
11
11
|
class ApiKey < ActiveRecord::Base
|
|
12
12
|
MAX_SCOPES = 100
|
|
13
|
+
# A key name: letters, numbers, underscores, hyphens and spaces.
|
|
14
|
+
MAX_NAME_LENGTH = 60
|
|
15
|
+
NAME_FORMAT = /\A[a-zA-Z0-9_ -]+\z/
|
|
13
16
|
MAX_SCOPE_BYTESIZE = 128
|
|
14
17
|
MAX_METADATA_BYTESIZE = 16_384
|
|
15
18
|
MAX_RESTRICTION_ENTRIES = 100
|
|
@@ -29,11 +32,17 @@ module ApiKeys
|
|
|
29
32
|
revoked_key expired_key unknown_key_type unknown_environment
|
|
30
33
|
environment_misconfigured environment_mismatch
|
|
31
34
|
origin_not_allowed ip_not_allowed restriction_misconfigured missing_scope
|
|
35
|
+
untrusted_key
|
|
32
36
|
].freeze
|
|
37
|
+
# The person who created the key (0.7.0). Optional columns.
|
|
38
|
+
CREATED_BY_COLUMNS = %w[created_by_type created_by_id].freeze
|
|
33
39
|
# Deliberately excludes `restrictions`: owners must be able to tighten a
|
|
34
|
-
# request policy even when the key itself is non-revocable.
|
|
40
|
+
# request policy even when the key itself is non-revocable. Includes the
|
|
41
|
+
# person who created the key: `config.trusted_key_check` may trust a key
|
|
42
|
+
# because of that person, so no update can name another one.
|
|
35
43
|
IMMUTABLE_IDENTITY_ATTRIBUTES = %w[
|
|
36
44
|
token_digest digest_algorithm prefix last4 owner_type owner_id key_type environment
|
|
45
|
+
created_by_type created_by_id
|
|
37
46
|
].freeze
|
|
38
47
|
|
|
39
48
|
self.table_name = "api_keys"
|
|
@@ -43,6 +52,10 @@ module ApiKeys
|
|
|
43
52
|
|
|
44
53
|
# == Associations ==
|
|
45
54
|
belongs_to :owner, polymorphic: true, optional: true
|
|
55
|
+
# The person behind the key, when the host names one at creation:
|
|
56
|
+
# `create_api_key!(created_by: current_user)`. Needs the created_by
|
|
57
|
+
# columns (`rails generate api_keys:add_created_by`).
|
|
58
|
+
belongs_to :created_by, polymorphic: true, optional: true
|
|
46
59
|
|
|
47
60
|
# == Attributes & Serialization ==
|
|
48
61
|
# Expose the plaintext token only immediately after creation
|
|
@@ -52,6 +65,11 @@ module ApiKeys
|
|
|
52
65
|
# saves can never set or clear it, so no update path (a form, mass
|
|
53
66
|
# assignment, a stray `update`) can reopen a handoff that was claimed.
|
|
54
67
|
attr_readonly :token_shown_at
|
|
68
|
+
# The person behind the key is fixed at creation. `config.trusted_key_check`
|
|
69
|
+
# may trust a key because of that person, so no save (with or without
|
|
70
|
+
# validations) and no update_column can name another one. A backfill of
|
|
71
|
+
# keys made before the columns existed writes them with SQL (update_all).
|
|
72
|
+
attr_readonly :created_by_type, :created_by_id
|
|
55
73
|
attr_accessor :expires_at_preset
|
|
56
74
|
|
|
57
75
|
# JSON attributes (:scopes, :metadata) are defined in the engine initializer
|
|
@@ -138,9 +156,9 @@ module ApiKeys
|
|
|
138
156
|
# validates :scopes, presence: true # Default handled by attribute def
|
|
139
157
|
# validates :metadata, presence: true # Default handled by attribute def
|
|
140
158
|
validates :name,
|
|
141
|
-
length: { maximum:
|
|
159
|
+
length: { maximum: MAX_NAME_LENGTH },
|
|
142
160
|
# Allow letters, numbers, underscores, hyphens, and spaces. No leading/trailing spaces.
|
|
143
|
-
format: { with:
|
|
161
|
+
format: { with: NAME_FORMAT, message: "can only contain letters, numbers, underscores, hyphens, and spaces (no leading / trailing spaces)" },
|
|
144
162
|
allow_blank: true # Apply length and format only if name is present
|
|
145
163
|
|
|
146
164
|
validates :name, presence: true, if: :name_required? # Only require presence conditionally
|
|
@@ -564,6 +582,15 @@ module ApiKeys
|
|
|
564
582
|
false
|
|
565
583
|
end
|
|
566
584
|
|
|
585
|
+
# Whether the `created_by` columns exist. Installations that predate
|
|
586
|
+
# v0.7.0 keep working untouched until they run the generator.
|
|
587
|
+
# @return [Boolean]
|
|
588
|
+
def self.created_by_columns?
|
|
589
|
+
(CREATED_BY_COLUMNS - column_names).empty?
|
|
590
|
+
rescue StandardError
|
|
591
|
+
false
|
|
592
|
+
end
|
|
593
|
+
|
|
567
594
|
# Whether the refused-traffic columns exist. Installations that predate
|
|
568
595
|
# v0.6.0 keep authenticating untouched until they run the generator.
|
|
569
596
|
# @return [Boolean]
|
|
@@ -225,13 +225,20 @@ module ApiKeys
|
|
|
225
225
|
# Accepts the raw string a form field submits ("example.com, *.example.com").
|
|
226
226
|
# @param allowed_ips [String, Array, nil] Convenience form of the IP list
|
|
227
227
|
# ("203.0.113.7, 10.0.0.0/8").
|
|
228
|
+
# @param created_by [ActiveRecord::Base, nil] The person behind the key, for
|
|
229
|
+
# `config.trusted_key_check`. Needs the created_by columns.
|
|
228
230
|
# @return [ApiKeys::ApiKey] The newly created ApiKey instance. The plaintext token
|
|
229
231
|
# is available via the `#token` attribute on this instance
|
|
230
232
|
# *only until it's reloaded*.
|
|
231
233
|
def create_api_key!(name: nil, scopes: nil, expires_at: nil, expires_at_preset: nil, metadata: nil,
|
|
232
|
-
key_type: nil, environment: nil, restrictions: nil, allowed_origins: nil, allowed_ips: nil
|
|
234
|
+
key_type: nil, environment: nil, restrictions: nil, allowed_origins: nil, allowed_ips: nil,
|
|
235
|
+
created_by: nil)
|
|
233
236
|
config = ApiKeys.configuration
|
|
234
237
|
|
|
238
|
+
if created_by && !ApiKeys::ApiKey.created_by_columns?
|
|
239
|
+
raise ApiKeys::Errors::CreatedByMigrationRequiredError
|
|
240
|
+
end
|
|
241
|
+
|
|
235
242
|
# Parse expires_at_preset if provided (takes precedence over expires_at)
|
|
236
243
|
if expires_at_preset.present?
|
|
237
244
|
expires_at = ApiKeys::Helpers::ExpirationOptions.parse(expires_at_preset)
|
|
@@ -302,6 +309,7 @@ module ApiKeys
|
|
|
302
309
|
# prefix, token_digest, digest_algorithm are set by ApiKey callbacks
|
|
303
310
|
}
|
|
304
311
|
attributes[:restrictions] = requested_restrictions if requested_restrictions
|
|
312
|
+
attributes[:created_by] = created_by if created_by
|
|
305
313
|
|
|
306
314
|
api_key = self.class.transaction do
|
|
307
315
|
self.api_keys.create!(**attributes)
|