mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 21:05:20 +00:00
* feat(api): allow multiple active API keys per user Previously a user could hold only one active API key; creating a new one silently revoked the existing key, breaking any app using it. Allow multiple named active keys instead. - Drop the one-active-key-per-source validation; add name uniqueness among the user's active visible keys (revoked names are reusable, same name allowed across users). - Rewrite Settings::ApiKeysController as a RESTful collection (index/show/new/create/destroy); create no longer revokes existing keys, and key lookup is scoped to the current user's active keys. - resource :api_key -> resources :api_keys. - Add an API keys list view with per-key revoke and an empty state; rewrite the show page for a single key. - Update i18n (remove single-key copy, pluralise nav label) and tests. * refactor(api): address review on multiple API keys - Remove unreachable destroy branches (cannot_revoke is guarded by the .visible 404; revoke! raises rather than returning false) and document that .visible is the demo-key revocation guard. - Delete the orphaned created.html.erb / created.turbo_stream.erb templates (no action renders them) and their unused locale keys. - Extract shared partials (_scope_badges, _status_indicator, _key_meta, _key_reveal, _usage) to de-duplicate the index and show views; unify the active-status indicator on the standard dot. - Carry forward the @container / @lg:flex-row / min-w-0 responsive fixes from #2079 into the shared key-reveal partial. * test(api): cover newly-created API key confirmation render * refactor(api): harden demo-key guard and address review nits - Document the demo-key revocation guard on ApiKey's `visible` scope (the authoritative spot) and add a model test locking the invariant that `.visible` excludes the demo monitoring key. - _scope_badges: use an i18n lookup with a humanize fallback instead of bypassing translation for unknown scopes. - _key_reveal: drop the hard-coded `id` from the shared partial; the system test now locates the key via its data-clipboard-target. * refactor(api-keys): migrate hand-rolled badges to DS::Pill Replace raw span elements in scope badges and status indicator with DS::Pill to align with the design system migration convention. * fix(loans): opening anchor now uses current balance, not original principal When creating a loan manually, the opening anchor valuation was being set to `initial_balance` (the original loan principal) instead of `account.balance` (the current outstanding balance). After the sync job ran, `account.balance` was overwritten to match the anchor, making every manually-created loan show its original principal as the current balance. Fix by always using `account.balance` for the opening anchor in `create_and_sync`, and reading `Loan#original_balance` from the `loans.initial_balance` column directly (with a fallback to `first_valuation_amount` for provider-synced loans that may not have the column populated). * fix(api-keys): strip accidental loan changes; rescue revoke! failures The fix(loans) commit was accidentally committed into this branch. Remove the loan-related changes from account.rb, loan.rb, and both test files, restoring them to their pre-loan-commit state. Also fix the destroy action: revoke! uses update! internally which raises ActiveRecord::RecordInvalid on failure rather than returning false. Add rescue for RecordInvalid and RecordNotDestroyed so failures produce a flash alert instead of a 500. Re-adds the revoke_failed locale key that was dropped from settings.api_keys.destroy. --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
124 lines
3.4 KiB
Ruby
124 lines
3.4 KiB
Ruby
class ApiKey < ApplicationRecord
|
|
include Encryptable
|
|
|
|
belongs_to :user
|
|
|
|
# Encrypt display_key if ActiveRecord encryption is configured
|
|
if encryption_ready?
|
|
encrypts :display_key, deterministic: true
|
|
end
|
|
|
|
# Constants
|
|
SOURCES = [ "web", "mobile", "monitoring" ].freeze
|
|
DEMO_MONITORING_KEY = "demo_monitoring_key_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
|
|
|
|
# Validations
|
|
validates :display_key, presence: true, uniqueness: true
|
|
validates :name, presence: true
|
|
validates :scopes, presence: true
|
|
validates :source, presence: true, inclusion: { in: SOURCES }
|
|
validate :scopes_not_empty
|
|
validate :name_unique_among_active_keys, on: :create
|
|
|
|
# Callbacks
|
|
before_validation :set_display_key
|
|
before_destroy :prevent_demo_monitoring_key_destroy!
|
|
|
|
# Scopes
|
|
scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
|
# SECURITY: excluding the demo monitoring key here is also the revocation guard
|
|
# in Settings::ApiKeysController#destroy — a demo key id 404s in `set_api_key`
|
|
# (it is not `.visible`) before it can be revoked. Do NOT broaden this scope to
|
|
# include monitoring keys without adding another explicit destroy guard.
|
|
scope :visible, -> { where.not(display_key: DEMO_MONITORING_KEY) }
|
|
|
|
# Class methods
|
|
def self.find_by_value(plain_key)
|
|
return nil unless plain_key
|
|
|
|
# Find by encrypted display_key (deterministic encryption allows querying)
|
|
find_by(display_key: plain_key)&.tap do |api_key|
|
|
return api_key if api_key.active?
|
|
end
|
|
end
|
|
|
|
def self.generate_secure_key
|
|
SecureRandom.hex(32)
|
|
end
|
|
|
|
# Instance methods
|
|
def active?
|
|
!revoked? && !expired?
|
|
end
|
|
|
|
def revoked?
|
|
revoked_at.present?
|
|
end
|
|
|
|
def expired?
|
|
expires_at.present? && expires_at < Time.current
|
|
end
|
|
|
|
def key_matches?(plain_key)
|
|
display_key == plain_key
|
|
end
|
|
|
|
def revoke!
|
|
raise ActiveRecord::RecordNotDestroyed, "Cannot revoke demo monitoring API key" if demo_monitoring_key?
|
|
update!(revoked_at: Time.current)
|
|
end
|
|
|
|
def delete
|
|
raise ActiveRecord::RecordNotDestroyed, "Cannot destroy demo monitoring API key" if demo_monitoring_key?
|
|
super
|
|
end
|
|
|
|
def demo_monitoring_key?
|
|
display_key == DEMO_MONITORING_KEY
|
|
end
|
|
|
|
def update_last_used!
|
|
update_column(:last_used_at, Time.current)
|
|
end
|
|
|
|
# Get the plain text API key for display (automatically decrypted by Rails)
|
|
def plain_key
|
|
display_key
|
|
end
|
|
|
|
# Temporarily store the plain key for creation flow
|
|
attr_accessor :key
|
|
|
|
private
|
|
|
|
def set_display_key
|
|
if key.present?
|
|
self.display_key = key
|
|
end
|
|
end
|
|
|
|
def scopes_not_empty
|
|
if scopes.blank? || (scopes.is_a?(Array) && (scopes.empty? || scopes.all?(&:blank?)))
|
|
errors.add(:scopes, "must include at least one permission")
|
|
elsif scopes.is_a?(Array) && scopes.length > 1
|
|
errors.add(:scopes, "can only have one permission level")
|
|
elsif scopes.is_a?(Array) && !%w[read read_write].include?(scopes.first)
|
|
errors.add(:scopes, "must be either 'read' or 'read_write'")
|
|
end
|
|
end
|
|
|
|
def name_unique_among_active_keys
|
|
return if name.blank? || user.blank?
|
|
scope = user.api_keys.active.visible.where(name: name)
|
|
scope = scope.where.not(id: id) if persisted?
|
|
errors.add(:name, :taken) if scope.exists?
|
|
end
|
|
|
|
def prevent_demo_monitoring_key_destroy!
|
|
return unless demo_monitoring_key?
|
|
|
|
errors.add(:base, :cannot_destroy_demo_key)
|
|
throw(:abort)
|
|
end
|
|
end
|