Skip to content

Commit 8a10e51

Browse files
vbilopavclaude
andcommitted
fix: v3.15.2 — validate Policies/Profiles/Rules by shape, not by name
Finishes the config-validator fix started in 3.15.1 for the two "name-keyed open dictionary" sections that share Auth:Schemes's shape: - RateLimiterOptions:Policies is now validated against a per-Type schema (FixedWindow / SlidingWindow / TokenBucket / Concurrency). User-chosen policy names no longer fail `ValidateConfigKeys: "Error"` startup, and typos / cross-type keys inside a policy now surface. - CacheOptions:Profiles uses a single flat schema (Memory / Redis / Hybrid all share the same key set). Custom profile names validate; typos like `Expirashun` now surface. - ValidationOptions:Rules moved off the open-dict whitelist into a flat schema, so typos like `Patrn` inside a rule no longer pass silently. 15 new ConfigValidationTests; 84/84 targeted tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2ed0199 commit 8a10e51

7 files changed

Lines changed: 621 additions & 12 deletions

File tree

.github/workflows/build-test-publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88
workflow_dispatch:
99

1010
env:
11-
RELEASE_VERSION: v3.15.1
11+
RELEASE_VERSION: v3.15.2
1212

1313
# Add workflow-level permissions
1414
permissions:

NpgsqlRestClient/ConfigDefaults.cs

Lines changed: 162 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1225,12 +1225,15 @@ public static List<string> FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
12251225
{
12261226
var fullPath = string.IsNullOrEmpty(path) ? kvp.Key : $"{path}:{kvp.Key}";
12271227

1228-
// Auth:Schemes is a name-keyed open dictionary, but the keys *inside* each named scheme
1229-
// are not arbitrary — they depend on the scheme's Type. Pick a type-discriminated schema
1230-
// so any name (including the docs-example names short_session/api_token/admin_jwt) gets
1231-
// the same per-type validation. Without this, a scheme name that happens to match an
1232-
// example in defaults would be validated against that example's incomplete key set,
1233-
// flagging perfectly valid per-type override keys (Bug A in CONFIG_VALIDATION_NAMED_SCHEMES_BUG.md).
1228+
// Name-keyed open dictionaries: the named children are arbitrary (user-chosen) but each
1229+
// child has a well-defined inner shape. Pick that shape and validate against it. Without
1230+
// these intercepts, the validator falls back to looking up the user-chosen name in
1231+
// defaults and either flags it as unknown (if the name has no example match) or matches
1232+
// it against an example's partial key set (if the name *does* collide with an example,
1233+
// which falsely rejects valid per-type override keys).
1234+
//
1235+
// Each intercept short-circuits the rest of the loop body so neither the example-lookup
1236+
// path nor the IsOpenDictionarySection fallback fires.
12341237
if (string.Equals(path, "Auth:Schemes", StringComparison.Ordinal) && kvp.Value is JsonObject schemeObj)
12351238
{
12361239
var schemeSchema = GetAuthSchemeSchemaByType(schemeObj);
@@ -1242,6 +1245,26 @@ public static List<string> FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
12421245
// startup for missing/invalid Type; no point double-reporting it here.
12431246
continue;
12441247
}
1248+
if (string.Equals(path, "RateLimiterOptions:Policies", StringComparison.Ordinal) && kvp.Value is JsonObject policyObj)
1249+
{
1250+
var policySchema = GetRateLimiterPolicySchemaByType(policyObj);
1251+
if (policySchema is not null)
1252+
{
1253+
warnings.AddRange(FindUnknownConfigKeys(policySchema, policyObj, fullPath));
1254+
}
1255+
// BuildRateLimiter silently skips policies with no/invalid Type — match that here.
1256+
continue;
1257+
}
1258+
if (string.Equals(path, "CacheOptions:Profiles", StringComparison.Ordinal) && kvp.Value is JsonObject profileObj)
1259+
{
1260+
warnings.AddRange(FindUnknownConfigKeys(GetCacheProfileSchema(), profileObj, fullPath));
1261+
continue;
1262+
}
1263+
if (string.Equals(path, "ValidationOptions:Rules", StringComparison.Ordinal) && kvp.Value is JsonObject ruleObj)
1264+
{
1265+
warnings.AddRange(FindUnknownConfigKeys(GetValidationRuleSchema(), ruleObj, fullPath));
1266+
continue;
1267+
}
12451268

12461269
if (ContainsKeyIgnoreCase(defaultObj, kvp.Key, out var matchedKey))
12471270
{
@@ -1341,6 +1364,134 @@ public static List<string> FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
13411364
return null;
13421365
}
13431366

1367+
private static string? ReadStringField(JsonObject obj, string key)
1368+
{
1369+
foreach (var kvp in obj)
1370+
{
1371+
if (string.Equals(kvp.Key, key, StringComparison.OrdinalIgnoreCase))
1372+
{
1373+
if (kvp.Value is JsonValue val && val.TryGetValue<string>(out var s))
1374+
{
1375+
return s;
1376+
}
1377+
return null;
1378+
}
1379+
}
1380+
return null;
1381+
}
1382+
1383+
private static JsonObject PartitionSchema() => new()
1384+
{
1385+
["Sources"] = new JsonArray(new JsonObject
1386+
{
1387+
["Type"] = null,
1388+
["Name"] = null,
1389+
["Value"] = null
1390+
}),
1391+
["BypassAuthenticated"] = false
1392+
};
1393+
1394+
/// <summary>
1395+
/// Returns the per-type schema for a named entry under <c>RateLimiterOptions:Policies</c>, keyed
1396+
/// off the policy's <c>Type</c> field (FixedWindow / SlidingWindow / TokenBucket / Concurrency).
1397+
/// Returns null when Type is missing/invalid — BuildRateLimiter silently skips such policies, so
1398+
/// the validator does the same to avoid double-reporting.
1399+
/// </summary>
1400+
private static JsonObject? GetRateLimiterPolicySchemaByType(JsonObject policy)
1401+
{
1402+
var type = ReadStringField(policy, "Type");
1403+
if (string.IsNullOrWhiteSpace(type))
1404+
{
1405+
return null;
1406+
}
1407+
if (string.Equals(type, "FixedWindow", StringComparison.OrdinalIgnoreCase))
1408+
{
1409+
return new JsonObject
1410+
{
1411+
["Type"] = "FixedWindow",
1412+
["Enabled"] = true,
1413+
["PermitLimit"] = 100,
1414+
["WindowSeconds"] = 60,
1415+
["QueueLimit"] = 10,
1416+
["AutoReplenishment"] = true,
1417+
["Partition"] = PartitionSchema()
1418+
};
1419+
}
1420+
if (string.Equals(type, "SlidingWindow", StringComparison.OrdinalIgnoreCase))
1421+
{
1422+
return new JsonObject
1423+
{
1424+
["Type"] = "SlidingWindow",
1425+
["Enabled"] = true,
1426+
["PermitLimit"] = 100,
1427+
["WindowSeconds"] = 60,
1428+
["SegmentsPerWindow"] = 6,
1429+
["QueueLimit"] = 10,
1430+
["AutoReplenishment"] = true,
1431+
["Partition"] = PartitionSchema()
1432+
};
1433+
}
1434+
if (string.Equals(type, "TokenBucket", StringComparison.OrdinalIgnoreCase))
1435+
{
1436+
return new JsonObject
1437+
{
1438+
["Type"] = "TokenBucket",
1439+
["Enabled"] = true,
1440+
["TokenLimit"] = 100,
1441+
["TokensPerPeriod"] = 10,
1442+
["ReplenishmentPeriodSeconds"] = 10,
1443+
["QueueLimit"] = 10,
1444+
["AutoReplenishment"] = true,
1445+
["Partition"] = PartitionSchema()
1446+
};
1447+
}
1448+
if (string.Equals(type, "Concurrency", StringComparison.OrdinalIgnoreCase))
1449+
{
1450+
return new JsonObject
1451+
{
1452+
["Type"] = "Concurrency",
1453+
["Enabled"] = true,
1454+
["PermitLimit"] = 10,
1455+
["QueueLimit"] = 5,
1456+
["OldestFirst"] = true,
1457+
["Partition"] = PartitionSchema()
1458+
};
1459+
}
1460+
return null;
1461+
}
1462+
1463+
/// <summary>
1464+
/// Returns the schema for any entry under <c>CacheOptions:Profiles</c>. All profiles share the same
1465+
/// key set regardless of <c>Type</c> (Memory / Redis / Hybrid); only the backend selection varies.
1466+
/// </summary>
1467+
private static JsonObject GetCacheProfileSchema() => new()
1468+
{
1469+
["Enabled"] = false,
1470+
["Type"] = null,
1471+
["Expiration"] = null,
1472+
["Parameters"] = new JsonArray(),
1473+
["When"] = new JsonArray(new JsonObject
1474+
{
1475+
["Parameter"] = null,
1476+
["Value"] = null,
1477+
["Then"] = null
1478+
})
1479+
};
1480+
1481+
/// <summary>
1482+
/// Returns the schema for any entry under <c>ValidationOptions:Rules</c>. All rules share the same
1483+
/// key set regardless of <c>Type</c> (NotNull / NotEmpty / Required / Regex / MinLength / MaxLength).
1484+
/// </summary>
1485+
private static JsonObject GetValidationRuleSchema() => new()
1486+
{
1487+
["Type"] = null,
1488+
["Pattern"] = null,
1489+
["MinLength"] = null,
1490+
["MaxLength"] = null,
1491+
["Message"] = null,
1492+
["StatusCode"] = 400
1493+
};
1494+
13441495
private static bool ContainsKeyIgnoreCase(JsonObject obj, string key, out string? matchedKey)
13451496
{
13461497
foreach (var kvp in obj)
@@ -1360,13 +1511,16 @@ private static bool ContainsKeyIgnoreCase(JsonObject obj, string key, out string
13601511
/// </summary>
13611512
private static bool IsOpenDictionarySection(string path)
13621513
{
1514+
// NOTE: Auth:Schemes, RateLimiterOptions:Policies, CacheOptions:Profiles, and
1515+
// ValidationOptions:Rules are intentionally NOT in this list. They are name-keyed open
1516+
// dictionaries too, but each is intercepted earlier in FindUnknownConfigKeys with a
1517+
// type/shape-discriminated schema so user-chosen names get the same per-entry validation that
1518+
// matching example names get. Adding them here would silently disable that.
13631519
if (path is
13641520
"ConnectionStrings" or
13651521
"Log:MinimalLevels" or
13661522
"Log:OTLResourceAttributes" or
13671523
"CommandRetryOptions:Strategies" or
1368-
"ValidationOptions:Rules" or
1369-
"Auth:Schemes" or
13701524
"NpgsqlRest:AuthenticationOptions:ContextKeyClaimsMapping" or
13711525
"NpgsqlRest:AuthenticationOptions:ParameterNameClaimsMapping" or
13721526
"NpgsqlRest:ClientCodeGen:CustomHeaders")

NpgsqlRestClient/NpgsqlRestClient.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<PublishAot>true</PublishAot>
1111
<PublishTrimmed>true</PublishTrimmed>
1212
<TrimMode>full</TrimMode>
13-
<Version>3.15.1</Version>
13+
<Version>3.15.2</Version>
1414
</PropertyGroup>
1515

1616
<ItemGroup>

0 commit comments

Comments
 (0)