From bbaed87d4f41c0a81a7e8aae492b531a8f55989d Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Tue, 22 Sep 2026 14:14:16 +0200 Subject: [PATCH 01/10] [VIES Integration] Disallow running codeunit 248 in background and API sessions --- .../VATLookupExtDataHndl.Codeunit.al | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) 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 26a51c3a4f8..6e42e78fb66 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al @@ -7,6 +7,7 @@ namespace Microsoft.Finance.VAT.Registration; using Microsoft.CRM.Contact; using Microsoft.Sales.Customer; using System; +using System.Environment; using System.Integration; using System.Reflection; using System.Telemetry; @@ -26,6 +27,8 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" var IsHandled: Boolean; begin + BlockAutomatedSessionAccess(); + InitVATRegistrationLog(Rec); VATRegistrationLog := Rec; @@ -49,11 +52,45 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" EUVATRegNoValidationServiceTok: Label 'EUVATRegNoValidationServiceTelemetryCategoryTok', Locked = true; ValidationSuccessfulMsg: Label 'The VAT reg. no. validation was successful', Locked = true; ValidationFailureMsg: Label 'The VAT reg. no. validation failed. Http request failure', Locked = true; + AutomatedAccessBlockedErr: Label 'VAT registration number validation against the EU VIES service is not available from API or background (non-interactive) sessions. Verify VAT registration numbers interactively instead.'; + AutomatedAccessBlockedMsg: Label 'The VAT reg. no. validation was blocked because it was invoked from an API or background session.', Locked = true; + SecurityAuditAutomatedAccessBlockedTxt: Label 'The EU VAT Registration No. validation service (VIES) lookup was blocked because it was invoked from an automated (API) or background session.', Locked = true; ResponseTooLargeErr: Label 'The response from the EU VAT Registration No. validation service (VIES) exceeded the maximum allowed size and was rejected.'; ResponseTooLargeMsg: Label 'The VAT reg. no. validation failed. The response exceeded the maximum allowed size.', Locked = true; SecurityAuditResponseTooLargeTxt: Label 'The EU VAT Registration No. validation service (VIES) returned a response that exceeded the maximum allowed size.', Locked = true; VATRegistrationURL: Text; + local procedure BlockAutomatedSessionAccess() + var + EnvironmentInformation: Codeunit "Environment Information"; + AuditLog: Codeunit "Audit Log"; + begin + // The unauthenticated EU VIES service blocks the shared outbound IP address of a cloud app service when it + // receives high-volume automated validation, which then affects every co-located tenant on that address. + // Online (SaaS), reject automated (API/OData/SOAP) and background (non-interactive) sessions so a job queue + // or integration cannot repeatedly bulk-validate against VIES and get the shared address deny-listed. + // Interactive validation is unaffected. On-prem is not restricted because customers there own their own + // outbound address and only affect themselves. + if not EnvironmentInformation.IsSaaS() then + exit; + if IsInteractiveClientSession() then + exit; + + // 4, 0 = AuditMessageOperation / AuditMessageOperationResult (standard security-audit codes; also routes the entry to Purview). + AuditLog.LogAuditMessage(SecurityAuditAutomatedAccessBlockedTxt, SecurityOperationResult::Failure, AuditCategory::Authorization, 4, 0); + Session.LogMessage('', AutomatedAccessBlockedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); + Error(AutomatedAccessBlockedErr); + end; + + local procedure IsInteractiveClientSession(): Boolean + var + ClientTypeManagement: Codeunit "Client Type Management"; + begin + if not GuiAllowed() then + exit(false); + exit(not (ClientTypeManagement.GetCurrentClientType() in [ClientType::Api, ClientType::SOAP, ClientType::OData, ClientType::ODataV4])); + end; + local procedure LookupVatRegistrationFromWebService(ShowErrors: Boolean) var TempBlobRequestBody: Codeunit "Temp Blob"; From e7fe86c100f6c4d4e022d4ebf49776af7dbe560c Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Tue, 22 Sep 2026 14:19:09 +0200 Subject: [PATCH 02/10] [VIES Integration] Disallow running codeunit 248 in background and API sessions --- .../Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6e42e78fb66..6891407572f 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al @@ -78,7 +78,7 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" // 4, 0 = AuditMessageOperation / AuditMessageOperationResult (standard security-audit codes; also routes the entry to Purview). AuditLog.LogAuditMessage(SecurityAuditAutomatedAccessBlockedTxt, SecurityOperationResult::Failure, AuditCategory::Authorization, 4, 0); - Session.LogMessage('', AutomatedAccessBlockedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); + Session.LogMessage('0000VL4', AutomatedAccessBlockedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); Error(AutomatedAccessBlockedErr); end; From ed031ec6af0d45722671a585571bcf12b44df2a4 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Tue, 22 Sep 2026 22:27:33 +0200 Subject: [PATCH 03/10] [VIES Integration] Per-tenant daily request rate-limit --- .../VATLookupExtDataHndl.Codeunit.al | 138 +++++++++++++++--- .../Tests/VAT/ERMVATVIESLookupUT.Codeunit.al | 119 +++++++++++++++ 2 files changed, 236 insertions(+), 21 deletions(-) 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 6891407572f..e89fb074507 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al @@ -27,7 +27,7 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" var IsHandled: Boolean; begin - BlockAutomatedSessionAccess(); + RegisterAndCheckVIESCallQuota(); InitVATRegistrationLog(Rec); VATRegistrationLog := Rec; @@ -52,43 +52,139 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" EUVATRegNoValidationServiceTok: Label 'EUVATRegNoValidationServiceTelemetryCategoryTok', Locked = true; ValidationSuccessfulMsg: Label 'The VAT reg. no. validation was successful', Locked = true; ValidationFailureMsg: Label 'The VAT reg. no. validation failed. Http request failure', Locked = true; - AutomatedAccessBlockedErr: Label 'VAT registration number validation against the EU VIES service is not available from API or background (non-interactive) sessions. Verify VAT registration numbers interactively instead.'; - AutomatedAccessBlockedMsg: Label 'The VAT reg. no. validation was blocked because it was invoked from an API or background session.', Locked = true; - SecurityAuditAutomatedAccessBlockedTxt: Label 'The EU VAT Registration No. validation service (VIES) lookup was blocked because it was invoked from an automated (API) or background session.', Locked = true; + 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.'; + DailyQuotaReachedMsg: Label 'The daily EU VAT reg. no. validation limit was reached for this environment.', Locked = true; + SecurityAuditDailyQuotaExceededTxt: Label 'An EU VAT Registration No. validation service (VIES) lookup was blocked because the environment reached its daily lookup limit.', Locked = true; + VIESCallQuotaKeyTok: Label 'VATRegNoLookupDailyCallQuota', Locked = true; ResponseTooLargeErr: Label 'The response from the EU VAT Registration No. validation service (VIES) exceeded the maximum allowed size and was rejected.'; ResponseTooLargeMsg: Label 'The VAT reg. no. validation failed. The response exceeded the maximum allowed size.', Locked = true; SecurityAuditResponseTooLargeTxt: Label 'The EU VAT Registration No. validation service (VIES) returned a response that exceeded the maximum allowed size.', Locked = true; VATRegistrationURL: Text; + QuotaTestOverride: Boolean; + QuotaTestEnforced: Boolean; + QuotaTestMaxDailyCallCount: Integer; - local procedure BlockAutomatedSessionAccess() + local procedure RegisterAndCheckVIESCallQuota() var EnvironmentInformation: Codeunit "Environment Information"; AuditLog: Codeunit "Audit Log"; + WindowDate: Date; + CallCount: Integer; begin - // The unauthenticated EU VIES service blocks the shared outbound IP address of a cloud app service when it - // receives high-volume automated validation, which then affects every co-located tenant on that address. - // Online (SaaS), reject automated (API/OData/SOAP) and background (non-interactive) sessions so a job queue - // or integration cannot repeatedly bulk-validate against VIES and get the shared address deny-listed. - // Interactive validation is unaffected. On-prem is not restricted because customers there own their own - // outbound address and only affect themselves. + // 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 tenant on that address. + // Cap the number of VIES lookups per tenant per day so a single tenant 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. Because the counter is + // read/written here (Base Application code), it lives in the Base Application's Isolated Storage, so all + // callers share one tenant-wide counter that no extension can read or reset. Enforced online (SaaS) only; + // on-prem tenants own their own outbound address and only affect themselves. if not EnvironmentInformation.IsSaaS() then exit; - if IsInteractiveClientSession() then + + ReadVIESCallQuota(WindowDate, CallCount); + + // Reset the counter at the start of a new (UTC) day. + if WindowDate <> Today() then begin + WindowDate := Today(); + CallCount := 0; + end; + + // Block once the daily limit is reached. Blocked calls are not counted (they never reach the service). + if CallCount >= GetMaxDailyCallCount() then + if IsDailyQuotaEnforced() 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); + Error(DailyQuotaExceededErr); + end; + + CallCount += 1; + + // Emit one telemetry signal per environment per day, on the call that reaches the limit. + if CallCount = GetMaxDailyCallCount() then + Session.LogMessage('', DailyQuotaReachedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); + + // Persist and commit the count before the outbound request, so it is durable regardless of the outer + // transaction outcome and so no database lock is held while waiting for the (potentially slow) response. + WriteVIESCallQuota(WindowDate, CallCount); + Commit(); + end; + + local procedure ReadVIESCallQuota(var WindowDate: Date; var CallCount: Integer) + var + StoredValue: Text; + ValueParts: List of [Text]; + begin + WindowDate := 0D; + CallCount := 0; + if not IsolatedStorage.Get(VIESCallQuotaKeyTok, DataScope::Module, StoredValue) then + exit; + ValueParts := StoredValue.Split('|'); + if ValueParts.Count() < 2 then exit; + Evaluate(WindowDate, ValueParts.Get(1), 9); // 9 = XML/culture-invariant format + Evaluate(CallCount, ValueParts.Get(2)); + end; + + local procedure WriteVIESCallQuota(WindowDate: Date; CallCount: Integer) + begin + // Stored as "|" in the Base Application's module-scoped Isolated Storage (tenant-wide, + // not accessible to other extensions). Module scope is intentional: the daily cap is per environment, + // aggregated across companies, because the shared outbound address is per environment. + IsolatedStorage.Set(VIESCallQuotaKeyTok, Format(WindowDate, 0, 9) + '|' + Format(CallCount), DataScope::Module); + end; - // 4, 0 = AuditMessageOperation / AuditMessageOperationResult (standard security-audit codes; also routes the entry to Purview). - AuditLog.LogAuditMessage(SecurityAuditAutomatedAccessBlockedTxt, SecurityOperationResult::Failure, AuditCategory::Authorization, 4, 0); - Session.LogMessage('0000VL4', AutomatedAccessBlockedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); - Error(AutomatedAccessBlockedErr); + local procedure GetMaxDailyCallCount(): Integer + begin + if QuotaTestOverride then + exit(QuotaTestMaxDailyCallCount); + // Legitimate use is < ~200 lookups per tenant 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; + + local procedure IsDailyQuotaEnforced(): Boolean + begin + if QuotaTestOverride then + exit(QuotaTestEnforced); + // Log-only during initial rollout: telemetry is emitted when the limit is reached, but lookups are not + // blocked yet. Set to true to enforce the cap once telemetry confirms no legitimate tenant reaches it. + exit(false); + 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 SetVIESCallQuotaTestState(EnforceQuota: Boolean; MaxDailyCallCount: Integer) + begin + QuotaTestOverride := true; + QuotaTestEnforced := EnforceQuota; + QuotaTestMaxDailyCallCount := MaxDailyCallCount; + end; + + internal procedure InvokeVIESCallQuotaForTest() + begin + RegisterAndCheckVIESCallQuota(); + end; + + internal procedure SeedVIESCallQuotaForTest(WindowDate: Date; CallCount: Integer) + begin + WriteVIESCallQuota(WindowDate, CallCount); end; - local procedure IsInteractiveClientSession(): Boolean + internal procedure GetVIESCallCountForTest() CallCount: Integer var - ClientTypeManagement: Codeunit "Client Type Management"; + WindowDate: Date; + begin + ReadVIESCallQuota(WindowDate, CallCount); + if WindowDate <> Today() then + exit(0); + end; + + internal procedure ClearVIESCallQuotaForTest() begin - if not GuiAllowed() then - exit(false); - exit(not (ClientTypeManagement.GetCurrentClientType() in [ClientType::Api, ClientType::SOAP, ClientType::OData, ClientType::ODataV4])); + if IsolatedStorage.Contains(VIESCallQuotaKeyTok, DataScope::Module) then + IsolatedStorage.Delete(VIESCallQuotaKeyTok, DataScope::Module); end; local procedure LookupVatRegistrationFromWebService(ShowErrors: Boolean) diff --git a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al index 9ec5dc75775..b0849c9ea77 100644 --- a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al +++ b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al @@ -33,6 +33,125 @@ codeunit 134193 "ERM VAT VIES Lookup UT" Address2Txt: Label 'Address2', Locked = true; WrongLogEntryOnPageErr: Label 'Unexpected entry in VAT Registration Log page.'; + [Test] + procedure DailyVIESCallQuotaBlocksWhenLimitReached() + var + VATLookupExtDataHndl: Codeunit "VAT Lookup Ext. Data Hndl"; + 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); + VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + VATLookupExtDataHndl.SetVIESCallQuotaTestState(true, 3); + + // [WHEN] The daily limit of lookups is registered + for Index := 1 to 3 do + VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + + // [THEN] The counter is at the limit + Assert.AreEqual(3, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'The lookup counter should be at the daily limit.'); + + // [WHEN] One more lookup is attempted [THEN] it is blocked + asserterror VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + Assert.ExpectedError('reached the daily limit'); + + // [THEN] Blocked lookups are not counted (they never reach the service) + Assert.AreEqual(3, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'Blocked lookups should not increment the counter.'); + + VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); + end; + + [Test] + procedure DailyVIESCallQuotaLogOnlyDoesNotBlock() + var + VATLookupExtDataHndl: Codeunit "VAT Lookup Ext. Data Hndl"; + EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; + Index: Integer; + begin + // [FEATURE] [VIES] [Throttling] + // [SCENARIO] In log-only mode the daily quota keeps counting but never blocks a lookup. + Initialize(); + + // [GIVEN] An online (SaaS) environment with the quota in log-only mode and a limit of 2 lookups/day + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(true); + VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + VATLookupExtDataHndl.SetVIESCallQuotaTestState(false, 2); + + // [WHEN] More lookups than the limit are registered + for Index := 1 to 5 do + VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + + // [THEN] None are blocked and all are counted + Assert.AreEqual(5, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'Log-only mode should keep counting without blocking.'); + + VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); + end; + + [Test] + procedure DailyVIESCallQuotaResetsOnNewDay() + var + VATLookupExtDataHndl: Codeunit "VAT Lookup Ext. Data Hndl"; + 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); + VATLookupExtDataHndl.SetVIESCallQuotaTestState(true, 3); + // [GIVEN] Yesterday already reached the daily limit + VATLookupExtDataHndl.SeedVIESCallQuotaForTest(Today() - 1, 3); + + // [WHEN] The customer makes lookups today up to the daily limit + for Index := 1 to 3 do + VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + + // [THEN] None are blocked and today's lookups are counted from zero (yesterday's count was discarded) + Assert.AreEqual(3, VATLookupExtDataHndl.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 VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + Assert.ExpectedError('reached the daily limit'); + + VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); + end; + + [Test] + procedure DailyVIESCallQuotaSkippedOnPrem() + var + VATLookupExtDataHndl: Codeunit "VAT Lookup Ext. Data Hndl"; + 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); + VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + VATLookupExtDataHndl.SetVIESCallQuotaTestState(true, 1); + + // [WHEN] Several lookups are registered + VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + + // [THEN] Nothing is counted or blocked because the quota does not apply on-premises + Assert.AreEqual(0, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'The quota must not apply on-premises.'); + + VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + end; + [Test] procedure CheckInitDefaultTemplate() var From 8b3f2bb64e52ff061cc1aebe4a581ae99ef9da21 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Wed, 23 Sep 2026 07:19:43 +0200 Subject: [PATCH 04/10] [VIES Integration] Per-tenant daily request rate-limit --- .../Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e89fb074507..692e9ccefbe 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al @@ -102,7 +102,7 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" // Emit one telemetry signal per environment per day, on the call that reaches the limit. if CallCount = GetMaxDailyCallCount() then - Session.LogMessage('', DailyQuotaReachedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); + Session.LogMessage('0000VL7', DailyQuotaReachedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); // Persist and commit the count before the outbound request, so it is durable regardless of the outer // transaction outcome and so no database lock is held while waiting for the (potentially slow) response. From 00b8aa7cff8844b19e65bff569d2a12d3df6b8ef Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Wed, 23 Sep 2026 11:08:54 +0200 Subject: [PATCH 05/10] [VIES Integration] Per-tenant daily request rate-limit --- .../VATLookupExtDataHndl.Codeunit.al | 119 +++++++++--------- .../Registration/VATRegNoLookupQuota.Table.al | 49 ++++++++ .../Tests/VAT/ERMVATVIESLookupUT.Codeunit.al | 33 +---- 3 files changed, 110 insertions(+), 91 deletions(-) create mode 100644 src/Layers/W1/BaseApp/Finance/VAT/Registration/VATRegNoLookupQuota.Table.al 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 692e9ccefbe..9137c9a55f4 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al @@ -20,7 +20,8 @@ using System.Xml; /// codeunit 248 "VAT Lookup Ext. Data Hndl" { - Permissions = TableData "VAT Registration Log" = rimd; + Permissions = TableData "VAT Registration Log" = rimd, + TableData "VAT Reg. No. Lookup Quota" = rimd; TableNo = "VAT Registration Log"; trigger OnRun() @@ -55,83 +56,76 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" 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.'; DailyQuotaReachedMsg: Label 'The daily EU VAT reg. no. validation limit was reached for this environment.', Locked = true; SecurityAuditDailyQuotaExceededTxt: Label 'An EU VAT Registration No. validation service (VIES) lookup was blocked because the environment reached its daily lookup limit.', Locked = true; - VIESCallQuotaKeyTok: Label 'VATRegNoLookupDailyCallQuota', Locked = true; ResponseTooLargeErr: Label 'The response from the EU VAT Registration No. validation service (VIES) exceeded the maximum allowed size and was rejected.'; ResponseTooLargeMsg: Label 'The VAT reg. no. validation failed. The response exceeded the maximum allowed size.', Locked = true; SecurityAuditResponseTooLargeTxt: Label 'The EU VAT Registration No. validation service (VIES) returned a response that exceeded the maximum allowed size.', Locked = true; VATRegistrationURL: Text; QuotaTestOverride: Boolean; - QuotaTestEnforced: Boolean; QuotaTestMaxDailyCallCount: Integer; local procedure RegisterAndCheckVIESCallQuota() var + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; EnvironmentInformation: Codeunit "Environment Information"; AuditLog: Codeunit "Audit Log"; - WindowDate: Date; - CallCount: Integer; 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 tenant on that address. - // Cap the number of VIES lookups per tenant per day so a single tenant cannot flood VIES - from any + // Cap the number of VIES lookups per environment per day so a single tenant 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. Because the counter is - // read/written here (Base Application code), it lives in the Base Application's Isolated Storage, so all - // callers share one tenant-wide counter that no extension can read or reset. Enforced online (SaaS) only; - // on-prem tenants own their own outbound address and only affect themselves. + // extension that reuses this codeunit - and get the shared address deny-listed. The count is kept in a + // single tenant-wide row (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 + // tenants own their own outbound address and only affect themselves. if not EnvironmentInformation.IsSaaS() then exit; - ReadVIESCallQuota(WindowDate, CallCount); + GetVIESCallQuotaUnderLock(VATRegNoLookupQuota); // Reset the counter at the start of a new (UTC) day. - if WindowDate <> Today() then begin - WindowDate := Today(); - CallCount := 0; + 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 CallCount >= GetMaxDailyCallCount() then - if IsDailyQuotaEnforced() 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); - Error(DailyQuotaExceededErr); - end; + if VATRegNoLookupQuota."Daily Call Count" >= GetMaxDailyCallCount() then + Error(DailyQuotaExceededErr); - CallCount += 1; + VATRegNoLookupQuota."Daily Call Count" += 1; - // Emit one telemetry signal per environment per day, on the call that reaches the limit. - if CallCount = GetMaxDailyCallCount() then + // 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', DailyQuotaReachedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); + end; - // Persist and commit the count before the outbound request, so it is durable regardless of the outer - // transaction outcome and so no database lock is held while waiting for the (potentially slow) response. - WriteVIESCallQuota(WindowDate, CallCount); + // Persist and commit the count before the outbound request: the increment stays durable regardless of the + // (isolated) caller transaction outcome, and the row lock is released before the potentially slow VIES call. + VATRegNoLookupQuota.Modify(); Commit(); end; - local procedure ReadVIESCallQuota(var WindowDate: Date; var CallCount: Integer) - var - StoredValue: Text; - ValueParts: List of [Text]; + local procedure GetVIESCallQuotaUnderLock(var VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota") begin - WindowDate := 0D; - CallCount := 0; - if not IsolatedStorage.Get(VIESCallQuotaKeyTok, DataScope::Module, StoredValue) then - exit; - ValueParts := StoredValue.Split('|'); - if ValueParts.Count() < 2 then + VATRegNoLookupQuota.LockTable(); + if VATRegNoLookupQuota.Get() then exit; - Evaluate(WindowDate, ValueParts.Get(1), 9); // 9 = XML/culture-invariant format - Evaluate(CallCount, ValueParts.Get(2)); + // Create the single row on first use. Do not rely on install/upgrade triggers - they are not guaranteed + // to have run for every tenant. A concurrent creator makes the insert fail; the row is then read below. + if not TryInsertVIESCallQuotaRow() then; + VATRegNoLookupQuota.Get(); end; - local procedure WriteVIESCallQuota(WindowDate: Date; CallCount: Integer) + [TryFunction] + local procedure TryInsertVIESCallQuotaRow() + var + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; begin - // Stored as "|" in the Base Application's module-scoped Isolated Storage (tenant-wide, - // not accessible to other extensions). Module scope is intentional: the daily cap is per environment, - // aggregated across companies, because the shared outbound address is per environment. - IsolatedStorage.Set(VIESCallQuotaKeyTok, Format(WindowDate, 0, 9) + '|' + Format(CallCount), DataScope::Module); + VATRegNoLookupQuota.Init(); + VATRegNoLookupQuota."Primary Key" := ''; + VATRegNoLookupQuota.Insert(); end; local procedure GetMaxDailyCallCount(): Integer @@ -143,22 +137,12 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" exit(2000); end; - local procedure IsDailyQuotaEnforced(): Boolean - begin - if QuotaTestOverride then - exit(QuotaTestEnforced); - // Log-only during initial rollout: telemetry is emitted when the limit is reached, but lookups are not - // blocked yet. Set to true to enforce the cap once telemetry confirms no legitimate tenant reaches it. - exit(false); - 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 SetVIESCallQuotaTestState(EnforceQuota: Boolean; MaxDailyCallCount: Integer) + internal procedure SetVIESCallQuotaLimitForTest(MaxDailyCallCount: Integer) begin QuotaTestOverride := true; - QuotaTestEnforced := EnforceQuota; QuotaTestMaxDailyCallCount := MaxDailyCallCount; end; @@ -168,23 +152,36 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" end; internal procedure SeedVIESCallQuotaForTest(WindowDate: Date; CallCount: Integer) + var + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; begin - WriteVIESCallQuota(WindowDate, CallCount); + 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() CallCount: Integer + internal procedure GetVIESCallCountForTest(): Integer var - WindowDate: Date; + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; begin - ReadVIESCallQuota(WindowDate, CallCount); - if WindowDate <> Today() then + 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 IsolatedStorage.Contains(VIESCallQuotaKeyTok, DataScope::Module) then - IsolatedStorage.Delete(VIESCallQuotaKeyTok, DataScope::Module); + if VATRegNoLookupQuota.Get() then + VATRegNoLookupQuota.Delete(); end; local procedure LookupVatRegistrationFromWebService(ShowErrors: Boolean) 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..eff2a3aa211 --- /dev/null +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATRegNoLookupQuota.Table.al @@ -0,0 +1,49 @@ +// ------------------------------------------------------------------------------------------------ +// 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; + +/// +/// Tenant-wide counter that tracks the number of EU VIES VAT registration number lookups performed per day. +/// Used to cap the daily lookup volume per environment so a single tenant 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/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al index b0849c9ea77..a2e56725cef 100644 --- a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al +++ b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al @@ -47,7 +47,7 @@ codeunit 134193 "ERM VAT VIES Lookup UT" // [GIVEN] An online (SaaS) environment where the daily VIES lookup quota is enforced at 3 lookups/day EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(true); VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); - VATLookupExtDataHndl.SetVIESCallQuotaTestState(true, 3); + VATLookupExtDataHndl.SetVIESCallQuotaLimitForTest(3); // [WHEN] The daily limit of lookups is registered for Index := 1 to 3 do @@ -67,33 +67,6 @@ codeunit 134193 "ERM VAT VIES Lookup UT" EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); end; - [Test] - procedure DailyVIESCallQuotaLogOnlyDoesNotBlock() - var - VATLookupExtDataHndl: Codeunit "VAT Lookup Ext. Data Hndl"; - EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; - Index: Integer; - begin - // [FEATURE] [VIES] [Throttling] - // [SCENARIO] In log-only mode the daily quota keeps counting but never blocks a lookup. - Initialize(); - - // [GIVEN] An online (SaaS) environment with the quota in log-only mode and a limit of 2 lookups/day - EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(true); - VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); - VATLookupExtDataHndl.SetVIESCallQuotaTestState(false, 2); - - // [WHEN] More lookups than the limit are registered - for Index := 1 to 5 do - VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); - - // [THEN] None are blocked and all are counted - Assert.AreEqual(5, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'Log-only mode should keep counting without blocking.'); - - VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); - EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); - end; - [Test] procedure DailyVIESCallQuotaResetsOnNewDay() var @@ -108,7 +81,7 @@ codeunit 134193 "ERM VAT VIES Lookup UT" // [GIVEN] An online (SaaS) environment with the quota enforced at 3 lookups/day EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(true); - VATLookupExtDataHndl.SetVIESCallQuotaTestState(true, 3); + VATLookupExtDataHndl.SetVIESCallQuotaLimitForTest(3); // [GIVEN] Yesterday already reached the daily limit VATLookupExtDataHndl.SeedVIESCallQuotaForTest(Today() - 1, 3); @@ -140,7 +113,7 @@ codeunit 134193 "ERM VAT VIES Lookup UT" // [GIVEN] An on-premises environment with an (irrelevant) enforced limit of 1 lookup/day EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); - VATLookupExtDataHndl.SetVIESCallQuotaTestState(true, 1); + VATLookupExtDataHndl.SetVIESCallQuotaLimitForTest(1); // [WHEN] Several lookups are registered VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); From d90674766fade42cf08d39050a2b46a5d8018cf6 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Wed, 23 Sep 2026 12:33:41 +0200 Subject: [PATCH 06/10] [VIES Integration] Per-tenant daily request rate-limit --- .../VATLookupExtDataHndl.Codeunit.al | 25 ++++++------------- .../Registration/VATRegNoLookupQuota.Table.al | 9 ++++--- 2 files changed, 13 insertions(+), 21 deletions(-) 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 9137c9a55f4..6271b89fca4 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al @@ -70,13 +70,13 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" 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 tenant on that address. - // Cap the number of VIES lookups per environment per day so a single tenant cannot flood VIES - from any - // session type (interactive, background or API) and from either the Base Application or a per-tenant + // 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 tenant-wide row (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 - // tenants own their own outbound address and only affect themselves. + // 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; @@ -113,16 +113,7 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" 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 tenant. A concurrent creator makes the insert fail; the row is then read below. - if not TryInsertVIESCallQuotaRow() then; - VATRegNoLookupQuota.Get(); - end; - - [TryFunction] - local procedure TryInsertVIESCallQuotaRow() - var - VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; - begin + // to have run for every environment. VATRegNoLookupQuota.Init(); VATRegNoLookupQuota."Primary Key" := ''; VATRegNoLookupQuota.Insert(); @@ -132,7 +123,7 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" begin if QuotaTestOverride then exit(QuotaTestMaxDailyCallCount); - // Legitimate use is < ~200 lookups per tenant per day (99th percentile). 2000 leaves generous headroom + // 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; diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATRegNoLookupQuota.Table.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATRegNoLookupQuota.Table.al index eff2a3aa211..f1971c28dfb 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATRegNoLookupQuota.Table.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATRegNoLookupQuota.Table.al @@ -5,10 +5,11 @@ namespace Microsoft.Finance.VAT.Registration; /// -/// Tenant-wide counter that tracks the number of EU VIES VAT registration number lookups performed per day. -/// Used to cap the daily lookup volume per environment so a single tenant 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. +/// 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" { From ade62988952d88932f36ae4f8d52e7693d4a0aef Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Wed, 23 Sep 2026 16:17:33 +0200 Subject: [PATCH 07/10] [VIES Integration] Move VIES quota to a dedicated codeunit on the standard request path Address review feedback on the per-environment daily VIES quota: - S2: charge the quota only on the standard VIES request path, after the blank-number check and the OnRun IsHandled event, so handled/replaced or invalid lookups that never contact VIES no longer consume quota. - S1: extract the quota read-modify-write and its Commit into a dedicated codeunit 247 "VAT Lookup Quota Mgt." invoked via a codeunit run, so the counter commit is its own unit of work placed immediately before the outbound request. Note: codeunit 248 already commits around the outbound call in its normal flow, so this does not introduce a new ambient-commit point; it makes the quota increment durable and confines it to real VIES sends. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../VATLookupExtDataHndl.Codeunit.al | 129 +--------------- .../VATLookupQuotaMgt.Codeunit.al | 144 ++++++++++++++++++ .../Tests/VAT/ERMVATVIESLookupUT.Codeunit.al | 44 +++--- 3 files changed, 173 insertions(+), 144 deletions(-) create mode 100644 src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al 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 6271b89fca4..c894cb7900d 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al @@ -7,7 +7,6 @@ namespace Microsoft.Finance.VAT.Registration; using Microsoft.CRM.Contact; using Microsoft.Sales.Customer; using System; -using System.Environment; using System.Integration; using System.Reflection; using System.Telemetry; @@ -20,16 +19,13 @@ using System.Xml; /// codeunit 248 "VAT Lookup Ext. Data Hndl" { - Permissions = TableData "VAT Registration Log" = rimd, - TableData "VAT Reg. No. Lookup Quota" = rimd; + Permissions = TableData "VAT Registration Log" = rimd; TableNo = "VAT Registration Log"; trigger OnRun() var IsHandled: Boolean; begin - RegisterAndCheckVIESCallQuota(); - InitVATRegistrationLog(Rec); VATRegistrationLog := Rec; @@ -53,127 +49,10 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" EUVATRegNoValidationServiceTok: Label 'EUVATRegNoValidationServiceTelemetryCategoryTok', Locked = true; ValidationSuccessfulMsg: Label 'The VAT reg. no. validation was successful', Locked = true; ValidationFailureMsg: Label 'The VAT reg. no. validation failed. Http request failure', Locked = true; - 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.'; - DailyQuotaReachedMsg: Label 'The daily EU VAT reg. no. validation limit was reached for this environment.', Locked = true; - SecurityAuditDailyQuotaExceededTxt: Label 'An EU VAT Registration No. validation service (VIES) lookup was blocked because the environment reached its daily lookup limit.', Locked = true; ResponseTooLargeErr: Label 'The response from the EU VAT Registration No. validation service (VIES) exceeded the maximum allowed size and was rejected.'; ResponseTooLargeMsg: Label 'The VAT reg. no. validation failed. The response exceeded the maximum allowed size.', Locked = true; SecurityAuditResponseTooLargeTxt: Label 'The EU VAT Registration No. validation service (VIES) returned a response that exceeded the maximum allowed size.', Locked = true; VATRegistrationURL: Text; - QuotaTestOverride: Boolean; - QuotaTestMaxDailyCallCount: Integer; - - local procedure RegisterAndCheckVIESCallQuota() - 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; - - GetVIESCallQuotaUnderLock(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', DailyQuotaReachedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); - end; - - // Persist and commit the count before the outbound request: the increment stays durable regardless of the - // (isolated) caller transaction outcome, and the row lock is released before the potentially slow VIES call. - VATRegNoLookupQuota.Modify(); - Commit(); - end; - - local procedure GetVIESCallQuotaUnderLock(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 - RegisterAndCheckVIESCallQuota(); - 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; local procedure LookupVatRegistrationFromWebService(ShowErrors: Boolean) var @@ -194,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."; ResponseInStream: InStream; InStream: InStream; ResponseOutStream: OutStream; @@ -205,6 +85,11 @@ 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. Run as a dedicated codeunit so the counter commit is its own unit of work. + 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..f29f7808976 --- /dev/null +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al @@ -0,0 +1,144 @@ +// ------------------------------------------------------------------------------------------------ +// 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. Invoked as a dedicated codeunit run so the +/// counter read-modify-write and its commit form their own unit of work, keeping the increment durable +/// without committing the caller's transaction state as part of the quota logic. +/// +codeunit 247 "VAT Lookup Quota Mgt." +{ + 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.'; + DailyQuotaReachedMsg: Label 'The daily EU VAT reg. no. validation limit was reached for this environment.', Locked = true; + SecurityAuditDailyQuotaExceededTxt: Label 'An EU VAT Registration No. validation service (VIES) lookup was blocked because the environment reached its daily lookup limit.', 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', DailyQuotaReachedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); + end; + + // Persist and commit the count in this dedicated run's unit of work before the outbound request: the + // increment stays durable even if the subsequent VIES call or the caller transaction later fails, and the + // row lock is released before the potentially slow VIES call. + 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/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al index a2e56725cef..c6e6be40eb0 100644 --- a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al +++ b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al @@ -36,7 +36,7 @@ codeunit 134193 "ERM VAT VIES Lookup UT" [Test] procedure DailyVIESCallQuotaBlocksWhenLimitReached() var - VATLookupExtDataHndl: Codeunit "VAT Lookup Ext. Data Hndl"; + VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; Index: Integer; begin @@ -46,31 +46,31 @@ codeunit 134193 "ERM VAT VIES Lookup UT" // [GIVEN] An online (SaaS) environment where the daily VIES lookup quota is enforced at 3 lookups/day EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(true); - VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); - VATLookupExtDataHndl.SetVIESCallQuotaLimitForTest(3); + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); + VATLookupQuotaMgt.SetVIESCallQuotaLimitForTest(3); // [WHEN] The daily limit of lookups is registered for Index := 1 to 3 do - VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); // [THEN] The counter is at the limit - Assert.AreEqual(3, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'The lookup counter should be at the daily 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 VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + asserterror VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); Assert.ExpectedError('reached the daily limit'); // [THEN] Blocked lookups are not counted (they never reach the service) - Assert.AreEqual(3, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'Blocked lookups should not increment the counter.'); + Assert.AreEqual(3, VATLookupQuotaMgt.GetVIESCallCountForTest(), 'Blocked lookups should not increment the counter.'); - VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); end; [Test] procedure DailyVIESCallQuotaResetsOnNewDay() var - VATLookupExtDataHndl: Codeunit "VAT Lookup Ext. Data Hndl"; + VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; Index: Integer; begin @@ -81,29 +81,29 @@ codeunit 134193 "ERM VAT VIES Lookup UT" // [GIVEN] An online (SaaS) environment with the quota enforced at 3 lookups/day EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(true); - VATLookupExtDataHndl.SetVIESCallQuotaLimitForTest(3); + VATLookupQuotaMgt.SetVIESCallQuotaLimitForTest(3); // [GIVEN] Yesterday already reached the daily limit - VATLookupExtDataHndl.SeedVIESCallQuotaForTest(Today() - 1, 3); + VATLookupQuotaMgt.SeedVIESCallQuotaForTest(Today() - 1, 3); // [WHEN] The customer makes lookups today up to the daily limit for Index := 1 to 3 do - VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); // [THEN] None are blocked and today's lookups are counted from zero (yesterday's count was discarded) - Assert.AreEqual(3, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'The counter should reset and count today''s lookups from zero.'); + 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 VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + asserterror VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); Assert.ExpectedError('reached the daily limit'); - VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); end; [Test] procedure DailyVIESCallQuotaSkippedOnPrem() var - VATLookupExtDataHndl: Codeunit "VAT Lookup Ext. Data Hndl"; + VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; begin // [FEATURE] [VIES] [Throttling] @@ -112,17 +112,17 @@ codeunit 134193 "ERM VAT VIES Lookup UT" // [GIVEN] An on-premises environment with an (irrelevant) enforced limit of 1 lookup/day EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); - VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); - VATLookupExtDataHndl.SetVIESCallQuotaLimitForTest(1); + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); + VATLookupQuotaMgt.SetVIESCallQuotaLimitForTest(1); // [WHEN] Several lookups are registered - VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); - VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); + VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); // [THEN] Nothing is counted or blocked because the quota does not apply on-premises - Assert.AreEqual(0, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'The quota must not apply on-premises.'); + Assert.AreEqual(0, VATLookupQuotaMgt.GetVIESCallCountForTest(), 'The quota must not apply on-premises.'); - VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); end; [Test] From 4349fedbe5beeb5ed3556ed375d39fdea7456ef7 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Wed, 23 Sep 2026 20:51:08 +0200 Subject: [PATCH 08/10] [VIES Integration] Reword VIES quota audit message to reflect limit reached, not blocked The audit entry fires on the last allowed lookup (the one that reaches the daily limit), so the message should describe the limit being reached rather than the lookup being blocked. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al index f29f7808976..96e8be37def 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al @@ -24,7 +24,7 @@ codeunit 247 "VAT Lookup Quota Mgt." 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.'; DailyQuotaReachedMsg: Label 'The daily EU VAT reg. no. validation limit was reached for this environment.', Locked = true; - SecurityAuditDailyQuotaExceededTxt: Label 'An EU VAT Registration No. validation service (VIES) lookup was blocked because the environment reached its daily lookup limit.', 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; From 1c024c557daff7ebc8210603320abecdd621b1e6 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Wed, 23 Sep 2026 20:56:22 +0200 Subject: [PATCH 09/10] [VIES Integration] Make quota codeunit internal, fix comments, set test transaction model Address AL review agent feedback: - Mark codeunit 247 "VAT Lookup Quota Mgt." as Access = Internal (implementation detail). - Correct the doc/inline comments so they no longer imply the dedicated codeunit run isolates the transaction: the Commit also commits the caller's ambient transaction (the same boundary codeunit 248 already commits at around the outbound call). - Mark the quota tests with TransactionModel::AutoCommit since they commit the counter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Registration/VATLookupExtDataHndl.Codeunit.al | 3 ++- .../Registration/VATLookupQuotaMgt.Codeunit.al | 15 +++++++++------ .../W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al | 3 +++ 3 files changed, 14 insertions(+), 7 deletions(-) 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 c894cb7900d..358684e11b4 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al @@ -87,7 +87,8 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" // 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. Run as a dedicated codeunit so the counter commit is its own unit of work. + // 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); diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al index 96e8be37def..a27c7f67d8e 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al @@ -8,12 +8,14 @@ using System.Environment; using System.Telemetry; /// -/// Enforces the per-environment daily EU VIES lookup quota. Invoked as a dedicated codeunit run so the -/// counter read-modify-write and its commit form their own unit of work, keeping the increment durable -/// without committing the caller's transaction state as part of the quota logic. +/// 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() @@ -67,9 +69,10 @@ codeunit 247 "VAT Lookup Quota Mgt." Session.LogMessage('0000VL7', DailyQuotaReachedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); end; - // Persist and commit the count in this dedicated run's unit of work before the outbound request: the - // increment stays durable even if the subsequent VIES call or the caller transaction later fails, and the - // row lock is released before the potentially slow VIES call. + // 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; diff --git a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al index c6e6be40eb0..5df005d0c45 100644 --- a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al +++ b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al @@ -34,6 +34,7 @@ codeunit 134193 "ERM VAT VIES Lookup UT" WrongLogEntryOnPageErr: Label 'Unexpected entry in VAT Registration Log page.'; [Test] + [TransactionModel(TransactionModel::AutoCommit)] procedure DailyVIESCallQuotaBlocksWhenLimitReached() var VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; @@ -68,6 +69,7 @@ codeunit 134193 "ERM VAT VIES Lookup UT" end; [Test] + [TransactionModel(TransactionModel::AutoCommit)] procedure DailyVIESCallQuotaResetsOnNewDay() var VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; @@ -101,6 +103,7 @@ codeunit 134193 "ERM VAT VIES Lookup UT" end; [Test] + [TransactionModel(TransactionModel::AutoCommit)] procedure DailyVIESCallQuotaSkippedOnPrem() var VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; From b11a8059bf9a5ffc84b42673a9e26f65c28378c5 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Wed, 23 Sep 2026 21:40:34 +0200 Subject: [PATCH 10/10] [VIES Integration] Rename quota telemetry label to Txt and reset quota state per test - Rename DailyQuotaReachedMsg -> DailyQuotaReachedTxt (telemetry/locked string convention). - Reset the VIES quota row and SaaS testability flag in the test Initialize() so an AutoCommit quota test that fails mid-way cannot leak committed state into later tests under a non-isolated test runner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al | 4 ++-- src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al index a27c7f67d8e..584b3b19bfc 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al @@ -25,7 +25,7 @@ codeunit 247 "VAT Lookup Quota Mgt." 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.'; - DailyQuotaReachedMsg: Label 'The daily EU VAT reg. no. validation limit was reached for this environment.', Locked = true; + 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; @@ -66,7 +66,7 @@ codeunit 247 "VAT Lookup Quota Mgt." 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', DailyQuotaReachedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); + 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 diff --git a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al index 5df005d0c45..6cb71d304b2 100644 --- a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al +++ b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al @@ -1120,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()