diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index e590ee84f24..a9fecef5347 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -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 diff --git a/app/controllers/api/v1/invoices_controller.rb b/app/controllers/api/v1/invoices_controller.rb index 4bebd97311f..78ee7ecbb4b 100644 --- a/app/controllers/api/v1/invoices_controller.rb +++ b/app/controllers/api/v1/invoices_controller.rb @@ -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) diff --git a/app/graphql/mutations/api_keys/rotate.rb b/app/graphql/mutations/api_keys/rotate.rb new file mode 100644 index 00000000000..82e78868e8c --- /dev/null +++ b/app/graphql/mutations/api_keys/rotate.rb @@ -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 diff --git a/app/graphql/resolvers/api_keys_resolver.rb b/app/graphql/resolvers/api_keys_resolver.rb index 0753c0b22a8..7f273ea3f42 100644 --- a/app/graphql/resolvers/api_keys_resolver.rb +++ b/app/graphql/resolvers/api_keys_resolver.rb @@ -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 diff --git a/app/graphql/types/api_keys/object.rb b/app/graphql/types/api_keys/object.rb index a33068941ce..fbb4179f8d5 100644 --- a/app/graphql/types/api_keys/object.rb +++ b/app/graphql/types/api_keys/object.rb @@ -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 diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index a239e4fef52..cf98bdb2e14 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -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 diff --git a/app/mailers/api_key_mailer.rb b/app/mailers/api_key_mailer.rb new file mode 100644 index 00000000000..e3100dda838 --- /dev/null +++ b/app/mailers/api_key_mailer.rb @@ -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 diff --git a/app/models/api_key.rb b/app/models/api_key.rb index 38cea6c5e51..c2b93029277 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -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 @@ -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 diff --git a/app/models/clickhouse/events_raw.rb b/app/models/clickhouse/events_raw.rb index 4673fbe9733..800507e5b9b 100644 --- a/app/models/clickhouse/events_raw.rb +++ b/app/models/clickhouse/events_raw.rb @@ -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 diff --git a/app/models/organization.rb b/app/models/organization.rb index 8a6d194dfb0..1dc39c32279 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -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? @@ -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 @@ -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 @@ -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 # diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 55bb96bf284..0d97c2fc434 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -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 diff --git a/app/services/api_keys/rotate_service.rb b/app/services/api_keys/rotate_service.rb new file mode 100644 index 00000000000..f7f00bdb2d1 --- /dev/null +++ b/app/services/api_keys/rotate_service.rb @@ -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 diff --git a/app/views/api_key_mailer/rotated.slim b/app/views/api_key_mailer/rotated.slim new file mode 100644 index 00000000000..85bbfca1dd4 --- /dev/null +++ b/app/views/api_key_mailer/rotated.slim @@ -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') diff --git a/config/locales/en/email.yml b/config/locales/en/email.yml index 73b01e3daf9..87eda3bf78e 100644 --- a/config/locales/en/email.yml +++ b/config/locales/en/email.yml @@ -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} diff --git a/db/migrate/20241030123528_add_hmac_key_to_organizations.rb b/db/migrate/20241030123528_add_hmac_key_to_organizations.rb new file mode 100644 index 00000000000..8e2c3706ded --- /dev/null +++ b/db/migrate/20241030123528_add_hmac_key_to_organizations.rb @@ -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 diff --git a/db/migrate/20241031095225_add_expires_at_to_api_keys.rb b/db/migrate/20241031095225_add_expires_at_to_api_keys.rb new file mode 100644 index 00000000000..2dffe2c9aef --- /dev/null +++ b/db/migrate/20241031095225_add_expires_at_to_api_keys.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 77bf5e3c7dd..e9c29ab1d59 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -107,6 +107,7 @@ t.string "value", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.datetime "expires_at" t.index ["organization_id"], name: "index_api_keys_on_organization_id" t.index ["value"], name: "index_api_keys_on_value", unique: true end @@ -964,7 +965,9 @@ t.boolean "custom_aggregation", default: false t.boolean "finalize_zero_amount_invoice", default: true, null: false t.boolean "clickhouse_events_store", default: false, null: false + t.string "hmac_key", null: false t.index ["api_key"], name: "index_organizations_on_api_key", unique: true + t.index ["hmac_key"], name: "index_organizations_on_hmac_key", unique: true t.check_constraint "invoice_grace_period >= 0", name: "check_organizations_on_invoice_grace_period" t.check_constraint "net_payment_term >= 0", name: "check_organizations_on_net_payment_term" end diff --git a/schema.graphql b/schema.graphql index 9318976098f..3415e394964 100644 --- a/schema.graphql +++ b/schema.graphql @@ -178,6 +178,7 @@ type AnrokIntegration { type ApiKey { createdAt: ISO8601DateTime! + expiresAt: ISO8601DateTime id: ID! value: String! } @@ -5376,6 +5377,16 @@ type Mutation { input: RevokeMembershipInput! ): Membership + """ + Create new ApiKey while expiring provided + """ + rotateApiKey( + """ + Parameters for RotateApiKey + """ + input: RotateApiKeyInput! + ): ApiKey + """ Sync crm integration invoice """ @@ -6794,6 +6805,17 @@ input RevokeMembershipInput { id: ID! } +""" +Autogenerated input type of RotateApiKey +""" +input RotateApiKeyInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + enum RoundingFunctionEnum { ceil floor @@ -6802,6 +6824,7 @@ enum RoundingFunctionEnum { type SanitizedApiKey { createdAt: ISO8601DateTime! + expiresAt: ISO8601DateTime id: ID! value: String! } diff --git a/schema.json b/schema.json index 85b75cce1f6..8ceec1202f7 100644 --- a/schema.json +++ b/schema.json @@ -1417,6 +1417,20 @@ ] }, + { + "name": "expiresAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, { "name": "id", "description": null, @@ -26171,6 +26185,35 @@ } ] }, + { + "name": "rotateApiKey", + "description": "Create new ApiKey while expiring provided", + "type": { + "kind": "OBJECT", + "name": "ApiKey", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + { + "name": "input", + "description": "Parameters for RotateApiKey", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RotateApiKeyInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ] + }, { "name": "syncCrmIntegrationInvoice", "description": "Sync crm integration invoice", @@ -35391,6 +35434,45 @@ ], "enumValues": null }, + { + "kind": "INPUT_OBJECT", + "name": "RotateApiKeyInput", + "description": "Autogenerated input type of RotateApiKey", + "interfaces": null, + "possibleTypes": null, + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "enumValues": null + }, { "kind": "ENUM", "name": "RoundingFunctionEnum", @@ -35447,6 +35529,20 @@ ] }, + { + "name": "expiresAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, { "name": "id", "description": null, diff --git a/spec/factories/api_keys.rb b/spec/factories/api_keys.rb index 33681af58b3..5a83f733927 100644 --- a/spec/factories/api_keys.rb +++ b/spec/factories/api_keys.rb @@ -3,5 +3,13 @@ FactoryBot.define do factory :api_key do organization { association(:organization, api_keys: []) } + + trait :expired do + expires_at { generate(:past_date) } + end + + trait :expiring do + expires_at { generate(:future_date) } + end end end diff --git a/spec/factories/common.rb b/spec/factories/common.rb new file mode 100644 index 00000000000..bafb829be79 --- /dev/null +++ b/spec/factories/common.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +FactoryBot.define do + sequence(:future_date) { rand(1..(10**7)).seconds.from_now } + sequence(:past_date) { rand(1..(10**7)).seconds.ago } +end diff --git a/spec/graphql/mutations/api_keys/rotate_spec.rb b/spec/graphql/mutations/api_keys/rotate_spec.rb new file mode 100644 index 00000000000..eb191651235 --- /dev/null +++ b/spec/graphql/mutations/api_keys/rotate_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::ApiKeys::Rotate, type: :graphql do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query:, + variables: {input: {id: api_key.id}} + ) + end + + let(:query) do + <<-GQL + mutation($input: RotateApiKeyInput!) { + rotateApiKey(input: $input) { id value createdAt } + } + GQL + end + + let(:required_permission) { 'developers:keys:manage' } + let!(:membership) { create(:membership) } + + it_behaves_like 'requires current user' + it_behaves_like 'requires permission', 'developers:keys:manage' + + context 'when api key with such ID exists in the current organization' do + let(:api_key) { membership.organization.api_keys.first } + + it 'expires the api key' do + expect { result }.to change { api_key.reload.expires_at }.from(nil).to(Time) + end + + it 'returns newly created api key' do + api_key_response = result['data']['rotateApiKey'] + new_api_key = membership.organization.api_keys.active.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['createdAt']).to eq(new_api_key.created_at.iso8601) + expect(api_key_response['expiresAt']).to be_nil + end + end + end + + context 'when api key with such ID does not exist in the current organization' do + let!(:api_key) { create(:api_key) } + + it 'does not change the api key' do + expect { result }.not_to change { api_key.reload.expires_at } + end + + it 'does not create an api key' do + expect { result }.not_to change(ApiKey, :count) + end + + it 'returns an error' do + expect_graphql_error(result:, message: 'Resource not found') + end + end +end diff --git a/spec/graphql/types/api_keys/object_spec.rb b/spec/graphql/types/api_keys/object_spec.rb index 65421215b17..fe45400a095 100644 --- a/spec/graphql/types/api_keys/object_spec.rb +++ b/spec/graphql/types/api_keys/object_spec.rb @@ -8,4 +8,5 @@ it { is_expected.to have_field(:id).of_type('ID!') } it { is_expected.to have_field(:value).of_type('String!') } it { is_expected.to have_field(:created_at).of_type('ISO8601DateTime!') } + it { is_expected.to have_field(:expires_at).of_type('ISO8601DateTime') } end diff --git a/spec/graphql/types/api_keys/sanitized_object_spec.rb b/spec/graphql/types/api_keys/sanitized_object_spec.rb index de091a3ec37..0689e6d4192 100644 --- a/spec/graphql/types/api_keys/sanitized_object_spec.rb +++ b/spec/graphql/types/api_keys/sanitized_object_spec.rb @@ -8,4 +8,5 @@ it { is_expected.to have_field(:id).of_type('ID!') } it { is_expected.to have_field(:value).of_type('String!') } it { is_expected.to have_field(:created_at).of_type('ISO8601DateTime!') } + it { is_expected.to have_field(:expires_at).of_type('ISO8601DateTime') } end diff --git a/spec/mailers/api_key_mailer_spec.rb b/spec/mailers/api_key_mailer_spec.rb new file mode 100644 index 00000000000..a628c07c3e9 --- /dev/null +++ b/spec/mailers/api_key_mailer_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ApiKeyMailer, type: :mailer do + describe '#rotated' do + let(:mail) { described_class.with(api_key:).rotated } + let(:api_key) { create(:api_key) } + let(:organization) { api_key.organization } + + before { create(:membership, organization:, role: :admin) } + + describe 'subject' do + subject { mail.subject } + + it { is_expected.to eq 'Your Lago API key has been rolled' } + end + + describe 'recipients' do + subject { mail.bcc } + + before { create(:membership, organization:, role: :manager) } + + specify do + expect(subject) + .to be_present + .and eq organization.admins.pluck(:email) + end + end + + describe 'body' do + subject { mail.body.to_s } + + it "includes organization's name" do + expect(subject).to include organization.name + end + end + end +end diff --git a/spec/mailers/previews/api_key_mailer_preview.rb b/spec/mailers/previews/api_key_mailer_preview.rb new file mode 100644 index 00000000000..df538fd82cf --- /dev/null +++ b/spec/mailers/previews/api_key_mailer_preview.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ApiKeyMailerPreview < BasePreviewMailer + def rotated + api_key = FactoryBot.create(:api_key) + ApiKeyMailer.with(api_key:).rotated + end +end diff --git a/spec/models/api_key_spec.rb b/spec/models/api_key_spec.rb index 90d5044bb29..42283567a31 100644 --- a/spec/models/api_key_spec.rb +++ b/spec/models/api_key_spec.rb @@ -54,4 +54,21 @@ end end end + + describe '.active' do + subject { described_class.active } + + let(:scoped) do + [ + create(:api_key), + create(:api_key, :expiring) + ] + end + + before { create(:api_key, :expired) } + + it 'returns API keys with either no expiration or future expiration dates' do + expect(subject).to match_array scoped + end + end end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index eaf5f2cbf48..1cdb63ebb1d 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -139,6 +139,28 @@ expect(organization).not_to be_valid end + + describe 'of hmac key uniqueness' do + before { create(:organization) } + + it { is_expected.to validate_uniqueness_of(:hmac_key) } + end + + describe 'of hmac key presence' do + subject { organization } + + context 'with a new record' do + let(:organization) { build(:organization) } + + it { is_expected.not_to validate_presence_of(:hmac_key) } + end + + context 'with a persisted record' do + let(:organization) { create(:organization) } + + it { is_expected.to validate_presence_of(:hmac_key) } + end + end end describe '#save' do @@ -146,6 +168,12 @@ context 'with a new record' do let(:organization) { build(:organization) } + let(:used_hmac_key) { create(:organization).hmac_key } + let(:unique_hmac_key) { SecureRandom.uuid } + + before do + allow(SecureRandom).to receive(:uuid).and_return(used_hmac_key, unique_hmac_key) + end it 'sets document number prefix of organization' do subject @@ -153,6 +181,10 @@ expect(organization.document_number_prefix) .to eq "#{organization.name.first(3).upcase}-#{organization.id.last(4).upcase}" end + + it 'sets unique hmac key' do + expect { subject }.to change(organization, :hmac_key).to unique_hmac_key + end end context 'with a persisted record' do @@ -161,6 +193,10 @@ it 'does not change document number prefix of organization' do expect { subject }.not_to change(organization, :document_number_prefix) end + + it 'does not change the hmac key' do + expect { subject }.not_to change(organization, :hmac_key) + end end end @@ -200,4 +236,20 @@ end end end + + describe '#admins' do + subject { organization.admins } + + let(:organization) { create(:organization) } + let(:scoped) { create(:membership, organization:).user } + + before do + create(:membership) + create(:membership, organization:, role: [:manager, :finance].sample) + end + + it 'returns admins of the organization' do + expect(subject).to contain_exactly scoped + end + end end diff --git a/spec/models/webhook_spec.rb b/spec/models/webhook_spec.rb index 8bf20d20ecf..7bf20a8055d 100644 --- a/spec/models/webhook_spec.rb +++ b/spec/models/webhook_spec.rb @@ -55,8 +55,8 @@ describe '#hmac_signature' do it 'generates a correct hmac signature' do - api_key_value = webhook.organization.api_keys.first.value - hmac = OpenSSL::HMAC.digest('sha-256', api_key_value, webhook.payload.to_json) + hmac_key = webhook.organization.hmac_key + hmac = OpenSSL::HMAC.digest('sha-256', hmac_key, webhook.payload.to_json) base64_hmac = Base64.strict_encode64(hmac) expect(base64_hmac).to eq(webhook.hmac_signature) diff --git a/spec/requests/api/base_controller_spec.rb b/spec/requests/api/base_controller_spec.rb index acee4257b87..035afd94f03 100644 --- a/spec/requests/api/base_controller_spec.rb +++ b/spec/requests/api/base_controller_spec.rb @@ -26,12 +26,12 @@ def create describe 'authenticate' do before do - request.headers['Authorization'] = "Bearer #{token}" + request.headers['Authorization'] = "Bearer #{api_key.value}" get :index end context 'with valid authorization header' do - let(:token) { api_key.value } + let(:api_key) { [create(:api_key), create(:api_key, :expiring)].sample } it 'returns success response' do expect(response).to have_http_status(:success) @@ -39,7 +39,7 @@ def create end context 'with invalid authentication header' do - let(:token) { SecureRandom.uuid } + let(:api_key) { create(:api_key, :expired) } it 'returns an authentication error' do expect(response).to have_http_status(:unauthorized) diff --git a/spec/services/api_keys/rotate_service_spec.rb b/spec/services/api_keys/rotate_service_spec.rb new file mode 100644 index 00000000000..ed2e57109da --- /dev/null +++ b/spec/services/api_keys/rotate_service_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ApiKeys::RotateService, type: :service do + describe '#call' do + subject(:service_result) { described_class.call(api_key) } + + context 'when API key is provided' do + 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 + + 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:) + 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 'when API key is missing' do + let(:api_key) { nil } + + 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::NotFoundFailure) + expect(service_result.error.error_code).to eq('api_key_not_found') + end + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index fb95ca558cc..f859e9bb7f2 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -20,6 +20,8 @@ config.shared_context_metadata_behavior = :apply_to_host_groups + config.example_status_persistence_file_path = 'tmp/rspec_examples.txt' + # NOTE: Database cleaner config to turn off/on transactional mode config.before(:suite) do DatabaseCleaner.clean_with(:deletion)