api_keys 0.4.3 → 0.5.1

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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +195 -27
  4. data/SECURITY.md +21 -2
  5. data/app/controllers/api_keys/keys_controller.rb +137 -11
  6. data/app/views/api_keys/keys/_form.html.erb +80 -17
  7. data/app/views/api_keys/keys/_key_badges.html.erb +7 -0
  8. data/app/views/api_keys/keys/_restriction_fields.html.erb +34 -0
  9. data/app/views/api_keys/security/best_practices.html.erb +1 -1
  10. data/app/views/layouts/api_keys/application.html.erb +9 -1
  11. data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
  12. data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
  13. data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -0
  14. data/lib/api_keys/authentication.rb +7 -1
  15. data/lib/api_keys/configuration.rb +54 -8
  16. data/lib/api_keys/engine.rb +6 -0
  17. data/lib/api_keys/errors.rb +11 -0
  18. data/lib/api_keys/form_builder_extensions.rb +40 -3
  19. data/lib/api_keys/helpers/token_session.rb +92 -3
  20. data/lib/api_keys/models/api_key.rb +240 -11
  21. data/lib/api_keys/models/concerns/has_api_keys.rb +67 -10
  22. data/lib/api_keys/restrictions.rb +430 -0
  23. data/lib/api_keys/services/authenticator.rb +110 -12
  24. data/lib/api_keys/version.rb +1 -1
  25. data/lib/api_keys.rb +1 -0
  26. data/lib/generators/api_keys/add_key_types_generator.rb +1 -1
  27. data/lib/generators/api_keys/add_restrictions_generator.rb +60 -0
  28. data/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb +54 -0
  29. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +22 -0
  30. data/lib/generators/api_keys/templates/initializer.rb +49 -6
  31. metadata +8 -1
@@ -4,6 +4,7 @@ require "active_record"
4
4
  require "json"
5
5
  require_relative "../services/token_generator"
6
6
  require_relative "../services/digestor"
7
+ require_relative "../restrictions"
7
8
 
8
9
  module ApiKeys
9
10
  # The core ActiveRecord model representing an API key.
@@ -11,6 +12,11 @@ module ApiKeys
11
12
  MAX_SCOPES = 100
12
13
  MAX_SCOPE_BYTESIZE = 128
13
14
  MAX_METADATA_BYTESIZE = 16_384
15
+ MAX_RESTRICTION_ENTRIES = 100
16
+ MAX_RESTRICTION_ENTRY_BYTESIZE = 255
17
+ RESTRICTIONS_COLUMN = "restrictions"
18
+ # Deliberately excludes `restrictions`: owners must be able to tighten a
19
+ # request policy even when the key itself is non-revocable.
14
20
  IMMUTABLE_IDENTITY_ATTRIBUTES = %w[
15
21
  token_digest digest_algorithm prefix last4 owner_type owner_id key_type environment
16
22
  ].freeze
@@ -26,6 +32,7 @@ module ApiKeys
26
32
  # == Attributes & Serialization ==
27
33
  # Expose the plaintext token only immediately after creation
28
34
  attr_reader :token
35
+ attr_accessor :expires_at_preset
29
36
 
30
37
  # JSON attributes (:scopes, :metadata) are defined in the engine initializer
31
38
  # using ActiveSupport.on_load(:active_record) to ensure DB connection is ready.
@@ -45,6 +52,64 @@ module ApiKeys
45
52
  super(cleaned)
46
53
  end
47
54
 
55
+ # == Request Restrictions ==
56
+ # Where this key may be used from. Reads always answer with a value object,
57
+ # so `key.restrictions.origins` works even on a key that has none.
58
+ #
59
+ # @return [ApiKeys::Restrictions]
60
+ def restrictions
61
+ return ApiKeys::Restrictions.none unless self.class.restrictions_column?
62
+
63
+ ApiKeys::Restrictions.wrap(self[:restrictions])
64
+ end
65
+
66
+ # Accepts a Restrictions instance, a hash of lists, or nil. Hashes are
67
+ # normalized into the storage shape; a kind given as `nil` or blank text
68
+ # is cleared, as the list writers do. Anything malformed is stored
69
+ # untouched so the validation, rather than a silent coercion, reports it.
70
+ def restrictions=(value)
71
+ ensure_restrictions_column!
72
+
73
+ normalized = if value.nil?
74
+ {}
75
+ elsif value.is_a?(Hash)
76
+ input = ApiKeys::Restrictions.normalize_input(value)
77
+ wrapped = ApiKeys::Restrictions.wrap(input)
78
+ wrapped.malformed? ? input : wrapped.to_h
79
+ elsif value.is_a?(ApiKeys::Restrictions)
80
+ value.malformed? ? { "__malformed__" => true } : value.to_h
81
+ else
82
+ value
83
+ end
84
+ super(normalized)
85
+ end
86
+
87
+ # @return [Array<String>] Allowed web origins (hosts and `*.host` wildcards).
88
+ def allowed_origins
89
+ restrictions.origins
90
+ end
91
+
92
+ # @return [Array<String>] Allowed IP addresses and CIDR ranges.
93
+ def allowed_ips
94
+ restrictions.ips
95
+ end
96
+
97
+ # Accepts an array or a raw string ("example.com, *.example.com") and
98
+ # normalizes it, so host applications never need their own parser.
99
+ def allowed_origins=(value)
100
+ self.restrictions = restrictions.to_h.merge("origins" => ApiKeys::Restrictions.normalize_origins(value))
101
+ end
102
+
103
+ # Accepts an array or a raw string ("203.0.113.7, 10.0.0.0/8").
104
+ def allowed_ips=(value)
105
+ self.restrictions = restrictions.to_h.merge("ips" => ApiKeys::Restrictions.normalize_ips(value))
106
+ end
107
+
108
+ # @return [Boolean] true when this key carries any request restriction.
109
+ def restricted?
110
+ restrictions.restricted?
111
+ end
112
+
48
113
  # == Validations ==
49
114
  validates :token_digest, presence: true, uniqueness: { case_sensitive: true }
50
115
  validates :prefix, presence: true, length: { maximum: 64 }
@@ -73,6 +138,8 @@ module ApiKeys
73
138
  validate :token_digest_matches_algorithm
74
139
  validate :token_identifiers_are_well_formed
75
140
  validate :metadata_is_well_formed
141
+ validate :restrictions_are_well_formed
142
+ validate :restrictions_respect_key_type, if: -> { key_type.present? }
76
143
  validate :authentication_identity_is_immutable, on: :update
77
144
 
78
145
  # TODO: Add validation for scope string format
@@ -101,7 +168,15 @@ module ApiKeys
101
168
  # .publishable returns only keys with key_type: "publishable"
102
169
  # .secret returns keys that are NOT publishable (includes legacy keys with nil/blank key_type)
103
170
  scope :publishable, -> { where(key_type: "publishable") }
104
- scope :secret, -> { where.not(key_type: "publishable") }
171
+ # SQL `!=` excludes NULL, so include pre-key-types rows explicitly. Those
172
+ # legacy credentials have always had secret-key capabilities.
173
+ scope :secret, -> {
174
+ where(key_type: nil).or(where.not(key_type: "publishable"))
175
+ }
176
+
177
+ # Keys that carry request restrictions, and keys usable from anywhere.
178
+ scope :restricted, -> { where.not(restrictions: [nil, {}]) }
179
+ scope :unrestricted, -> { where(restrictions: [nil, {}]) }
105
180
 
106
181
  # === Usage Analytics Scopes ===
107
182
  # These scopes help admin dashboards analyze API key usage patterns.
@@ -139,9 +214,30 @@ module ApiKeys
139
214
 
140
215
  # == Instance Methods ==
141
216
 
217
+ # Revocation is the emergency control, so it must not hinge on whether the
218
+ # rest of the row still satisfies today's policy. A key issued before its
219
+ # type's permission or restriction ceiling was narrowed keeps
220
+ # authenticating within the narrower ceiling; refusing to revoke it would
221
+ # leave a credential nobody can switch off.
222
+ #
223
+ # The revocability guard always applies. When the revocation is the only
224
+ # pending change, the row is saved without validations: only `revoked_at`
225
+ # (and `updated_at`) are written, and save callbacks run as before. Other
226
+ # unsaved edits on the same instance are never written unvalidated: in
227
+ # that case the whole save is validated, exactly as before 0.5.1.
228
+ #
229
+ # The authenticator's token cache holds only a key ID and reloads the row
230
+ # on every hit, so the next request is refused as revoked with no cache
231
+ # invalidation needed.
142
232
  def revoke!
143
233
  raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
144
- update!(revoked_at: Time.current)
234
+
235
+ self.revoked_at = Time.current
236
+ if persisted? && (changes_to_save.keys - %w[revoked_at]).empty?
237
+ save!(validate: false)
238
+ else
239
+ save!
240
+ end
145
241
  end
146
242
 
147
243
  def revoked?
@@ -201,9 +297,12 @@ module ApiKeys
201
297
  # Keys with a key_type check the configuration
202
298
  def revocable?
203
299
  return true if key_type.blank?
204
- config = key_type_config
205
- return false if config.nil?
206
- config.fetch(:revocable, true)
300
+ self.class.revocable_for(key_type_config)
301
+ end
302
+
303
+ # Non-revocable keys are permanent by design; other key types may expire.
304
+ def expirable?
305
+ revocable?
207
306
  end
208
307
 
209
308
  # Returns the configuration hash for this key's type
@@ -220,17 +319,17 @@ module ApiKeys
220
319
  configured_pair&.last
221
320
  end
222
321
 
223
- # Returns true if this key type is configured as public AND non-revocable.
224
- # Only these keys have their plaintext token stored in metadata for later viewing.
322
+ # Returns true if this key type is explicitly configured as public.
323
+ # Only these keys have their plaintext token stored for later viewing.
225
324
  # This is used for publishable keys that are designed to be embedded in distributed apps.
226
325
  def public_key_type?
227
326
  return false if key_type.blank?
228
327
  config = key_type_config
229
328
  return false if config.nil?
230
- config[:public] == true && config[:revocable] == false
329
+ config[:public] == true
231
330
  end
232
331
 
233
- # Returns the stored plaintext token for public, non-revocable keys.
332
+ # Returns the stored plaintext token for public keys.
234
333
  # Returns nil for all other key types (the token is only available at creation time).
235
334
  # @return [String, nil] The full plaintext token, or nil if not stored
236
335
  def viewable_token
@@ -274,6 +373,34 @@ module ApiKeys
274
373
  ceiling == :all || ceiling.include?(required)
275
374
  end
276
375
 
376
+ # Whether this key's scope list is enforced, so an empty list grants no
377
+ # permissions. True for typed keys, whenever `key_types` or the global
378
+ # `default_scopes` are configured, and when the owner model's
379
+ # `has_api_keys` declares `default_scopes`. The last case is read through
380
+ # `owner`, so a key that does not know its owner yet (a bare `ApiKey.new`)
381
+ # cannot see it: build keys from their owner (`owner.api_keys.build`).
382
+ # False otherwise, where an empty list keeps its legacy meaning:
383
+ # unrestricted. Forms use it to decide whether "every box cleared" can be
384
+ # submitted as an empty list without widening the key.
385
+ #
386
+ # @return [Boolean]
387
+ def scopes_enforced?
388
+ scope_policy_enabled?
389
+ end
390
+
391
+ # Whether a scope is inside this key's current permission ceiling: its
392
+ # type's `permissions:`, or the configured scopes for an untyped key.
393
+ # A held scope outside it (left behind when a ceiling was narrowed) is
394
+ # already denied by `allows_scope?` and fails the ceiling validation on
395
+ # the next save.
396
+ #
397
+ # @param scope [String, Symbol]
398
+ # @return [Boolean]
399
+ def scope_within_ceiling?(scope)
400
+ ceiling = permission_ceiling
401
+ ceiling == :all || ceiling.include?(scope.to_s)
402
+ end
403
+
277
404
  # Alias for scopes - provides a more user-friendly API that matches
278
405
  # the configuration DSL where key types use `permissions` for scope ceiling.
279
406
  # Note: We use a method instead of alias_method because `scopes` is defined
@@ -306,8 +433,41 @@ module ApiKeys
306
433
  # == Class Methods ==
307
434
  # Most creation logic is handled by standard ActiveRecord methods + callbacks
308
435
 
436
+ # Whether the `restrictions` column exists. Installations that predate
437
+ # v0.5.0 keep working untouched until they run the generator.
438
+ # @return [Boolean]
439
+ def self.restrictions_column?
440
+ column_names.include?(RESTRICTIONS_COLUMN)
441
+ rescue StandardError
442
+ false
443
+ end
444
+
445
+ # Single source of truth for a key type's request-restriction ceiling.
446
+ # Omitting the setting allows every supported restriction kind.
447
+ def self.restriction_kinds_for(type_config)
448
+ return ApiKeys::Restrictions::KINDS.dup unless type_config.is_a?(Hash) && type_config.key?(:restrictions)
449
+
450
+ Array(type_config[:restrictions]).map(&:to_sym)
451
+ end
452
+
453
+ # Single source of truth for lifecycle policy in model and dashboard code.
454
+ def self.revocable_for(type_config)
455
+ type_config.is_a?(Hash) && type_config.fetch(:revocable, true)
456
+ end
457
+
458
+ # @return [Array<Symbol>] Restriction kinds this key's type permits.
459
+ def allowed_restriction_kinds
460
+ self.class.restriction_kinds_for(key_type_config)
461
+ end
462
+
309
463
  private
310
464
 
465
+ def ensure_restrictions_column!
466
+ return if self.class.restrictions_column?
467
+
468
+ raise ApiKeys::Errors::RestrictionsMigrationRequiredError
469
+ end
470
+
311
471
  # Set defaults for attributes not handled by the `attribute` API in the engine.
312
472
  def set_defaults
313
473
  # NOTE: Defaults for scopes/metadata handled by `attribute` definitions in engine initializer.
@@ -390,10 +550,11 @@ module ApiKeys
390
550
  self.expires_at = ApiKeys.configuration.expire_after.from_now
391
551
  end
392
552
 
393
- # Store plaintext token in metadata for public, non-revocable keys.
553
+ # Store plaintext token in metadata for explicitly public keys.
394
554
  # This allows users to view the token again in the dashboard.
395
555
  # SECURITY: Only do this for keys explicitly configured as public: true
396
- # AND revocable: false (e.g., publishable keys for distributed apps).
556
+ # Public key types must have a finite permission ceiling, but may be
557
+ # revocable and expirable like any other credential.
397
558
  if public_key_type?
398
559
  self.metadata = (self.metadata || {}).merge("token" => @token)
399
560
  end
@@ -467,6 +628,74 @@ module ApiKeys
467
628
  errors.add(:metadata, "must contain valid JSON data")
468
629
  end
469
630
 
631
+ # Restrictions are security policy: a malformed list must fail loudly at
632
+ # write time rather than quietly protecting nothing at authentication time.
633
+ def restrictions_are_well_formed
634
+ return unless self.class.restrictions_column?
635
+
636
+ raw = self[:restrictions]
637
+ return if raw.nil?
638
+
639
+ unless raw.is_a?(Hash)
640
+ errors.add(:restrictions, "must be an object")
641
+ return
642
+ end
643
+
644
+ unknown_kinds = raw.keys.map(&:to_s) - ApiKeys::Restrictions::KIND_NAMES
645
+ if unknown_kinds.any?
646
+ errors.add(:restrictions, "contains unknown restriction kinds: #{unknown_kinds.sort.join(', ')}")
647
+ end
648
+
649
+ validate_restriction_list(:origins) { |entry| ApiKeys::Restrictions.valid_origin_entry?(entry) }
650
+ validate_restriction_list(:ips) { |entry| ApiKeys::Restrictions.valid_ip_entry?(entry) }
651
+
652
+ # Whatever made the policy malformed, it never saves: a policy that
653
+ # authentication would refuse must not be written in the first place.
654
+ if restrictions.malformed? && errors[:restrictions].empty?
655
+ errors.add(:restrictions, "must contain valid restriction data")
656
+ end
657
+ end
658
+
659
+ def validate_restriction_list(kind)
660
+ entries = restrictions.public_send(kind)
661
+
662
+ if entries.length > MAX_RESTRICTION_ENTRIES
663
+ errors.add(:restrictions, "#{kind} cannot contain more than #{MAX_RESTRICTION_ENTRIES} entries")
664
+ end
665
+
666
+ if entries.any? { |entry| !valid_restriction_entry_size?(entry) }
667
+ errors.add(:restrictions, "#{kind} entries cannot exceed #{MAX_RESTRICTION_ENTRY_BYTESIZE} bytes")
668
+ end
669
+
670
+ return if entries.all? { |entry| yield(entry) }
671
+
672
+ message = if kind == :origins
673
+ "origins must be bare hosts like example.com or *.example.com"
674
+ else
675
+ "ips must be valid IPv4/IPv6 addresses or CIDR ranges"
676
+ end
677
+ errors.add(:restrictions, message)
678
+ end
679
+
680
+ # Non-string entries are reported by the shape check below, not here.
681
+ def valid_restriction_entry_size?(entry)
682
+ return true unless entry.is_a?(String)
683
+ return false unless entry.valid_encoding?
684
+
685
+ entry.bytesize <= MAX_RESTRICTION_ENTRY_BYTESIZE
686
+ rescue ArgumentError
687
+ false
688
+ end
689
+
690
+ # Key types may declare a ceiling on the restriction kinds their keys carry,
691
+ # mirroring the way `permissions:` caps scopes.
692
+ def restrictions_respect_key_type
693
+ exceeded = restrictions.kinds - allowed_restriction_kinds
694
+ return if exceeded.empty?
695
+
696
+ errors.add(:restrictions, "#{exceeded.sort.join(', ')} are not allowed for #{key_type} keys")
697
+ end
698
+
470
699
  def authentication_identity_is_immutable
471
700
  IMMUTABLE_IDENTITY_ATTRIBUTES.each do |attribute_name|
472
701
  next unless will_save_change_to_attribute?(attribute_name)
@@ -219,10 +219,17 @@ module ApiKeys
219
219
  # Must be defined in ApiKeys.configuration.key_types if provided.
220
220
  # @param environment [Symbol, nil] The environment (e.g., :test, :live).
221
221
  # Defaults to current_environment if key_types feature is enabled.
222
+ # @param restrictions [Hash, ApiKeys::Restrictions, nil] Request restrictions in
223
+ # storage shape, e.g. { origins: ["example.com"], ips: ["10.0.0.0/8"] }.
224
+ # @param allowed_origins [String, Array, nil] Convenience form of the origins list.
225
+ # Accepts the raw string a form field submits ("example.com, *.example.com").
226
+ # @param allowed_ips [String, Array, nil] Convenience form of the IP list
227
+ # ("203.0.113.7, 10.0.0.0/8").
222
228
  # @return [ApiKeys::ApiKey] The newly created ApiKey instance. The plaintext token
223
229
  # is available via the `#token` attribute on this instance
224
230
  # *only until it's reloaded*.
225
- def create_api_key!(name: nil, scopes: nil, expires_at: nil, expires_at_preset: nil, metadata: nil, key_type: nil, environment: nil)
231
+ 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)
226
233
  config = ApiKeys.configuration
227
234
 
228
235
  # Parse expires_at_preset if provided (takes precedence over expires_at)
@@ -240,6 +247,13 @@ module ApiKeys
240
247
  check_required_columns!
241
248
  end
242
249
 
250
+ # Requesting restrictions (directly, or through a key type that
251
+ # declares a ceiling) requires the column that stores them.
252
+ requested_restrictions = build_restrictions(restrictions, allowed_origins, allowed_ips)
253
+ if requested_restrictions || restriction_ceilings_configured?(config)
254
+ check_restrictions_column!
255
+ end
256
+
243
257
  # Use default_key_type if not specified and key_types feature is enabled
244
258
  resolved_key_type = key_type
245
259
  if resolved_key_type.nil? && key_types_feature_enabled?(config) && config.default_key_type.present?
@@ -278,16 +292,19 @@ module ApiKeys
278
292
  # ApiKey's creation callback locks the owner row before quota validation.
279
293
  # Keep an explicit transaction here so the helper's creation workflow is
280
294
  # a single atomic unit; direct ApiKey.create! calls are protected too.
295
+ attributes = {
296
+ name: name,
297
+ scopes: key_scopes,
298
+ expires_at: expires_at,
299
+ metadata: metadata || {}, # Ensure metadata is at least an empty hash
300
+ key_type: resolved_key_type&.to_s,
301
+ environment: resolved_environment&.to_s
302
+ # prefix, token_digest, digest_algorithm are set by ApiKey callbacks
303
+ }
304
+ attributes[:restrictions] = requested_restrictions if requested_restrictions
305
+
281
306
  api_key = self.class.transaction do
282
- self.api_keys.create!(
283
- name: name,
284
- scopes: key_scopes,
285
- expires_at: expires_at,
286
- metadata: metadata || {}, # Ensure metadata is at least an empty hash
287
- key_type: resolved_key_type&.to_s,
288
- environment: resolved_environment&.to_s
289
- # prefix, token_digest, digest_algorithm are set by ApiKey callbacks
290
- )
307
+ self.api_keys.create!(**attributes)
291
308
  end
292
309
 
293
310
  # Return the ApiKey instance itself.
@@ -357,6 +374,38 @@ module ApiKeys
357
374
  scopes.select { |scope| permissions.include?(scope.to_s) }
358
375
  end
359
376
 
377
+ # Merges the three ways a caller can express restrictions into one
378
+ # storage hash. Returns nil when the caller asked for none of them, so
379
+ # the column keeps its default and legacy installs stay untouched.
380
+ #
381
+ # @return [Hash, nil]
382
+ def build_restrictions(restrictions, allowed_origins, allowed_ips)
383
+ return nil if restrictions.nil? && allowed_origins.nil? && allowed_ips.nil?
384
+
385
+ unless restrictions.nil? || restrictions.is_a?(Hash) || restrictions.is_a?(ApiKeys::Restrictions)
386
+ raise ArgumentError, "restrictions must be a Hash or ApiKeys::Restrictions"
387
+ end
388
+
389
+ # `nil` or blank text for a kind clears it, as the list writers do.
390
+ # A malformed policy goes to the model as it came, so validation
391
+ # rejects it. Normalizing it first could reduce it to `{}`, which
392
+ # would create the key unrestricted.
393
+ input = ApiKeys::Restrictions.normalize_input(restrictions)
394
+ wrapped = ApiKeys::Restrictions.wrap(input)
395
+ return input if wrapped.malformed?
396
+
397
+ attributes = wrapped.to_h
398
+ attributes["origins"] = ApiKeys::Restrictions.normalize_origins(allowed_origins) unless allowed_origins.nil?
399
+ attributes["ips"] = ApiKeys::Restrictions.normalize_ips(allowed_ips) unless allowed_ips.nil?
400
+ attributes
401
+ end
402
+
403
+ # True when any configured key type declares a restriction ceiling.
404
+ def restriction_ceilings_configured?(config)
405
+ config.key_types.present? &&
406
+ config.key_types.any? { |_type, settings| settings.is_a?(Hash) && settings.key?(:restrictions) }
407
+ end
408
+
360
409
  # Check that required columns exist for key_types feature
361
410
  # Raises MigrationRequiredError if columns are missing
362
411
  def check_required_columns!
@@ -370,6 +419,14 @@ module ApiKeys
370
419
  end
371
420
  end
372
421
 
422
+ # Check that the restrictions column exists before writing to it.
423
+ # Raises RestrictionsMigrationRequiredError naming the generator.
424
+ def check_restrictions_column!
425
+ return if ApiKeys::ApiKey.restrictions_column?
426
+
427
+ raise ApiKeys::Errors::RestrictionsMigrationRequiredError
428
+ end
429
+
373
430
  # Example: Check if the owner has reached their API key limit.
374
431
  # def reached_api_key_limit?
375
432
  # limit = self.class.api_keys_settings[:max_keys]