diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al index 0a9c7ed0f9d..934db5e1c9e 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al @@ -69,6 +69,7 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" var VATRegNoSrvConfig: Record "VAT Reg. No. Srv Config"; SOAPWebServiceRequestMgt: Codeunit "SOAP Web Service Request Mgt."; + VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; ResponseInStream: InStream; InStream: InStream; ResponseOutStream: OutStream; @@ -80,6 +81,12 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" if VATRegistrationLog."VAT Registration No." = '' then Error(NoVATNoToValidateErr); + // Charge the per-environment daily VIES quota on the standard request path only - after the blank-number + // check and only when the lookup was not handled by a subscriber - so handled or invalid lookups that never + // contact VIES do not consume quota. The dedicated codeunit commits the counter before the request; that + // commit also commits the ambient transaction, the same boundary this codeunit already commits at below. + VATLookupQuotaMgt.Run(); + PrepareSOAPRequestBody(TempBlobBody); TempBlobBody.CreateInStream(InStream); diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al new file mode 100644 index 00000000000..f1bffccb453 --- /dev/null +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al @@ -0,0 +1,147 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.Finance.VAT.Registration; + +using System.Environment; +using System.Telemetry; + +/// +/// Enforces the per-environment daily EU VIES lookup quota, run as a dedicated codeunit on the standard +/// VIES request path. It increments and commits the daily counter before the outbound request so the count +/// stays durable. That commit also commits the caller's ambient transaction - the same boundary at which +/// codeunit 248 already commits around the outbound call - so it is not an isolated transaction. +/// +codeunit 247 "VAT Lookup Quota Mgt." +{ + Access = Internal; + Permissions = TableData "VAT Reg. No. Lookup Quota" = rimd; + + trigger OnRun() + begin + RegisterAndCheckQuota(); + end; + + var + DailyQuotaExceededErr: Label 'VAT registration number validation against the EU VIES service has reached the daily limit for this environment. Try again tomorrow, and avoid verifying VAT registration numbers in bulk.'; + DailyQuotaReachedTxt: Label 'The daily EU VAT reg. no. validation limit was reached for this environment.', Locked = true; + SecurityAuditDailyQuotaExceededTxt: Label 'The EU VAT Registration No. validation service (VIES) daily lookup limit was reached for this environment; further lookups are blocked for the rest of the day.', Locked = true; + EUVATRegNoValidationServiceTok: Label 'EUVATRegNoValidationServiceTelemetryCategoryTok', Locked = true; + QuotaTestOverride: Boolean; + QuotaTestMaxDailyCallCount: Integer; + + local procedure RegisterAndCheckQuota() + var + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; + EnvironmentInformation: Codeunit "Environment Information"; + AuditLog: Codeunit "Audit Log"; + begin + // The unauthenticated EU VIES service deny-lists the shared outbound IP address of a cloud app service + // when it receives high-volume validation, which then affects every co-located environment on that address. + // Cap the number of VIES lookups per environment per day so a single environment cannot flood VIES - from + // any session type (interactive, background or API) and from either the Base Application or a per-tenant + // extension that reuses this codeunit - and get the shared address deny-listed. The count is kept in a + // single row shared by all companies in the database (DataPerCompany = false) that is locked for the brief + // read-modify-write, so concurrent sessions increment it atomically without lost updates. Enforced online + // (SaaS) only; on-prem environments own their own outbound address and only affect themselves. + if not EnvironmentInformation.IsSaaS() then + exit; + + GetQuotaUnderLock(VATRegNoLookupQuota); + + // Reset the counter at the start of a new (UTC) day. + if VATRegNoLookupQuota."Window Date" <> Today() then begin + VATRegNoLookupQuota."Window Date" := Today(); + VATRegNoLookupQuota."Daily Call Count" := 0; + end; + + // Block once the daily limit is reached. Blocked calls are not counted (they never reach the service). + if VATRegNoLookupQuota."Daily Call Count" >= GetMaxDailyCallCount() then + Error(DailyQuotaExceededErr); + + VATRegNoLookupQuota."Daily Call Count" += 1; + + // On the call that reaches the limit, record it once - after this, lookups are blocked for the rest of the day. + if VATRegNoLookupQuota."Daily Call Count" = GetMaxDailyCallCount() then begin + // 4, 0 = AuditMessageOperation / AuditMessageOperationResult (standard security-audit codes; also routes the entry to Purview). + AuditLog.LogAuditMessage(SecurityAuditDailyQuotaExceededTxt, SecurityOperationResult::Failure, AuditCategory::Authorization, 4, 0); + Session.LogMessage('0000VL7', DailyQuotaReachedTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); + end; + + // Persist and commit the count before the outbound request so the increment stays durable even if the + // subsequent VIES call fails, and the row lock is released before the potentially slow VIES call. This + // commit also commits the caller's ambient transaction - the same boundary codeunit 248 commits at around + // the outbound call - so it is not isolated from caller state. + VATRegNoLookupQuota.Modify(); + Commit(); + end; + + local procedure GetQuotaUnderLock(var VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota") + begin + VATRegNoLookupQuota.LockTable(); + if VATRegNoLookupQuota.Get() then + exit; + // Create the single row on first use. Do not rely on install/upgrade triggers - they are not guaranteed + // to have run for every environment. + VATRegNoLookupQuota.Init(); + VATRegNoLookupQuota."Primary Key" := ''; + VATRegNoLookupQuota.Insert(); + end; + + local procedure GetMaxDailyCallCount(): Integer + begin + if QuotaTestOverride then + exit(QuotaTestMaxDailyCallCount); + // Legitimate use is < ~200 lookups per environment per day (99th percentile). 2000 leaves generous headroom + // while staying roughly 10x below the daily volume at which VIES deny-lists a shared outbound address. + exit(2000); + end; + + // The following members exist only so the automated tests can exercise the daily-quota decision logic + // without calling the external VIES service. They are internal, so the Base Application test libraries can + // reach them but per-tenant extensions cannot influence or bypass the quota. + internal procedure SetVIESCallQuotaLimitForTest(MaxDailyCallCount: Integer) + begin + QuotaTestOverride := true; + QuotaTestMaxDailyCallCount := MaxDailyCallCount; + end; + + internal procedure InvokeVIESCallQuotaForTest() + begin + RegisterAndCheckQuota(); + end; + + internal procedure SeedVIESCallQuotaForTest(WindowDate: Date; CallCount: Integer) + var + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; + begin + if not VATRegNoLookupQuota.Get() then begin + VATRegNoLookupQuota.Init(); + VATRegNoLookupQuota."Primary Key" := ''; + VATRegNoLookupQuota.Insert(); + end; + VATRegNoLookupQuota."Window Date" := WindowDate; + VATRegNoLookupQuota."Daily Call Count" := CallCount; + VATRegNoLookupQuota.Modify(); + end; + + internal procedure GetVIESCallCountForTest(): Integer + var + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; + begin + if not VATRegNoLookupQuota.Get() then + exit(0); + if VATRegNoLookupQuota."Window Date" <> Today() then + exit(0); + exit(VATRegNoLookupQuota."Daily Call Count"); + end; + + internal procedure ClearVIESCallQuotaForTest() + var + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; + begin + if VATRegNoLookupQuota.Get() then + VATRegNoLookupQuota.Delete(); + end; +} diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATRegNoLookupQuota.Table.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATRegNoLookupQuota.Table.al new file mode 100644 index 00000000000..32a4a3cd644 --- /dev/null +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATRegNoLookupQuota.Table.al @@ -0,0 +1,50 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.Finance.VAT.Registration; + +/// +/// Per-environment counter that tracks the number of EU VIES VAT registration number lookups performed per day. +/// Used to cap the daily lookup volume per environment (all companies in the database share one counter) so a +/// single environment cannot flood the shared, unauthenticated VIES service and get the shared outbound IP address +/// deny-listed. Holds a single row that is locked for the brief read-modify-write, so concurrent sessions +/// increment it atomically. +/// +table 243 "VAT Reg. No. Lookup Quota" +{ + Access = Internal; + DataPerCompany = false; + DataClassification = SystemMetadata; + ReplicateData = false; + InherentEntitlements = RIMDX; + InherentPermissions = RIMDX; + + fields + { + field(1; "Primary Key"; Code[10]) + { + Caption = 'Primary Key'; + DataClassification = SystemMetadata; + } + field(2; "Window Date"; Date) + { + Caption = 'Window Date'; + DataClassification = SystemMetadata; + } + field(3; "Daily Call Count"; Integer) + { + Caption = 'Daily Call Count'; + DataClassification = SystemMetadata; + MinValue = 0; + } + } + + keys + { + key(PK; "Primary Key") + { + Clustered = true; + } + } +} diff --git a/src/Layers/W1/BaseApp/app.json b/src/Layers/W1/BaseApp/app.json index b7b8a59642f..18ee7e915c1 100644 --- a/src/Layers/W1/BaseApp/app.json +++ b/src/Layers/W1/BaseApp/app.json @@ -26,6 +26,11 @@ } ], "internalsVisibleTo": [ + { + "id": "0f0955b8-92e2-4ce2-a580-3c4583dde9ae", + "name": "Tests-VAT", + "publisher": "Microsoft" + }, { "id": "6992416f-3f39-4d3c-8242-3fff61350bea", "name": "Business Central Cloud Migration - Previous Release", diff --git a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al index 9ec5dc75775..6cb71d304b2 100644 --- a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al +++ b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al @@ -33,6 +33,101 @@ codeunit 134193 "ERM VAT VIES Lookup UT" Address2Txt: Label 'Address2', Locked = true; WrongLogEntryOnPageErr: Label 'Unexpected entry in VAT Registration Log page.'; + [Test] + [TransactionModel(TransactionModel::AutoCommit)] + procedure DailyVIESCallQuotaBlocksWhenLimitReached() + var + VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; + EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; + Index: Integer; + begin + // [FEATURE] [VIES] [Throttling] + // [SCENARIO] When the daily VIES lookup quota is enforced, lookups beyond the daily limit are blocked. + Initialize(); + + // [GIVEN] An online (SaaS) environment where the daily VIES lookup quota is enforced at 3 lookups/day + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(true); + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); + VATLookupQuotaMgt.SetVIESCallQuotaLimitForTest(3); + + // [WHEN] The daily limit of lookups is registered + for Index := 1 to 3 do + VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); + + // [THEN] The counter is at the limit + Assert.AreEqual(3, VATLookupQuotaMgt.GetVIESCallCountForTest(), 'The lookup counter should be at the daily limit.'); + + // [WHEN] One more lookup is attempted [THEN] it is blocked + asserterror VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); + Assert.ExpectedError('reached the daily limit'); + + // [THEN] Blocked lookups are not counted (they never reach the service) + Assert.AreEqual(3, VATLookupQuotaMgt.GetVIESCallCountForTest(), 'Blocked lookups should not increment the counter.'); + + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); + end; + + [Test] + [TransactionModel(TransactionModel::AutoCommit)] + procedure DailyVIESCallQuotaResetsOnNewDay() + var + VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; + EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; + Index: Integer; + begin + // [FEATURE] [VIES] [Throttling] + // [SCENARIO] After the day rolls over the counter resets, so the customer can validate VAT numbers again + // up to a fresh daily limit. + Initialize(); + + // [GIVEN] An online (SaaS) environment with the quota enforced at 3 lookups/day + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(true); + VATLookupQuotaMgt.SetVIESCallQuotaLimitForTest(3); + // [GIVEN] Yesterday already reached the daily limit + VATLookupQuotaMgt.SeedVIESCallQuotaForTest(Today() - 1, 3); + + // [WHEN] The customer makes lookups today up to the daily limit + for Index := 1 to 3 do + VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); + + // [THEN] None are blocked and today's lookups are counted from zero (yesterday's count was discarded) + Assert.AreEqual(3, VATLookupQuotaMgt.GetVIESCallCountForTest(), 'The counter should reset and count today''s lookups from zero.'); + + // [THEN] The daily limit still applies for the rest of the same day + asserterror VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); + Assert.ExpectedError('reached the daily limit'); + + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); + end; + + [Test] + [TransactionModel(TransactionModel::AutoCommit)] + procedure DailyVIESCallQuotaSkippedOnPrem() + var + VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; + EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; + begin + // [FEATURE] [VIES] [Throttling] + // [SCENARIO] The daily quota applies to online environments only; on-premises lookups are never capped. + Initialize(); + + // [GIVEN] An on-premises environment with an (irrelevant) enforced limit of 1 lookup/day + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); + VATLookupQuotaMgt.SetVIESCallQuotaLimitForTest(1); + + // [WHEN] Several lookups are registered + VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); + VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); + + // [THEN] Nothing is counted or blocked because the quota does not apply on-premises + Assert.AreEqual(0, VATLookupQuotaMgt.GetVIESCallCountForTest(), 'The quota must not apply on-premises.'); + + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); + end; + [Test] procedure CheckInitDefaultTemplate() var @@ -1025,11 +1120,18 @@ codeunit 134193 "ERM VAT VIES Lookup UT" var VATRegistrationLog: Record "VAT Registration Log"; VATRegistrationLogDetails: Record "VAT Registration Log Details"; + VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; + EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; begin ClearTemplates(); VATRegistrationLog.DeleteAll(); VATRegistrationLogDetails.DeleteAll(); LibraryVariableStorage.Clear(); + // Reset the per-environment VIES quota state at the start of every test so that an AutoCommit quota + // test which fails mid-way cannot leak committed state (the quota row or the SaaS testability flag) + // into later tests when run under a non-isolated test runner. + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); end; local procedure ClearTemplates()