diff --git a/Gemfile b/Gemfile index f0000bbc65a..c945873dc9a 100644 --- a/Gemfile +++ b/Gemfile @@ -34,6 +34,7 @@ gem 'aws-sdk-ses', '~> 1.6' gem 'aws-sdk-eventbridge' gem 'base32-crockford' gem 'delayed_job_active_record', '~> 4.1' +gem 'blueprinter', '~> 0.25.3' gem 'device_detector' gem 'devise', '~> 4.8' gem 'dotiw', '>= 4.0.1' diff --git a/Gemfile.lock b/Gemfile.lock index 9d4c32c3e94..761d9c2e883 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -218,6 +218,7 @@ GEM bindata (2.4.9) binding_of_caller (0.8.0) debug_inspector (>= 0.0.1) + blueprinter (0.25.3) bootsnap (1.5.1) msgpack (~> 1.0) brakeman (4.10.0) @@ -723,6 +724,7 @@ DEPENDENCIES base32-crockford better_errors (>= 2.5.1) binding_of_caller + blueprinter (~> 0.25.3) bootsnap (~> 1.5.0) brakeman bullet (>= 6.0.2) diff --git a/app/blueprints/agreements/agency_blueprint.rb b/app/blueprints/agreements/agency_blueprint.rb new file mode 100644 index 00000000000..914769b0ee5 --- /dev/null +++ b/app/blueprints/agreements/agency_blueprint.rb @@ -0,0 +1,7 @@ +module Agreements + class AgencyBlueprint < Blueprinter::Base + identifier :abbreviation + + field :name + end +end diff --git a/app/blueprints/agreements/partner_account_blueprint.rb b/app/blueprints/agreements/partner_account_blueprint.rb new file mode 100644 index 00000000000..ce1a1ff81d2 --- /dev/null +++ b/app/blueprints/agreements/partner_account_blueprint.rb @@ -0,0 +1,11 @@ +module Agreements + class PartnerAccountBlueprint < Blueprinter::Base + identifier :requesting_agency + + field :name + field :became_partner, datetime_format: '%Y-%m-%d' + field :status do |account, _options| + account.partner_status + end + end +end diff --git a/app/models/agency.rb b/app/models/agency.rb index 8e7b67a2356..52e6cbf1767 100644 --- a/app/models/agency.rb +++ b/app/models/agency.rb @@ -2,7 +2,9 @@ class Agency < ApplicationRecord has_many :agency_identities, dependent: :destroy # rubocop:disable Rails/HasManyOrHasOneDependent has_many :service_providers, inverse_of: :agency + has_many :partner_accounts, class_name: 'Agreements::PartnerAccount' # rubocop:enable Rails/HasManyOrHasOneDependent + validates :name, presence: true validates :abbreviation, uniqueness: { case_sensitive: false } end diff --git a/app/models/agreements/partner_account.rb b/app/models/agreements/partner_account.rb index 779db6f541e..acd626c5d6b 100644 --- a/app/models/agreements/partner_account.rb +++ b/app/models/agreements/partner_account.rb @@ -10,4 +10,8 @@ class Agreements::PartnerAccount < ApplicationRecord validates :name, presence: true, uniqueness: true validates :requesting_agency, presence: true, uniqueness: true + + def partner_status + partner_account_status.partner_name + end end diff --git a/app/models/agreements/partner_account_status.rb b/app/models/agreements/partner_account_status.rb index 0300a2fdc7a..ff265800f20 100644 --- a/app/models/agreements/partner_account_status.rb +++ b/app/models/agreements/partner_account_status.rb @@ -7,4 +7,8 @@ class Agreements::PartnerAccountStatus < ApplicationRecord validates :order, presence: true, uniqueness: true, numericality: { only_integer: true } + + def partner_name + super || name + end end diff --git a/app/services/agreements/db/accounts_by_agency.rb b/app/services/agreements/db/accounts_by_agency.rb new file mode 100644 index 00000000000..3f935f274f4 --- /dev/null +++ b/app/services/agreements/db/accounts_by_agency.rb @@ -0,0 +1,11 @@ +module Agreements + module Db + class AccountsByAgency + def self.call + PartnerAccount. + includes(:agency, :partner_account_status). + group_by { |pa| pa.agency } + end + end + end +end diff --git a/app/services/agreements/reports/agencies_report.rb b/app/services/agreements/reports/agencies_report.rb new file mode 100644 index 00000000000..cec5302caff --- /dev/null +++ b/app/services/agreements/reports/agencies_report.rb @@ -0,0 +1,21 @@ +module Agreements + module Reports + class AgenciesReport < BaseReport + def initialize(agencies:) + @agencies = agencies + end + + def run + save_report( + 'agencies', + AgencyBlueprint.render(agencies, root: :agencies), + '', + ) + end + + private + + attr_reader :agencies + end + end +end diff --git a/app/services/agreements/reports/agency_partner_accounts_report.rb b/app/services/agreements/reports/agency_partner_accounts_report.rb new file mode 100644 index 00000000000..004b86c1d06 --- /dev/null +++ b/app/services/agreements/reports/agency_partner_accounts_report.rb @@ -0,0 +1,22 @@ +module Agreements + module Reports + class AgencyPartnerAccountsReport < BaseReport + def initialize(agency:, partner_accounts:) + @agency = agency.downcase + @partner_accounts = partner_accounts.sort_by(&:requesting_agency) + end + + def run + save_report( + 'partner_accounts', + PartnerAccountBlueprint.render(partner_accounts, root: :partner_accounts), + "agencies/#{agency}/", + ) + end + + private + + attr_reader :agency, :partner_accounts + end + end +end diff --git a/app/services/agreements/reports/base_report.rb b/app/services/agreements/reports/base_report.rb new file mode 100644 index 00000000000..0bbe3488af8 --- /dev/null +++ b/app/services/agreements/reports/base_report.rb @@ -0,0 +1,23 @@ +module Agreements + module Reports + class BaseReport < ::Reports::BaseReport + def gen_s3_bucket_name + prefix = IdentityConfig.store.partner_api_bucket_prefix + "#{prefix}.#{ec2_data.account_id}-#{ec2_data.region}" + end + + def save_report(report_name, body, path = nil) + if !IdentityConfig.store.s3_reports_enabled + logger.info('Not uploading report to S3, s3_reports_enabled is false') + return body + end + upload_file_to_s3(report_name, body, path) + end + + def upload_file_to_s3(report_name, body, path) + s3_path = "#{path}#{report_name}" + upload_file_to_s3_bucket(path: s3_path, body: body, content_type: 'application/json') + end + end + end +end diff --git a/app/services/agreements/reports/partner_api_report.rb b/app/services/agreements/reports/partner_api_report.rb new file mode 100644 index 00000000000..6e3d23131d0 --- /dev/null +++ b/app/services/agreements/reports/partner_api_report.rb @@ -0,0 +1,38 @@ +module Agreements + module Reports + class PartnerApiReport + def run + return unless IdentityConfig.store.enable_partner_api + + collect_account_data + upload_json_files + true + end + + private + + attr_reader :accounts_by_agency, :agencies + + def collect_account_data + @accounts_by_agency = Agreements::Db::AccountsByAgency.call + @agencies = accounts_by_agency.keys + @accounts_by_agency = accounts_by_agency.transform_keys(&:abbreviation) + end + + def upload_json_files + upload_agencies + upload_accounts + end + + def upload_agencies + AgenciesReport.new(agencies: agencies).run + end + + def upload_accounts + accounts_by_agency.each do |agency_abbr, accounts| + AgencyPartnerAccountsReport.new(agency: agency_abbr, partner_accounts: accounts).run + end + end + end + end +end diff --git a/config/application.yml.default b/config/application.yml.default index b91859c2aa9..4c33212fbfa 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -64,6 +64,7 @@ doc_capture_request_valid_for_minutes: '15' email_from: no-reply@login.gov email_from_display_name: Login.gov enable_load_testing_mode: 'false' +enable_partner_api: 'false' enable_rate_limiting: 'true' enable_test_routes: 'true' enable_usps_verification: 'true' @@ -122,6 +123,7 @@ otps_per_ip_period: '300' otps_per_ip_track_only_mode: 'true' outbound_connection_check_url: 'https://checkip.amazonaws.com' participate_in_dap: 'false' +partner_api_bucket_prefix: '' password_max_attempts: '3' personal_key_retired: 'true' phone_format_e164_opt_out_list: '[]' diff --git a/config/initializers/blueprinter.rb b/config/initializers/blueprinter.rb new file mode 100644 index 00000000000..4907e347d67 --- /dev/null +++ b/config/initializers/blueprinter.rb @@ -0,0 +1,3 @@ +Blueprinter.configure do |config| + config.sort_fields_by = :definition +end diff --git a/config/initializers/job_configurations.rb b/config/initializers/job_configurations.rb index 9041b5d94b2..aebe33ad2c0 100644 --- a/config/initializers/job_configurations.rb +++ b/config/initializers/job_configurations.rb @@ -190,3 +190,11 @@ timeout: 300, callback: -> { Reports::MonthlyGpoLetterRequestsReport.new.call }, ) + +# Send Partner API reports to S3 +JobRunner::Runner.add_config JobRunner::JobConfiguration.new( + name: 'Partner API report', + interval: 24 * 60 * 60, # 24 hours + timeout: 300, + callback: -> { Agreements::Reports::PartnerApiReport.new.call }, +) diff --git a/lib/identity_config.rb b/lib/identity_config.rb index e563743df84..e04d909b7bb 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -118,6 +118,7 @@ def self.build_store(config_map) config.add(:email_from, type: :string) config.add(:email_from_display_name, type: :string) config.add(:enable_load_testing_mode, type: :boolean) + config.add(:enable_partner_api, type: :boolean) config.add(:enable_rate_limiting, type: :boolean) config.add(:enable_test_routes, type: :boolean) config.add(:enable_usps_verification, type: :boolean) @@ -182,6 +183,7 @@ def self.build_store(config_map) config.add(:otps_per_ip_track_only_mode, type: :boolean) config.add(:outbound_connection_check_url) config.add(:participate_in_dap, type: :boolean) + config.add(:partner_api_bucket_prefix, type: :string) config.add(:password_max_attempts, type: :integer) config.add(:password_pepper, type: :string) config.add(:personal_key_retired, type: :boolean) diff --git a/spec/blueprints/agreements/agency_blueprint_spec.rb b/spec/blueprints/agreements/agency_blueprint_spec.rb new file mode 100644 index 00000000000..f2a981777ec --- /dev/null +++ b/spec/blueprints/agreements/agency_blueprint_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +RSpec.describe Agreements::AgencyBlueprint do + let(:agency) do + create(:agency, abbreviation: 'ABC', name: 'Awesome Bureau of Comedy') + end + let(:expected) do + { + agencies: [ + { + abbreviation: 'ABC', + name: 'Awesome Bureau of Comedy', + }, + ], + }.to_json + end + + it 'renders the appropriate json' do + expect(described_class.render([agency], root: :agencies)).to eq(expected) + end +end diff --git a/spec/blueprints/agreements/partner_account_blueprint_spec.rb b/spec/blueprints/agreements/partner_account_blueprint_spec.rb new file mode 100644 index 00000000000..74250b4a54f --- /dev/null +++ b/spec/blueprints/agreements/partner_account_blueprint_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +RSpec.describe Agreements::PartnerAccountBlueprint do + let(:status) do + create( + :partner_account_status, + name: 'secret', + partner_name: 'public', + ) + end + let(:account) do + create( + :partner_account, + partner_account_status: status, + requesting_agency: 'ABC-DEF', + name: 'Department of Energy Fusion', + became_partner: '2021-01-01', + ) + end + let(:expected) do + { + partner_accounts: [ + { + requesting_agency: 'ABC-DEF', + name: 'Department of Energy Fusion', + became_partner: '2021-01-01', + status: 'public', + }, + ], + }.to_json + end + + it 'renders the appropriate json' do + expect(described_class.render([account], root: :partner_accounts)).to eq(expected) + end +end diff --git a/spec/models/agency_spec.rb b/spec/models/agency_spec.rb index 60b24bdf7af..9e4ba4cef3a 100644 --- a/spec/models/agency_spec.rb +++ b/spec/models/agency_spec.rb @@ -2,7 +2,9 @@ describe Agency do describe 'Associations' do - it { is_expected.to have_many(:agency_identities) } + it { is_expected.to have_many(:agency_identities).dependent(:destroy) } + it { is_expected.to have_many(:service_providers).inverse_of(:agency) } + it { is_expected.to have_many(:partner_accounts).class_name('Agreements::PartnerAccount') } end describe 'validations' do let(:agency) { build_stubbed(:agency) } diff --git a/spec/models/agreements/partner_account_spec.rb b/spec/models/agreements/partner_account_spec.rb index 903d732c2d8..cedc364e54c 100644 --- a/spec/models/agreements/partner_account_spec.rb +++ b/spec/models/agreements/partner_account_spec.rb @@ -16,4 +16,12 @@ it { is_expected.to have_many(:iaa_orders).through(:iaa_gtcs) } it { is_expected.to have_many(:integrations) } end + + describe '#partner_status' do + it 'returns the partner_name of the associated partner_account_status' do + status = build(:partner_account_status, partner_name: 'foo') + account = build(:partner_account, partner_account_status: status) + expect(account.partner_status).to eq('foo') + end + end end diff --git a/spec/models/agreements/partner_account_status_spec.rb b/spec/models/agreements/partner_account_status_spec.rb index 40cd8140eb9..28065f41415 100644 --- a/spec/models/agreements/partner_account_status_spec.rb +++ b/spec/models/agreements/partner_account_status_spec.rb @@ -12,4 +12,16 @@ it { is_expected.to have_many(:partner_accounts) } end + + describe '#partner_name' do + it 'returns the partner_name if set' do + status = build(:partner_account_status, name: 'foo', partner_name: 'bar') + expect(status.partner_name).to eq('bar') + end + + it 'returns the name if no partner_name is set' do + status = build(:partner_account_status, name: 'foo', partner_name: nil) + expect(status.partner_name).to eq('foo') + end + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 723c239686b..35483e5e66f 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -65,6 +65,7 @@ config.include Features::MailerHelper, type: :feature config.include Features::SessionHelper, type: :feature config.include Features::StripTagsHelper, type: :feature + config.include AgreementsHelper config.include AnalyticsHelper config.include AwsKmsClientHelper config.include KeyRotationHelper diff --git a/spec/services/agreements/db/accounts_by_agency_spec.rb b/spec/services/agreements/db/accounts_by_agency_spec.rb new file mode 100644 index 00000000000..0c3e04aac72 --- /dev/null +++ b/spec/services/agreements/db/accounts_by_agency_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +RSpec.describe Agreements::Db::AccountsByAgency do + let(:agency1) { create(:agency) } + let(:agency2) { create(:agency) } + + before { clear_agreements_data } + + describe '.call' do + it 'returns all partner accounts grouped by agency' do + account1, account2 = create_pair(:partner_account, agency: agency1) + account3 = create(:partner_account, agency: agency2) + expected = { + agency1 => [account1, account2], + agency2 => [account3], + } + + expect(described_class.call).to eq(expected) + end + end +end diff --git a/spec/services/agreements/reports/agencies_report_spec.rb b/spec/services/agreements/reports/agencies_report_spec.rb new file mode 100644 index 00000000000..88ed29b510b --- /dev/null +++ b/spec/services/agreements/reports/agencies_report_spec.rb @@ -0,0 +1,10 @@ +require 'rails_helper' + +RSpec.describe Agreements::Reports::AgenciesReport do + it 'uploads the JSON serialization of the passed agencies' do + agency = build(:agency) + expected = Agreements::AgencyBlueprint.render([agency], root: :agencies) + + expect(described_class.new(agencies: [agency]).run).to eq(expected) + end +end diff --git a/spec/services/agreements/reports/agency_partner_accounts_report_spec.rb b/spec/services/agreements/reports/agency_partner_accounts_report_spec.rb new file mode 100644 index 00000000000..961bf94de81 --- /dev/null +++ b/spec/services/agreements/reports/agency_partner_accounts_report_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +RSpec.describe Agreements::Reports::AgencyPartnerAccountsReport do + let(:agency) { create(:agency) } + let(:partner_accounts) do + build_pair(:partner_account, agency: agency).sort_by(&:requesting_agency) + end + + it 'uploads the JSON serialization of the passed partner accounts' do + expected = Agreements::PartnerAccountBlueprint.render(partner_accounts, root: :partner_accounts) + report_object = described_class.new( + agency: agency.abbreviation, + partner_accounts: partner_accounts, + ) + expect(report_object.run).to eq(expected) + end +end diff --git a/spec/services/agreements/reports/partner_api_report_spec.rb b/spec/services/agreements/reports/partner_api_report_spec.rb new file mode 100644 index 00000000000..530cfa272dc --- /dev/null +++ b/spec/services/agreements/reports/partner_api_report_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +RSpec.describe Agreements::Reports::PartnerApiReport do + before do + store = double(RedactedStruct).tap do |s| + allow(s).to receive(:enable_partner_api).and_return(true) + allow(s).to receive(:s3_reports_enabled).and_return(false) + end + allow(IdentityConfig).to receive(:store).and_return(store) + end + + it 'runs a series of reports' do + # just a smoke test + expect(described_class.new.run).to eq(true) + end +end diff --git a/spec/support/agreements_helper.rb b/spec/support/agreements_helper.rb new file mode 100644 index 00000000000..3fa7305fd7f --- /dev/null +++ b/spec/support/agreements_helper.rb @@ -0,0 +1,12 @@ +module AgreementsHelper + def clear_agreements_data + Agreements::IntegrationUsage.delete_all + Agreements::IaaOrder.delete_all + Agreements::Integration.delete_all + Agreements::IntegrationStatus.delete_all + Agreements::IaaGtc.delete_all + Agreements::IaaStatus.delete_all + Agreements::PartnerAccount.delete_all + Agreements::PartnerAccountStatus.delete_all + end +end