Skip to content
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
Comment thread
dcenic marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,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.";
Comment thread
dcenic marked this conversation as resolved.
ResponseInStream: InStream;
InStream: InStream;
ResponseOutStream: OutStream;
Expand All @@ -84,6 +85,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();
Comment thread
dcenic marked this conversation as resolved.

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."
Comment thread
dcenic marked this conversation as resolved.
Comment thread
dcenic marked this conversation as resolved.
{
Access = Internal;
Permissions = TableData "VAT Reg. No. Lookup Quota" = rimd;

trigger OnRun()
Comment thread
dcenic marked this conversation as resolved.
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();
Comment thread
dcenic marked this conversation as resolved.
end;

local procedure GetQuotaUnderLock(var VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota")
begin
Comment thread
dcenic marked this conversation as resolved.
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;
}
}
}
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()
Comment thread
dcenic marked this conversation as resolved.
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();
Comment thread
dcenic marked this conversation as resolved.

// [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