Skip to content

Commit 4ab4f30

Browse files
committed
Refactor remote config application for ASM
1 parent cc6678f commit 4ab4f30

2 files changed

Lines changed: 275 additions & 63 deletions

File tree

tracer/src/Datadog.Trace/AppSec/Waf/ConfigurationState.cs

Lines changed: 129 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
using System;
99
using System.Collections.Generic;
10+
using System.Diagnostics.CodeAnalysis;
1011
using System.Linq;
1112
using Datadog.Trace.AppSec.Rcm.Models.Asm;
1213
using Datadog.Trace.AppSec.Rcm.Models.AsmData;
@@ -26,8 +27,10 @@ namespace Datadog.Trace.AppSec.Rcm;
2627
/// <summary>
2728
/// This class represents the state of RCM for ASM.
2829
/// It has 2 possible status:
29-
/// - ASM is not activated, and _fileUpdates/_fileRemoves contain some pending non-deserialized changes to apply when ASM_FEATURES activate ASM. Every time an RC payload is received here, pending changes are reset to the last ones
30-
/// - ASM is activated, stored configs in _fileUpdates/_fileRemoves are applied every time.
30+
/// - ASM is not activated, and _pendingByProduct accumulates pending non-deserialized changes (updates and removes)
31+
/// across RC polls until ASM_FEATURES activates ASM, at which point they are applied. RCM only forwards deltas
32+
/// (new/changed/removed configs), so the pending state must persist between polls.
33+
/// - ASM is activated, the pending state in _pendingByProduct is applied every time.
3134
/// </summary>
3235
internal sealed record ConfigurationState
3336
{
@@ -41,8 +44,13 @@ internal sealed record ConfigurationState
4144

4245
private readonly string? _rulesPath;
4346
private readonly bool _canBeToggled;
44-
private readonly Dictionary<string, List<RemoteConfiguration>> _fileUpdates = new();
45-
private readonly Dictionary<string, List<RemoteConfigurationPath>> _fileRemoves = new();
47+
48+
// Pending RCM operations, grouped by product. Inner key is the full config path; the value is
49+
// either an update payload or a remove marker. Using a dictionary keyed by path means each path
50+
// can only hold one pending operation at a time — a later update or remove for the same path
51+
// overwrites whatever was there, which is exactly the RCM "latest delta wins" semantics.
52+
private readonly Dictionary<string, Dictionary<string, PendingOperation>> _pendingByProduct = new();
53+
4654
private bool _defaultRulesetApplied;
4755

4856
public ConfigurationState(SecuritySettings settings, IConfigurationTelemetry telemetry, bool wafIsNull)
@@ -136,9 +144,19 @@ internal RemoteConfigWafFiles GetWafConfigurations(bool updating = false)
136144
var configurations = new Dictionary<string, object>();
137145
List<string>? removes = null;
138146

139-
if (updating && _fileRemoves is { Count: > 0 })
147+
if (updating)
140148
{
141-
removes = _fileRemoves.SelectMany(p => p.Value).Where(p => p.Product != RcmProducts.AsmFeatures).Select(v => v.Path).ToList();
149+
foreach (var entry in _pendingByProduct)
150+
{
151+
if (entry.Key == RcmProducts.AsmFeatures) { continue; }
152+
foreach (var op in entry.Value.Values)
153+
{
154+
if (op.IsRemove)
155+
{
156+
(removes ??= new()).Add(op.Path.Path);
157+
}
158+
}
159+
}
142160
}
143161

144162
if (AsmConfigs is { Count: > 0 })
@@ -192,11 +210,12 @@ internal RemoteConfigWafFiles GetWafConfigurations(bool updating = false)
192210

193211
return new(configurations.Count > 0 ? configurations : null, removes);
194212

213+
// True if `path` has a pending update (not a remove) in any product slot.
195214
bool IsNewUpdate(string path)
196215
{
197-
foreach (var productUpdates in _fileUpdates)
216+
foreach (var pending in _pendingByProduct.Values)
198217
{
199-
if (productUpdates.Value.Any(u => u.Path.Path == path))
218+
if (pending.TryGetValue(path, out var op) && !op.IsRemove)
200219
{
201220
return true;
202221
}
@@ -207,99 +226,111 @@ bool IsNewUpdate(string path)
207226
}
208227

209228
/// <summary>
210-
/// Calls each product updater to deserialize all remote config payloads and store them properly in dictionaries which might involve various logical merges
211-
/// This method deserializes everything stored in _fileUpdates. ConfigurationStatus will have a *bigger* memory footprint.
229+
/// Calls each product updater to deserialize all remote config payloads and store them properly in dictionaries which might involve various logical merges.
212230
/// </summary>
213231
public void ApplyStoredFiles()
214232
{
215-
// no need to clear _fileUpdates / _fileRemoves after they've been applied, as when we receive a new config, `StoreLastConfigState` method will clear anything remaining anyway.
233+
// The pending state is NOT cleared here — GetWafConfigurations still reads it to identify which
234+
// configs are new for the WAF update path. Re-application is idempotent: each product updater
235+
// writes into its destination collection keyed by config path, so replaying the same pending
236+
// entry on a later turn produces the same end state.
216237
foreach (var updater in _productConfigUpdaters)
217238
{
218-
var fileRemoves = _fileRemoves.TryGetValue(updater.Key, out var removes);
219-
var fileUpdates = _fileUpdates.TryGetValue(updater.Key, out var updates);
220-
if (fileRemoves || fileUpdates)
239+
if (!_pendingByProduct.TryGetValue(updater.Key, out var pending) || pending.Count == 0)
221240
{
222-
updater.Value.ProcessUpdates(this, removes, updates);
241+
continue;
223242
}
243+
244+
var (removes, updates) = SplitPending(pending);
245+
updater.Value.ProcessUpdates(this, removes, updates);
224246
}
225247
}
226248

227249
/// <summary>
228-
/// This method just stores the config state without deserializing anything, this state will be ready to use and deserialized if ASM is enabled later on.
229-
/// This method considers that RC sends us everything again, the whole state together. That's why it's clearing all unapplied updates / removals before processing the last ones received.
230-
/// In case ASM remained disabled, we discard previous updates and removals stored here that were never applied.
250+
/// Merges an incoming RCM delta into the per-product pending state without deserializing payloads.
251+
/// If ASM is enabled (or this delta turns it on), the merged state is applied immediately; otherwise it stays pending until ASM activates.
231252
/// </summary>
232-
/// <param name="configsByProduct">configsByProduct</param>
233-
/// <param name="removedConfigs">removedConfigs</param>
253+
/// <remarks>
254+
/// Background: RCM forwards only the DELTA versus what we last acknowledged — not the full backend state.
255+
/// A config received in an earlier poll is NOT re-sent on the next poll if it hasn't changed.
256+
/// So while ASM is disabled, we must accumulate deltas across polls, otherwise configs received
257+
/// before activation would be silently dropped (they're already in RCM's _appliedConfigurations
258+
/// and won't be re-forwarded). Each path holds exactly one pending op in _pendingByProduct;
259+
/// a later update or remove for the same path overwrites whatever was there.
260+
/// </remarks>
234261
public void ReceivedNewConfig(Dictionary<string, List<RemoteConfiguration>> configsByProduct, Dictionary<string, List<RemoteConfigurationPath>>? removedConfigs)
235262
{
236-
_fileUpdates.Clear();
237-
_fileRemoves.Clear();
238-
// if we just have asm features, it might only be to toggle appsec. Other products bring actual configurations to the waf.
239-
var hasUpdateConfigurations = false;
240-
var anyChange = configsByProduct.Count > 0 || removedConfigs?.Count > 0;
241-
if (anyChange)
263+
if (configsByProduct.Count == 0 && (removedConfigs is null || removedConfigs.Count == 0))
242264
{
243-
if (removedConfigs != null)
244-
{
245-
foreach (var configByProductToRemove in removedConfigs)
246-
{
247-
if (configByProductToRemove.Key != RcmProducts.AsmFeatures)
248-
{
249-
hasUpdateConfigurations = true;
250-
}
265+
return;
266+
}
251267

252-
if (_fileRemoves.TryGetValue(configByProductToRemove.Key, out var remove))
253-
{
254-
remove.AddRange(configByProductToRemove.Value);
255-
}
256-
else
257-
{
258-
_fileRemoves[configByProductToRemove.Key] = configByProductToRemove.Value;
259-
}
260-
}
261-
}
268+
// Tracks whether this delta touches any product OTHER than ASM_FEATURES. A pure ASM_FEATURES
269+
// delta only toggles AppSec on/off — it doesn't carry WAF rules — so when that's all we got,
270+
// there's no reason to push a config update through to the WAF below.
271+
var hasUpdateConfigurations = false;
262272

263-
foreach (var configByProduct in configsByProduct)
273+
if (removedConfigs is not null)
274+
{
275+
foreach (var entry in removedConfigs)
264276
{
265-
if (configByProduct.Key != RcmProducts.AsmFeatures)
277+
var productKey = entry.Key;
278+
hasUpdateConfigurations |= productKey != RcmProducts.AsmFeatures;
279+
if (!_pendingByProduct.TryGetValue(productKey, out var pending))
266280
{
267-
hasUpdateConfigurations = true;
281+
pending = new Dictionary<string, PendingOperation>();
282+
_pendingByProduct[productKey] = pending;
268283
}
269284

270-
if (_fileUpdates.TryGetValue(configByProduct.Key, out var update))
271-
{
272-
update.AddRange(configByProduct.Value);
273-
}
274-
else
285+
foreach (var removed in entry.Value)
275286
{
276-
_fileUpdates[configByProduct.Key] = configByProduct.Value;
287+
// Overwrites any pending update OR pending remove for this path. Later op wins.
288+
pending[removed.Path] = new PendingOperation(removed, update: null);
277289
}
278290
}
291+
}
279292

280-
ApplyAsmFeatures(AppsecEnabled);
281-
IncomingUpdateState.ShouldUpdateAppsec = !IncomingUpdateState.ShouldInitAppsec && AppsecEnabled && hasUpdateConfigurations;
293+
foreach (var entry in configsByProduct)
294+
{
295+
var productKey = entry.Key;
296+
hasUpdateConfigurations |= productKey != RcmProducts.AsmFeatures;
297+
if (!_pendingByProduct.TryGetValue(productKey, out var pending))
298+
{
299+
pending = new Dictionary<string, PendingOperation>();
300+
_pendingByProduct[productKey] = pending;
301+
}
282302

283-
if (IncomingUpdateState.ShouldUpdateAppsec || IncomingUpdateState.ShouldInitAppsec)
303+
foreach (var update in entry.Value)
284304
{
285-
ApplyStoredFiles();
305+
// Same overwrite semantics — replaces any prior op for this path.
306+
pending[update.Path.Path] = new PendingOperation(update.Path, update);
286307
}
287308
}
309+
310+
// ASM_FEATURES is processed eagerly because it can flip AppsecEnabled and decide whether
311+
// we initialize the WAF, update an already-running WAF, or do nothing.
312+
ApplyAsmFeatures(AppsecEnabled);
313+
IncomingUpdateState.ShouldUpdateAppsec = !IncomingUpdateState.ShouldInitAppsec && AppsecEnabled && hasUpdateConfigurations;
314+
315+
// If ASM just turned on, or it was already on and we got real WAF config, drain pending state
316+
// into the per-product output dictionaries (RulesetConfigs, AsmConfigs, AsmDataConfigs).
317+
if (IncomingUpdateState.ShouldUpdateAppsec || IncomingUpdateState.ShouldInitAppsec)
318+
{
319+
ApplyStoredFiles();
320+
}
288321
}
289322

290323
private void ApplyAsmFeatures(bool appsecCurrentlyEnabled)
291324
{
292325
// only deserialize and apply asm_features as it will decide if asm gets toggled on and if we deserialize all the others
293326
// (the enable of auto user instrumentation as added to asm_features)
294-
var change = _fileRemoves.TryGetValue(RcmProducts.AsmFeatures, out var removals);
295-
change |= _fileUpdates.TryGetValue(RcmProducts.AsmFeatures, out var updates);
296-
297-
if (change)
327+
if (!_pendingByProduct.TryGetValue(RcmProducts.AsmFeatures, out var pending) || pending.Count == 0)
298328
{
299-
_asmFeatureProduct.ProcessUpdates(this, removals, updates);
329+
return;
300330
}
301331

302-
if (!change) { return; }
332+
var (removals, updates) = SplitPending(pending);
333+
_asmFeatureProduct.ProcessUpdates(this, removals, updates);
303334

304335
// normally CanBeToggled should not need a check as asm_features capacity is only sent if AppSec env var is null, but still guards it in case
305336
if (_canBeToggled)
@@ -341,6 +372,41 @@ private void ApplyAsmFeatures(bool appsecCurrentlyEnabled)
341372
}
342373
}
343374

375+
// Splits the per-product pending map into the (removes, updates) shape that IAsmConfigUpdater.ProcessUpdates expects.
376+
// Either list may be null when that side is empty — matching the interface contract.
377+
private static (List<RemoteConfigurationPath>? Removes, List<RemoteConfiguration>? Updates) SplitPending(Dictionary<string, PendingOperation> pending)
378+
{
379+
List<RemoteConfigurationPath>? removes = null;
380+
List<RemoteConfiguration>? updates = null;
381+
foreach (var op in pending.Values)
382+
{
383+
if (op.IsRemove)
384+
{
385+
removes ??= [];
386+
removes.Add(op.Path);
387+
}
388+
else
389+
{
390+
updates ??= [];
391+
updates.Add(op.Update);
392+
}
393+
}
394+
395+
return (removes, updates);
396+
}
397+
398+
// A pending RCM operation for one config path: either an update carrying a fresh payload,
399+
// or a removal marker. The Update field is null iff this entry represents a removal.
400+
private readonly struct PendingOperation(RemoteConfigurationPath path, RemoteConfiguration? update)
401+
{
402+
public RemoteConfigurationPath Path { get; } = path;
403+
404+
public RemoteConfiguration? Update { get; } = update;
405+
406+
[MemberNotNullWhen(false, nameof(Update))]
407+
public bool IsRemove => Update is null;
408+
}
409+
344410
internal sealed record IncomingUpdateStatus : IDisposable
345411
{
346412
internal bool ShouldInitAppsec { get; set; }

0 commit comments

Comments
 (0)