Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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;
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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;
}
}
}
5 changes: 5 additions & 0 deletions src/Layers/W1/BaseApp/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
102 changes: 102 additions & 0 deletions src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading