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

Add ability to rotate API key with expiry period #2822

Merged
merged 3 commits into from
Nov 15, 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
3 changes: 3 additions & 0 deletions Guardfile
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,7 @@ guard :rspec, cmd: 'bundle exec rspec' do
"spec/requests/#{m[1]}_controller_spec.rb"
]
end

# Run schema check for any change in Graphql folder
watch(%r{^app/graphql/(.+)\.rb$}) { |m| "spec/graphql/lago_api_schema_spec.rb" }
end
13 changes: 10 additions & 3 deletions app/graphql/mutations/api_keys/rotate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,20 @@ class Rotate < BaseMutation
graphql_name 'RotateApiKey'
description 'Create new ApiKey while expiring provided'

argument :id, ID, required: true
input_object_class Types::ApiKeys::RotateInput

type Types::ApiKeys::Object

def resolve(id:)
def resolve(id:, name: nil, expires_at: nil)
api_key = current_organization.api_keys.find_by(id:)
result = ::ApiKeys::RotateService.call(api_key)

result = ::ApiKeys::RotateService.call(
api_key:,
params: {
name:,
expires_at:
}
)

result.success? ? result.api_key : result_error(result)
end
Expand Down
13 changes: 13 additions & 0 deletions app/graphql/types/api_keys/rotate_input.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module Types
module ApiKeys
class RotateInput < Types::BaseInputObject
graphql_name 'RotateApiKeyInput'

argument :expires_at, GraphQL::Types::ISO8601DateTime, required: false
argument :id, ID, required: true
argument :name, String, required: false
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class CurrentOrganizationType < BaseOrganizationType
field :zipcode, String

field :api_key, String, permission: 'developers:keys:manage'
field :hmac_key, String, permission: 'developers:keys:manage'
field :webhook_url, String, permission: 'developers:manage'

field :document_number_prefix, String, null: false
Expand Down
14 changes: 10 additions & 4 deletions app/services/api_keys/rotate_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,25 @@

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

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

new_api_key = api_key.organization.api_keys.new
if params[:expires_at].present? && !License.premium?
return result.forbidden_failure!(code: "cannot_rotate_with_provided_date")
end

expires_at = params[:expires_at] || Time.current
new_api_key = api_key.organization.api_keys.new(name: params[:name])

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

ApiKeyMailer.with(api_key:).rotated.deliver_later
Expand All @@ -27,6 +33,6 @@ def call

private

attr_reader :api_key
attr_reader :api_key, :params
end
end
3 changes: 3 additions & 0 deletions schema.graphql

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

44 changes: 41 additions & 3 deletions schema.json

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

15 changes: 11 additions & 4 deletions spec/graphql/mutations/api_keys/rotate_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,22 @@
current_organization: membership.organization,
permissions: required_permission,
query:,
variables: {input: {id: api_key.id}}
variables: {input: {id: api_key.id, expiresAt: expires_at, name:}}
)
end

let(:query) do
<<-GQL
mutation($input: RotateApiKeyInput!) {
rotateApiKey(input: $input) { id value createdAt }
rotateApiKey(input: $input) { id value name createdAt expiresAt }
}
GQL
end

let(:required_permission) { 'developers:keys:manage' }
let!(:membership) { create(:membership) }
let(:expires_at) { generate(:future_date).iso8601 }
let(:name) { Faker::Lorem.words.join(' ') }

it_behaves_like 'requires current user'
it_behaves_like 'requires current organization'
Expand All @@ -31,17 +33,22 @@
context 'when api key with such ID exists in the current organization' do
let(:api_key) { membership.organization.api_keys.first }

around { |test| lago_premium!(&test) }

it 'expires the api key' do
expect { result }.to change { api_key.reload.expires_at }.from(nil).to(Time)
expect { result }
.to change { api_key.reload.expires_at&.iso8601 }
.to(expires_at)
end

it 'returns newly created api key' do
api_key_response = result['data']['rotateApiKey']
new_api_key = membership.organization.api_keys.last
new_api_key = membership.organization.api_keys.order(:created_at).last

aggregate_failures do
expect(api_key_response['id']).to eq(new_api_key.id)
expect(api_key_response['value']).to eq(new_api_key.value)
expect(api_key_response['name']).to eq(name)
expect(api_key_response['createdAt']).to eq(new_api_key.created_at.iso8601)
expect(api_key_response['expiresAt']).to be_nil
end
Expand Down
11 changes: 11 additions & 0 deletions spec/graphql/types/api_keys/rotate_input_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Types::ApiKeys::RotateInput do
subject { described_class }

it { is_expected.to accept_argument(:id).of_type('ID!') }
it { is_expected.to accept_argument(:name).of_type('String') }
it { is_expected.to accept_argument(:expires_at).of_type('ISO8601DateTime') }
end
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
it { is_expected.to have_field(:zipcode).of_type('String') }

it { is_expected.to have_field(:api_key).of_type('String').with_permission('developers:keys:manage') }
it { is_expected.to have_field(:hmac_key).of_type('String').with_permission('developers:keys:manage') }
it { is_expected.to have_field(:webhook_url).of_type('String').with_permission('developers:manage') }

it { is_expected.to have_field(:timezone).of_type('TimezoneEnum') }
Expand Down
101 changes: 88 additions & 13 deletions spec/services/api_keys/rotate_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,107 @@

RSpec.describe ApiKeys::RotateService, type: :service do
describe '#call' do
subject(:service_result) { described_class.call(api_key) }
subject(:service_result) { described_class.call(api_key:, params:) }

let(:params) { {expires_at:, name:} }
let(:name) { Faker::Lorem.words.join(' ') }

context 'when API key is provided' do
let(:api_key) { create(:api_key) }
let!(:api_key) { create(:api_key) }
let(:organization) { api_key.organization }

it 'expires the API key' do
expect { service_result }.to change(api_key, :expires_at).from(nil).to(Time)
end
context 'when preferred expiration date is provided' do
let(:expires_at) { generate(:future_date) }

context 'with premium organization' do
around { |test| lago_premium!(&test) }

it 'creates a new API key for organization' do
expect { service_result }.to change(ApiKey, :count).by(1)
it 'expires the API key with preferred date' do
expect { service_result }
.to change { api_key.reload.expires_at&.iso8601 }
.to(expires_at.iso8601)
end

expect(service_result.api_key)
.to be_persisted
.and have_attributes(organization:)
it 'creates a new API key for organization' do
expect { service_result }.to change(ApiKey, :count).by(1)

expect(service_result.api_key)
.to be_persisted.and have_attributes(organization:, name:)
end

it 'sends an API key rotated email' do
expect { service_result }
.to have_enqueued_mail(ApiKeyMailer, :rotated).with hash_including(params: {api_key:})
end
end

context 'with free organization' do
it 'does not creates a new API key for organization' do
expect { service_result }.not_to change(ApiKey, :count)
end

it 'does not send an API key rotated email' do
expect { service_result }.not_to have_enqueued_mail(ApiKeyMailer, :rotated)
end

it 'returns an error' do
aggregate_failures do
expect(service_result).not_to be_success
expect(service_result.error).to be_a(BaseService::ForbiddenFailure)
expect(service_result.error.code).to eq('cannot_rotate_with_provided_date')
end
end
end
end

it 'sends an API key rotated email' do
expect { service_result }
.to have_enqueued_mail(ApiKeyMailer, :rotated).with hash_including(params: {api_key:})
context 'when preferred expiration date is missing' do
let(:expires_at) { nil }

before { freeze_time }

context 'with premium organization' do
around { |test| lago_premium!(&test) }

it 'expires the API key with current time' do
expect { service_result }.to change(api_key, :expires_at).to(Time.current)
end

it 'creates a new API key for organization' do
expect { service_result }.to change(ApiKey.unscoped, :count).by(1)

expect(service_result.api_key)
.to be_persisted.and have_attributes(organization:, name:)
end

it 'sends an API key rotated email' do
expect { service_result }
.to have_enqueued_mail(ApiKeyMailer, :rotated).with hash_including(params: {api_key:})
end
end

context 'with free organization' do
it 'expires the API key with current time' do
expect { service_result }.to change(api_key, :expires_at).to(Time.current)
end

it 'creates a new API key for organization' do
expect { service_result }.to change(ApiKey.unscoped, :count).by(1)

expect(service_result.api_key)
.to be_persisted.and have_attributes(organization:, name:)
end

it 'sends an API key rotated email' do
expect { service_result }
.to have_enqueued_mail(ApiKeyMailer, :rotated).with hash_including(params: {api_key:})
end
end
end
end

context 'when API key is missing' do
let(:api_key) { nil }
let(:expires_at) { double }

it 'does not creates a new API key for organization' do
expect { service_result }.not_to change(ApiKey, :count)
Expand Down