Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat add ability to rotate api key #2771

Merged
merged 5 commits into from
Nov 5, 2024
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
2 changes: 1 addition & 1 deletion app/controllers/api/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def authenticate
end

def current_organization(api_key_value = nil)
@current_organization ||= ApiKey.find_by(value: api_key_value)&.organization
@current_organization ||= ApiKey.active.find_by(value: api_key_value)&.organization
end

def set_context_source
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/invoices_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ def payment_url
private

def create_params
@create_params if defined? @create_params
return @create_params if defined? @create_params

@create_params =
params.require(:invoice)
Expand Down
25 changes: 25 additions & 0 deletions app/graphql/mutations/api_keys/rotate.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module Mutations
module ApiKeys
class Rotate < BaseMutation
include AuthenticableApiUser

REQUIRED_PERMISSION = 'developers:keys:manage'

graphql_name 'RotateApiKey'
description 'Create new ApiKey while expiring provided'

argument :id, ID, required: true

type Types::ApiKeys::Object

def resolve(id:)
api_key = context[:current_organization].api_keys.active.find_by(id:)
result = ::ApiKeys::RotateService.call(api_key)

result.success? ? result.api_key : result_error(result)
end
end
end
end
2 changes: 1 addition & 1 deletion app/graphql/resolvers/api_keys_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class ApiKeysResolver < Resolvers::BaseResolver
type Types::ApiKeys::SanitizedObject.collection_type, null: false

def resolve(page: nil, limit: nil)
current_organization.api_keys.page(page).limit(limit)
current_organization.api_keys.active.page(page).limit(limit)
end
end
end
1 change: 1 addition & 0 deletions app/graphql/types/api_keys/object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class Object < Types::BaseObject
field :value, String, null: false

field :created_at, GraphQL::Types::ISO8601DateTime, null: false
field :expires_at, GraphQL::Types::ISO8601DateTime, null: true
end
end
end
2 changes: 2 additions & 0 deletions app/graphql/types/mutation_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -150,5 +150,7 @@ class MutationType < Types::BaseObject

field :create_dunning_campaign, mutation: Mutations::DunningCampaigns::Create
field :update_dunning_campaign, mutation: Mutations::DunningCampaigns::Update

field :rotate_api_key, mutation: Mutations::ApiKeys::Rotate
end
end
16 changes: 16 additions & 0 deletions app/mailers/api_key_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

class ApiKeyMailer < ApplicationMailer
def rotated
organization = params[:api_key].organization
@organization_name = organization.name

I18n.with_locale(:en) do
mail(
bcc: organization.admins.pluck(:email),
from: ENV['LAGO_FROM_EMAIL'],
subject: I18n.t('email.api_key.rotated.subject')
)
end
end
end
3 changes: 3 additions & 0 deletions app/models/api_key.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ class ApiKey < ApplicationRecord
validates :value, uniqueness: true
validates :value, presence: true, on: :update

scope :active, -> { where('expires_at IS NULL OR expires_at > ?', Time.current) }

private

def set_value
Expand All @@ -25,6 +27,7 @@ def set_value
# Table name: api_keys
#
# id :uuid not null, primary key
# expires_at :datetime
# value :string not null
# created_at :datetime not null
# updated_at :datetime not null
Expand Down
1 change: 1 addition & 0 deletions app/models/clickhouse/events_raw.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def organization
# Table name: events_raw
#
# code :string not null
# ingested_at :datetime not null
# precise_total_amount_cents :decimal(40, 15)
# properties :string not null
# timestamp :datetime not null
Expand Down
18 changes: 17 additions & 1 deletion app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,22 @@ class Organization < ApplicationRecord
validates :timezone, timezone: true
validates :webhook_url, url: true, allow_nil: true
validates :finalize_zero_amount_invoice, inclusion: {in: [true, false]}
validates :hmac_key, uniqueness: true
validates :hmac_key, presence: true, on: :update

validate :validate_email_settings

before_create :set_hmac_key
after_create :generate_document_number_prefix

PREMIUM_INTEGRATIONS.each do |premium_integration|
scope "with_#{premium_integration}_support", -> { where("? = ANY(premium_integrations)", premium_integration) }
end

def admins
users.joins(:memberships).merge!(memberships.admin)
end

def logo_url
return if logo.blank?

Expand Down Expand Up @@ -138,6 +145,13 @@ def validate_email_settings

errors.add(:email_settings, :unsupported_value)
end

def set_hmac_key
loop do
self.hmac_key = SecureRandom.uuid
break unless self.class.exists?(hmac_key:)
end
end
end

# == Schema Information
Expand All @@ -161,6 +175,7 @@ def validate_email_settings
# email_settings :string default([]), not null, is an Array
# eu_tax_management :boolean default(FALSE)
# finalize_zero_amount_invoice :boolean default(TRUE), not null
# hmac_key :string not null
# invoice_footer :text
# invoice_grace_period :integer default(0), not null
# legal_name :string
Expand All @@ -180,5 +195,6 @@ def validate_email_settings
#
# Indexes
#
# index_organizations_on_api_key (api_key) UNIQUE
# index_organizations_on_api_key (api_key) UNIQUE
# index_organizations_on_hmac_key (hmac_key) UNIQUE
#
3 changes: 1 addition & 2 deletions app/models/webhook.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,7 @@ def jwt_signature
end

def hmac_signature
api_key_value = organization.api_keys.first.value
hmac = OpenSSL::HMAC.digest('sha-256', api_key_value, payload.to_json)
hmac = OpenSSL::HMAC.digest('sha-256', organization.hmac_key, payload.to_json)
Base64.strict_encode64(hmac)
end

Expand Down
32 changes: 32 additions & 0 deletions app/services/api_keys/rotate_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

module ApiKeys
class RotateService < BaseService
def initialize(api_key)
@api_key = api_key
super
end

def call
return result.not_found_failure!(resource: 'api_key') unless api_key

new_api_key = api_key.organization.api_keys.new

ActiveRecord::Base.transaction do
new_api_key.save!
api_key.update!(expires_at: Time.current)
end

ApiKeyMailer.with(api_key:).rotated.deliver_later

result.api_key = new_api_key
result
rescue ActiveRecord::RecordInvalid => e
result.record_validation_failure!(record: e.record)
end

private

attr_reader :api_key
end
end
32 changes: 32 additions & 0 deletions app/views/api_key_mailer/rotated.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
div style='margin-bottom: 32px;width: 80px;height: 24px;'
svg xmlns="http://www.w3.org/2000/svg" fill="none" viewbox="0 0 80 24"
g clip-path="url(#a)"
g fill="#19212E" clip-path="url(#b)"
path d="M69.85 18.161a5.529 5.529 0 0 1-2.324-2.326c-.557-1.003-.819-2.158-.819-3.463 0-1.305.279-2.46.819-3.463.54-1.004 1.326-1.773 2.324-2.326 1.015-.535 2.178-.82 3.504-.82s2.488.268 3.503.82a5.625 5.625 0 0 1 2.325 2.326c.54 1.004.818 2.158.818 3.463 0 1.322-.278 2.476-.819 3.48a5.678 5.678 0 0 1-2.324 2.309c-1.015.535-2.177.82-3.503.82s-2.505-.268-3.504-.82Zm5.795-3.095c.557-.686.852-1.59.852-2.677 0-1.104-.279-1.991-.852-2.677-.572-.686-1.326-1.037-2.291-1.037-.95 0-1.703.351-2.276 1.037-.573.686-.851 1.573-.851 2.677s.278 2.008.851 2.677c.573.686 1.326 1.037 2.276 1.037.965 0 1.719-.351 2.291-1.037ZM65.512 5.931v12.515c0 1.69-.556 3.044-1.637 4.048-1.097 1.004-2.8 1.506-5.108 1.506-1.784 0-3.224-.401-4.321-1.204-1.097-.804-1.686-1.941-1.768-3.413h3.487c.163.619.49 1.087.965 1.422.475.334 1.114.502 1.9.502.982 0 1.751-.252 2.291-.753.54-.502.819-1.255.819-2.226v-1.355c-.917 1.188-2.227 1.774-3.896 1.774-1.114 0-2.112-.268-2.996-.803-.884-.536-1.572-1.289-2.08-2.276-.507-.97-.752-2.124-.752-3.43 0-1.288.245-2.425.753-3.413a5.367 5.367 0 0 1 2.095-2.275c.884-.535 1.9-.803 3.012-.803 1.654 0 2.963.653 3.93 1.958L62.5 5.93h3.012Zm-4.24 8.984c.557-.669.835-1.539.835-2.61 0-1.087-.278-1.974-.835-2.66-.556-.686-1.31-1.02-2.242-1.02-.934 0-1.687.334-2.243 1.02-.573.67-.852 1.556-.852 2.644 0 1.087.279 1.974.852 2.643.573.67 1.31 1.004 2.242 1.004.934-.017 1.687-.351 2.243-1.02ZM51.22 5.931v12.9h-3.077l-.294-1.807c-1 1.288-2.309 1.924-3.93 1.924-1.113 0-2.111-.268-2.995-.803-.884-.536-1.572-1.305-2.08-2.31-.507-1.003-.752-2.158-.752-3.496 0-1.305.245-2.46.753-3.463A5.5 5.5 0 0 1 40.94 6.55c.884-.535 1.9-.82 3.012-.82.852 0 1.605.168 2.26.502a4.659 4.659 0 0 1 1.62 1.372l.344-1.706h3.045v.033Zm-4.24 9.152c.557-.67.836-1.573.836-2.677 0-1.121-.279-2.024-.835-2.71-.557-.686-1.31-1.038-2.243-1.038-.933 0-1.686.352-2.243 1.038-.573.686-.85 1.589-.85 2.71 0 1.104.277 1.99.85 2.677.573.686 1.31 1.02 2.243 1.02.933 0 1.686-.35 2.243-1.02ZM27.5 18.83V1.263h3.683v14.338h6.827v3.23H27.5Z"
g clip-path="url(#c)"
g fill="#19212E" clip-path="url(#d)"
path d="M19.875 11.693a9.973 9.973 0 0 1-2.804 5.558c-1.517 1.534-3.41 2.508-5.5 2.833 0-.018-.017-.036-.017-.054v-.018c-.018-.036-.018-.054-.036-.108l-.054-.163a5.625 5.625 0 0 1-.196-.83v-.018c0-.036-.018-.072-.018-.108v-.018c0-.036-.018-.072-.018-.09v-.036c0-.036-.018-.072-.018-.127-.018-.09-.018-.18-.018-.288v-.127c0-.108-.017-.216-.017-.343 0-3.572 2.892-6.496 6.428-6.496H18.071c.09 0 .197.018.286.036.036 0 .09 0 .143.018.036 0 .071.018.09.018h.017c.036 0 .072 0 .107.018h.018c.286.055.554.127.84.217l.16.054c.036.018.054.018.09.036h.017c0 .018.018.036.036.036Z"
path d="M20 10.213h-.018c-.16-.054-.321-.09-.482-.144-.018 0-.036-.018-.071-.018a3.26 3.26 0 0 0-.447-.09h-.018c-.035 0-.089-.018-.125-.018h-.035c-.054 0-.108-.018-.143-.018-.054 0-.125-.018-.161-.018-.107-.018-.232-.018-.34-.036h-.589c-4.339 0-7.857 3.554-7.857 7.94 0 .126 0 .252.018.396v.09c0 .037 0 .073.018.109.018.108.018.235.036.343 0 .054.018.108.018.162 0 .054.017.108.017.145.018.072.018.126.036.18.054.343.143.686.25 1.029v.018a9.768 9.768 0 0 1-6.09-2.003V17.847c0-7.561 6.09-13.715 13.572-13.715H18.018c1.321 1.697 2 3.844 1.982 6.082Z" opacity=".6"
path d="M16.732 2.617c-.071 0-.16.018-.232.018-.125 0-.232.018-.357.036-.036 0-.09 0-.125.018-.036 0-.054 0-.09.018a7.867 7.867 0 0 0-.642.09c-.161.018-.304.054-.465.072-.089.018-.196.036-.285.054-.107.018-.215.054-.34.072-.035.019-.089.019-.125.037-.071.018-.16.036-.232.054-.035 0-.053.018-.089.018-.09.018-.179.036-.25.072-.036.018-.09.018-.125.036-.071.018-.125.036-.196.054-.054.018-.108.036-.143.054-.09.018-.161.054-.232.072-.018 0-.036.019-.054.019-.107.036-.214.072-.321.126-.125.036-.233.09-.34.126a.656.656 0 0 0-.196.09c-.072.018-.125.055-.197.073-.107.054-.232.09-.339.144-.196.09-.393.18-.59.289-.25.126-.481.252-.731.397-.072.054-.161.09-.232.144-.09.054-.179.108-.286.18-.09.055-.179.109-.25.163-.072.054-.16.108-.232.162l-.215.163c-.178.126-.339.252-.5.379-.071.054-.125.108-.196.162-.107.09-.232.199-.34.289-.089.072-.178.162-.267.234-.072.054-.125.127-.197.18-.214.217-.428.416-.642.65a1.79 1.79 0 0 0-.179.199c-.09.09-.16.18-.232.27-.107.109-.197.235-.286.343-.053.073-.107.127-.16.199-.126.162-.25.343-.376.505l-.16.217c-.054.072-.107.162-.161.234-.054.09-.107.163-.16.253-.054.09-.126.18-.18.289-.053.072-.089.162-.142.234-.143.235-.268.487-.393.722a9.036 9.036 0 0 0-.286.595c-.053.109-.107.217-.143.343-.017.054-.053.127-.071.199-.036.072-.054.144-.09.198-.053.109-.089.235-.124.343-.036.108-.072.217-.125.325 0 .018-.018.036-.018.054-.036.072-.054.163-.072.235-.017.054-.035.108-.053.144-.018.072-.036.127-.054.199-.018.036-.018.09-.035.126-.018.09-.054.18-.072.253 0 .018-.018.054-.018.09-.018.072-.035.162-.053.234 0 .037-.018.073-.036.127-.018.108-.054.216-.071.343a8.66 8.66 0 0 0-.054.288 4.253 4.253 0 0 0-.071.47c-.036.216-.054.432-.09.65 0 .035 0 .053-.018.09 0 .035 0 .072-.017.126-.018.126-.018.234-.036.36 0 .073-.018.163-.018.235C.946 15.068.035 12.722 0 10.232A10.1 10.1 0 0 1 2.75 3.14c.054-.072.125-.126.179-.18l.178-.181C4.982.992 7.43 0 10 0h.125a9.965 9.965 0 0 1 6.607 2.617Z" opacity=".3"
defs
clippath#a
path fill="#fff" d="M0 0h80v24H0z"
clippath#b
path fill="#fff" d="M27.5 1.263H80V24H27.5z"
clippath#c
path fill="#fff" d="M0 0h20v20.21H0z"
clippath#d
path fill="#fff" d="M0 0h20v20.21H0z"

div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;'
= I18n.t('email.api_key.rotated.greetings', organization_name: @organization_name)

div style='margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;'
= I18n.t('email.api_key.rotated.change_notice')

div style='margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;'
= I18n.t('email.api_key.rotated.access_warning')

div style='width: 100%;height: 1px;background-color: #D9DEE7;margin-bottom: 32px;'
div style="color: #66758F;font-style: normal;font-weight: 400;font-size: 14px;line-height: 20px;"
= I18n.t('email.api_key.rotated.email_info')
7 changes: 7 additions & 0 deletions config/locales/en/email.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
---
en:
email:
api_key:
rotated:
access_warning: If you did not authorize this request, please reset your password and roll your API key immediately to secure your account.
change_notice: If someone from your team initiated this change, no further action is required. To keep your billing running smoothly, update your app to reference the new token as soon as possible.
email_info: You’re receiving this email because an API key has been rotated in your Lago instance, and you have admin privileges.
greetings: Your %{organization_name}'s API key has been rotated!
subject: Your Lago API key has been rolled
credit_note:
created:
credit_note_from: Credit Note from %{organization_name}
Expand Down
31 changes: 31 additions & 0 deletions db/migrate/20241030123528_add_hmac_key_to_organizations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

class AddHmacKeyToOrganizations < ActiveRecord::Migration[7.1]
disable_ddl_transaction!

def up
add_column :organizations, :hmac_key, :string, null: false, default: ""

safety_assured do
execute <<-SQL
UPDATE organizations
SET hmac_key = first_api_key.value
FROM (
SELECT DISTINCT ON (organization_id)
organization_id,
value
FROM api_keys
ORDER BY organization_id, id ASC
) first_api_key
WHERE organizations.id = first_api_key.organization_id
SQL
end

add_index :organizations, :hmac_key, unique: true, algorithm: :concurrently
change_column_default :organizations, :hmac_key, nil
end

def down
remove_column :organizations, :hmac_key
end
end
7 changes: 7 additions & 0 deletions db/migrate/20241031095225_add_expires_at_to_api_keys.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddExpiresAtToApiKeys < ActiveRecord::Migration[7.1]
def change
add_column :api_keys, :expires_at, :datetime
end
end
3 changes: 3 additions & 0 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions schema.graphql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading