granttrace 2.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api_sentinel.py +402 -0
- core/__init__.py +4 -0
- core/auditor.py +1101 -0
- core/config_scaffold.py +254 -0
- core/config_validator.py +1408 -0
- core/diff.py +839 -0
- core/evidence.py +353 -0
- core/generator.py +1048 -0
- core/models.py +104 -0
- core/parameters.py +198 -0
- core/parser.py +495 -0
- core/reporter.py +359 -0
- core/transactions.py +167 -0
- granttrace-2.4.0.dist-info/METADATA +108 -0
- granttrace-2.4.0.dist-info/RECORD +21 -0
- granttrace-2.4.0.dist-info/WHEEL +5 -0
- granttrace-2.4.0.dist-info/entry_points.txt +2 -0
- granttrace-2.4.0.dist-info/licenses/LICENSE +202 -0
- granttrace-2.4.0.dist-info/top_level.txt +3 -0
- mock_server/__init__.py +4 -0
- mock_server/server.py +190 -0
core/config_validator.py
ADDED
|
@@ -0,0 +1,1408 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Authoritative configuration validation engine for GrantTrace.
|
|
3
|
+
|
|
4
|
+
Provides offline, comprehensive semantic validation for GrantTrace configuration
|
|
5
|
+
files. Reuses the exact security rules and constraints enforced by the auditor
|
|
6
|
+
and transaction managers without requiring external dependencies like jsonschema.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import difflib
|
|
12
|
+
import math
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
|
15
|
+
|
|
16
|
+
from core.evidence import safe_diagnostic_value, sanitize_log_text, sanitize_text
|
|
17
|
+
from core.models import parse_operation_key
|
|
18
|
+
from core.transactions import path_parts, paths_collide
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ConfigIssue:
|
|
23
|
+
path: str
|
|
24
|
+
message: str
|
|
25
|
+
expected: str
|
|
26
|
+
actual: str
|
|
27
|
+
suggestion: Optional[str] = None
|
|
28
|
+
is_warning: bool = False
|
|
29
|
+
|
|
30
|
+
def format(self) -> str:
|
|
31
|
+
prefix = " [WARN]" if self.is_warning else " - Path:"
|
|
32
|
+
safe_path = sanitize_log_text(sanitize_text(self.path))
|
|
33
|
+
safe_msg = sanitize_log_text(sanitize_text(self.message))
|
|
34
|
+
lines = [f"{prefix} '{safe_path}'", f" Reason: {safe_msg}"]
|
|
35
|
+
if self.expected:
|
|
36
|
+
lines.append(f" Expected: {sanitize_log_text(sanitize_text(self.expected))}")
|
|
37
|
+
if self.actual:
|
|
38
|
+
lines.append(f" Actual: {sanitize_log_text(sanitize_text(self.actual))}")
|
|
39
|
+
if self.suggestion:
|
|
40
|
+
lines.append(f" Tip: {sanitize_log_text(sanitize_text(self.suggestion))}")
|
|
41
|
+
return "\n".join(lines)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ValidationSummary:
|
|
46
|
+
total_issues: int = 0
|
|
47
|
+
errors_count: int = 0
|
|
48
|
+
warnings_count: int = 0
|
|
49
|
+
identities_count: int = 0
|
|
50
|
+
allowlisted_endpoints: int = 0
|
|
51
|
+
readbacks_count: int = 0
|
|
52
|
+
parameter_endpoints: int = 0
|
|
53
|
+
bola_policies_count: int = 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ConfigValidationResult:
|
|
58
|
+
is_valid: bool
|
|
59
|
+
issues: List[ConfigIssue] = field(default_factory=list)
|
|
60
|
+
summary: ValidationSummary = field(default_factory=ValidationSummary)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def errors(self) -> List[ConfigIssue]:
|
|
64
|
+
return [issue for issue in self.issues if not issue.is_warning]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def warnings(self) -> List[ConfigIssue]:
|
|
68
|
+
return [issue for issue in self.issues if issue.is_warning]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ConfigValidator:
|
|
72
|
+
"""Authoritative semantic validator for GrantTrace configuration objects."""
|
|
73
|
+
|
|
74
|
+
MAX_RECURSION_DEPTH = 15
|
|
75
|
+
|
|
76
|
+
KNOWN_TOP_LEVEL_KEYS = {
|
|
77
|
+
"$schema",
|
|
78
|
+
"description",
|
|
79
|
+
"identities",
|
|
80
|
+
"parameter_values",
|
|
81
|
+
"write_allowlist",
|
|
82
|
+
"readbacks",
|
|
83
|
+
"bola",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
KNOWN_IDENTITY_KEYS = {
|
|
87
|
+
"id",
|
|
88
|
+
"token",
|
|
89
|
+
"headers",
|
|
90
|
+
"parameters",
|
|
91
|
+
"auth_header_names",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
KNOWN_READBACK_KEYS = {
|
|
95
|
+
"method",
|
|
96
|
+
"path",
|
|
97
|
+
"consistency",
|
|
98
|
+
"field_map",
|
|
99
|
+
"parameters",
|
|
100
|
+
"parameter_values",
|
|
101
|
+
"snapshot_path",
|
|
102
|
+
"snapshot_field_map",
|
|
103
|
+
"ignore_readback_paths",
|
|
104
|
+
"readback_attempts",
|
|
105
|
+
"readback_delay",
|
|
106
|
+
"baseline_payload",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
KNOWN_BOLA_KEYS = {
|
|
110
|
+
"expected_public",
|
|
111
|
+
"expected_visitor_access",
|
|
112
|
+
"resource_id_paths",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
VALID_CONSISTENCY_VALUES = ("strong", "weak", "eventual")
|
|
116
|
+
STANDARD_AUTH_HEADER_WORDS = ("authorization", "token", "api_key", "apikey", "cookie", "session", "credential")
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def _find_closest_match(cls, word: str, candidates: Sequence[str]) -> Optional[str]:
|
|
120
|
+
matches = difflib.get_close_matches(word, candidates, n=1, cutoff=0.6)
|
|
121
|
+
return matches[0] if matches else None
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def _check_depth(cls, obj: Any, current_depth: int, max_depth: int) -> bool:
|
|
125
|
+
if current_depth > max_depth:
|
|
126
|
+
return False
|
|
127
|
+
if isinstance(obj, dict):
|
|
128
|
+
for v in obj.values():
|
|
129
|
+
if not cls._check_depth(v, current_depth + 1, max_depth):
|
|
130
|
+
return False
|
|
131
|
+
elif isinstance(obj, (list, tuple)):
|
|
132
|
+
for item in obj:
|
|
133
|
+
if not cls._check_depth(item, current_depth + 1, max_depth):
|
|
134
|
+
return False
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def _check_no_nan_or_inf(cls, obj: Any, path: str, issues: List[ConfigIssue]) -> None:
|
|
139
|
+
if isinstance(obj, float):
|
|
140
|
+
if math.isnan(obj):
|
|
141
|
+
issues.append(
|
|
142
|
+
ConfigIssue(
|
|
143
|
+
path=path,
|
|
144
|
+
message="Non-standard JSON number NaN is not permitted in configuration",
|
|
145
|
+
expected="standard finite JSON number",
|
|
146
|
+
actual="NaN",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
elif math.isinf(obj):
|
|
150
|
+
issues.append(
|
|
151
|
+
ConfigIssue(
|
|
152
|
+
path=path,
|
|
153
|
+
message="Non-standard JSON number Infinity is not permitted in configuration",
|
|
154
|
+
expected="standard finite JSON number",
|
|
155
|
+
actual="Infinity" if obj > 0 else "-Infinity",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
elif isinstance(obj, dict):
|
|
159
|
+
for k, v in obj.items():
|
|
160
|
+
sub_path = f"{path}.{k}" if path else str(k)
|
|
161
|
+
cls._check_no_nan_or_inf(v, sub_path, issues)
|
|
162
|
+
elif isinstance(obj, (list, tuple)):
|
|
163
|
+
for i, item in enumerate(obj):
|
|
164
|
+
cls._check_no_nan_or_inf(item, f"{path}[{i}]", issues)
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def validate(cls, config: Any, raw_spec: Optional[Dict[str, Any]] = None) -> ConfigValidationResult:
|
|
168
|
+
"""Validate a loaded configuration mapping and collect all issues without early exit."""
|
|
169
|
+
issues: List[ConfigIssue] = []
|
|
170
|
+
summary = ValidationSummary()
|
|
171
|
+
|
|
172
|
+
if not isinstance(config, dict):
|
|
173
|
+
issues.append(
|
|
174
|
+
ConfigIssue(
|
|
175
|
+
path="<root>",
|
|
176
|
+
message="Configuration root must be a JSON object",
|
|
177
|
+
expected="dict / JSON object",
|
|
178
|
+
actual=safe_diagnostic_value(config),
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
summary.total_issues = len(issues)
|
|
182
|
+
summary.errors_count = len(issues)
|
|
183
|
+
return ConfigValidationResult(is_valid=False, issues=issues, summary=summary)
|
|
184
|
+
|
|
185
|
+
# 0. Check maximum recursion depth to defend against malicious/circular payloads
|
|
186
|
+
if not cls._check_depth(config, 0, cls.MAX_RECURSION_DEPTH):
|
|
187
|
+
issues.append(
|
|
188
|
+
ConfigIssue(
|
|
189
|
+
path="<root>",
|
|
190
|
+
message=f"Configuration nesting exceeds maximum supported depth of {cls.MAX_RECURSION_DEPTH}",
|
|
191
|
+
expected=f"depth <= {cls.MAX_RECURSION_DEPTH}",
|
|
192
|
+
actual="excessive nesting",
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
summary.total_issues = len(issues)
|
|
196
|
+
summary.errors_count = len(issues)
|
|
197
|
+
return ConfigValidationResult(is_valid=False, issues=issues, summary=summary)
|
|
198
|
+
|
|
199
|
+
# Check for NaN / Infinity numbers
|
|
200
|
+
try:
|
|
201
|
+
cls._check_no_nan_or_inf(config, "", issues)
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
issues.append(
|
|
204
|
+
ConfigIssue(
|
|
205
|
+
path="<root>",
|
|
206
|
+
message=f"Error inspecting numeric values: {exc}",
|
|
207
|
+
expected="valid JSON values",
|
|
208
|
+
actual=safe_diagnostic_value(type(exc).__name__),
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# 1. Check top-level unrecognized keys and typos
|
|
213
|
+
for key in list(config.keys()):
|
|
214
|
+
if key not in cls.KNOWN_TOP_LEVEL_KEYS:
|
|
215
|
+
closest = cls._find_closest_match(str(key), sorted(cls.KNOWN_TOP_LEVEL_KEYS))
|
|
216
|
+
suggestion = f"Did you mean '{closest}'?" if closest else None
|
|
217
|
+
issues.append(
|
|
218
|
+
ConfigIssue(
|
|
219
|
+
path=str(key),
|
|
220
|
+
message=f"Unrecognized top-level configuration key '{key}'",
|
|
221
|
+
expected=f"one of {sorted(cls.KNOWN_TOP_LEVEL_KEYS)}",
|
|
222
|
+
actual=safe_diagnostic_value(key),
|
|
223
|
+
suggestion=suggestion,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Description / $schema
|
|
228
|
+
if "$schema" in config and not isinstance(config["$schema"], str):
|
|
229
|
+
issues.append(
|
|
230
|
+
ConfigIssue(
|
|
231
|
+
path="$schema",
|
|
232
|
+
message="$schema declaration must be a string",
|
|
233
|
+
expected="string",
|
|
234
|
+
actual=safe_diagnostic_value(config["$schema"]),
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
if "description" in config and not isinstance(config["description"], str):
|
|
238
|
+
issues.append(
|
|
239
|
+
ConfigIssue(
|
|
240
|
+
path="description",
|
|
241
|
+
message="Description must be a string",
|
|
242
|
+
expected="string",
|
|
243
|
+
actual=safe_diagnostic_value(config["description"]),
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# 2. Section: identities
|
|
248
|
+
try:
|
|
249
|
+
spec_auth_names = cls._extract_spec_auth_headers(raw_spec)
|
|
250
|
+
except (TypeError, ValueError) as exc:
|
|
251
|
+
issues.append(ConfigIssue(
|
|
252
|
+
path="<spec>.securitySchemes",
|
|
253
|
+
message=f"Invalid specification authentication definitions: {exc}",
|
|
254
|
+
expected="object-valued securityDefinitions, components, and securitySchemes",
|
|
255
|
+
actual="malformed specification structure",
|
|
256
|
+
))
|
|
257
|
+
spec_auth_names = set()
|
|
258
|
+
if "identities" in config:
|
|
259
|
+
try:
|
|
260
|
+
cls._validate_identities_section(
|
|
261
|
+
config["identities"], issues, summary, spec_auth_names=spec_auth_names
|
|
262
|
+
)
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
issues.append(
|
|
265
|
+
ConfigIssue(
|
|
266
|
+
path="identities",
|
|
267
|
+
message=f"Validation failed on identities section: {exc}",
|
|
268
|
+
expected="valid identities object",
|
|
269
|
+
actual=safe_diagnostic_value(type(exc).__name__),
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# 3. Section: write_allowlist
|
|
274
|
+
allowlisted_set: Set[str] = set()
|
|
275
|
+
if "write_allowlist" in config:
|
|
276
|
+
try:
|
|
277
|
+
cls._validate_write_allowlist_section(
|
|
278
|
+
config["write_allowlist"], issues, summary, allowlisted_set
|
|
279
|
+
)
|
|
280
|
+
except Exception as exc:
|
|
281
|
+
issues.append(
|
|
282
|
+
ConfigIssue(
|
|
283
|
+
path="write_allowlist",
|
|
284
|
+
message=f"Validation failed on write_allowlist section: {exc}",
|
|
285
|
+
expected="list of strings",
|
|
286
|
+
actual=safe_diagnostic_value(type(exc).__name__),
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# 4. Section: readbacks
|
|
291
|
+
readbacks_set: Set[str] = set()
|
|
292
|
+
if "readbacks" in config:
|
|
293
|
+
try:
|
|
294
|
+
cls._validate_readbacks_section(
|
|
295
|
+
config["readbacks"], issues, summary, readbacks_set
|
|
296
|
+
)
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
issues.append(
|
|
299
|
+
ConfigIssue(
|
|
300
|
+
path="readbacks",
|
|
301
|
+
message=f"Validation failed on readbacks section: {exc}",
|
|
302
|
+
expected="dict mapping operation keys to readback configurations",
|
|
303
|
+
actual=safe_diagnostic_value(type(exc).__name__),
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Cross-check: write_allowlist endpoints MUST have a valid readback mapping!
|
|
308
|
+
# Must execute even if 'readbacks' section is omitted or empty.
|
|
309
|
+
if allowlisted_set:
|
|
310
|
+
missing_readbacks = sorted(allowlisted_set - readbacks_set)
|
|
311
|
+
for missing_op in missing_readbacks:
|
|
312
|
+
issues.append(
|
|
313
|
+
ConfigIssue(
|
|
314
|
+
path=f"readbacks.{missing_op}",
|
|
315
|
+
message=f"Operation '{missing_op}' is in write_allowlist but missing an independent GET readback mapping",
|
|
316
|
+
expected=f"an entry in 'readbacks' for '{missing_op}'",
|
|
317
|
+
actual="missing",
|
|
318
|
+
suggestion=f"Define readback mapping under 'readbacks' for '{missing_op}' with method 'GET' and field_map",
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# 5. Section: parameter_values
|
|
323
|
+
if "parameter_values" in config:
|
|
324
|
+
try:
|
|
325
|
+
cls._validate_parameter_values_section(config["parameter_values"], issues, summary)
|
|
326
|
+
except Exception as exc:
|
|
327
|
+
issues.append(
|
|
328
|
+
ConfigIssue(
|
|
329
|
+
path="parameter_values",
|
|
330
|
+
message=f"Validation failed on parameter_values section: {exc}",
|
|
331
|
+
expected="dict indexed by operation keys",
|
|
332
|
+
actual=safe_diagnostic_value(type(exc).__name__),
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# 6. Section: bola
|
|
337
|
+
if "bola" in config:
|
|
338
|
+
try:
|
|
339
|
+
cls._validate_bola_section(config["bola"], issues, summary)
|
|
340
|
+
except Exception as exc:
|
|
341
|
+
issues.append(
|
|
342
|
+
ConfigIssue(
|
|
343
|
+
path="bola",
|
|
344
|
+
message=f"Validation failed on bola section: {exc}",
|
|
345
|
+
expected="dict indexed by operation keys",
|
|
346
|
+
actual=safe_diagnostic_value(type(exc).__name__),
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
errors = [issue for issue in issues if not issue.is_warning]
|
|
351
|
+
summary.total_issues = len(issues)
|
|
352
|
+
summary.errors_count = len(errors)
|
|
353
|
+
summary.warnings_count = len(issues) - len(errors)
|
|
354
|
+
|
|
355
|
+
return ConfigValidationResult(
|
|
356
|
+
is_valid=(len(errors) == 0),
|
|
357
|
+
issues=issues,
|
|
358
|
+
summary=summary,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
@classmethod
|
|
362
|
+
def _extract_spec_auth_headers(cls, raw_spec: Optional[Dict[str, Any]]) -> Set[str]:
|
|
363
|
+
if not isinstance(raw_spec, dict):
|
|
364
|
+
return set()
|
|
365
|
+
legacy = raw_spec.get("securityDefinitions", {})
|
|
366
|
+
components = raw_spec.get("components", {})
|
|
367
|
+
if not isinstance(legacy, dict) or not isinstance(components, dict):
|
|
368
|
+
raise ValueError("securityDefinitions and components must be objects")
|
|
369
|
+
modern = components.get("securitySchemes", {})
|
|
370
|
+
if not isinstance(modern, dict):
|
|
371
|
+
raise ValueError("components.securitySchemes must be an object")
|
|
372
|
+
schemes = dict(legacy)
|
|
373
|
+
schemes.update(modern)
|
|
374
|
+
return {
|
|
375
|
+
str(scheme.get("name", "")).lower()
|
|
376
|
+
for scheme in schemes.values()
|
|
377
|
+
if isinstance(scheme, dict) and scheme.get("in") == "header" and scheme.get("name")
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
@classmethod
|
|
381
|
+
def is_auth_header(
|
|
382
|
+
cls,
|
|
383
|
+
name: str,
|
|
384
|
+
custom_auth_names: Optional[Iterable[str]] = None,
|
|
385
|
+
spec_auth_names: Optional[Iterable[str]] = None,
|
|
386
|
+
) -> bool:
|
|
387
|
+
normalized = str(name).lower().replace("-", "_")
|
|
388
|
+
if any(word in normalized for word in cls.STANDARD_AUTH_HEADER_WORDS):
|
|
389
|
+
return True
|
|
390
|
+
declared: Set[str] = set()
|
|
391
|
+
if custom_auth_names:
|
|
392
|
+
declared.update(str(k).lower() for k in custom_auth_names if isinstance(k, str))
|
|
393
|
+
if spec_auth_names:
|
|
394
|
+
declared.update(str(k).lower() for k in spec_auth_names if isinstance(k, str))
|
|
395
|
+
return str(name).lower() in declared
|
|
396
|
+
|
|
397
|
+
@classmethod
|
|
398
|
+
def _is_auth_header(
|
|
399
|
+
cls,
|
|
400
|
+
name: str,
|
|
401
|
+
custom_auth_names: Sequence[str] = (),
|
|
402
|
+
spec_auth_names: Sequence[str] = (),
|
|
403
|
+
) -> bool:
|
|
404
|
+
return cls.is_auth_header(name, custom_auth_names, spec_auth_names)
|
|
405
|
+
|
|
406
|
+
@classmethod
|
|
407
|
+
def _validate_identities_section(
|
|
408
|
+
cls,
|
|
409
|
+
identities: Any,
|
|
410
|
+
issues: List[ConfigIssue],
|
|
411
|
+
summary: ValidationSummary,
|
|
412
|
+
spec_auth_names: Optional[Set[str]] = None,
|
|
413
|
+
) -> None:
|
|
414
|
+
if not isinstance(identities, dict):
|
|
415
|
+
issues.append(
|
|
416
|
+
ConfigIssue(
|
|
417
|
+
path="identities",
|
|
418
|
+
message="'identities' must be an object containing 'owner', 'visitor', and 'anonymous'",
|
|
419
|
+
expected="dict / JSON object",
|
|
420
|
+
actual=safe_diagnostic_value(identities),
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
expected_identities = {"owner", "visitor", "anonymous"}
|
|
426
|
+
missing_required = expected_identities - set(identities)
|
|
427
|
+
if missing_required:
|
|
428
|
+
issues.append(
|
|
429
|
+
ConfigIssue(
|
|
430
|
+
path="identities",
|
|
431
|
+
message=f"Missing required identity roles: {sorted(missing_required)}",
|
|
432
|
+
expected="all of ['anonymous', 'owner', 'visitor']",
|
|
433
|
+
actual=f"present: {sorted(str(k) for k in identities.keys())}",
|
|
434
|
+
suggestion="Ensure 'owner', 'visitor', and 'anonymous' are all defined in 'identities'",
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
for identity_name, identity_data in identities.items():
|
|
439
|
+
if identity_name not in expected_identities:
|
|
440
|
+
closest = cls._find_closest_match(str(identity_name), sorted(expected_identities))
|
|
441
|
+
suggestion = f"Did you mean '{closest}'?" if closest else None
|
|
442
|
+
issues.append(
|
|
443
|
+
ConfigIssue(
|
|
444
|
+
path=f"identities.{identity_name}",
|
|
445
|
+
message=f"Unrecognized identity role '{identity_name}'",
|
|
446
|
+
expected="one of ['owner', 'visitor', 'anonymous']",
|
|
447
|
+
actual=safe_diagnostic_value(identity_name),
|
|
448
|
+
suggestion=suggestion,
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
continue
|
|
452
|
+
|
|
453
|
+
if not isinstance(identity_data, dict):
|
|
454
|
+
issues.append(
|
|
455
|
+
ConfigIssue(
|
|
456
|
+
path=f"identities.{identity_name}",
|
|
457
|
+
message=f"Identity '{identity_name}' must be an object",
|
|
458
|
+
expected="dict / JSON object",
|
|
459
|
+
actual=safe_diagnostic_value(identity_data),
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
continue
|
|
463
|
+
|
|
464
|
+
summary.identities_count += 1
|
|
465
|
+
cls._validate_single_identity(identity_name, identity_data, issues)
|
|
466
|
+
|
|
467
|
+
# Collect all custom auth header names across all identities
|
|
468
|
+
all_custom_names: Set[str] = set()
|
|
469
|
+
if isinstance(identities, dict):
|
|
470
|
+
for id_data in identities.values():
|
|
471
|
+
if isinstance(id_data, dict) and isinstance(id_data.get("auth_header_names"), list):
|
|
472
|
+
for n in id_data["auth_header_names"]:
|
|
473
|
+
if isinstance(n, str) and n.strip():
|
|
474
|
+
all_custom_names.add(n.strip().lower())
|
|
475
|
+
|
|
476
|
+
# Cross-identity effective authentication verification
|
|
477
|
+
if all(k in identities and isinstance(identities[k], dict) for k in ("owner", "visitor", "anonymous")):
|
|
478
|
+
cls._validate_identity_authentication_integrity(
|
|
479
|
+
identities, issues, custom_names=all_custom_names, spec_names=spec_auth_names
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
@classmethod
|
|
483
|
+
def _validate_single_identity(
|
|
484
|
+
cls,
|
|
485
|
+
identity_name: str,
|
|
486
|
+
identity: Dict[str, Any],
|
|
487
|
+
issues: List[ConfigIssue],
|
|
488
|
+
) -> None:
|
|
489
|
+
for key in identity.keys():
|
|
490
|
+
if key not in cls.KNOWN_IDENTITY_KEYS:
|
|
491
|
+
closest = cls._find_closest_match(str(key), sorted(cls.KNOWN_IDENTITY_KEYS))
|
|
492
|
+
suggestion = f"Did you mean '{closest}'?" if closest else None
|
|
493
|
+
issues.append(
|
|
494
|
+
ConfigIssue(
|
|
495
|
+
path=f"identities.{identity_name}.{key}",
|
|
496
|
+
message=f"Unrecognized field '{key}' in identity '{identity_name}'",
|
|
497
|
+
expected=f"one of {sorted(cls.KNOWN_IDENTITY_KEYS)}",
|
|
498
|
+
actual=safe_diagnostic_value(key),
|
|
499
|
+
suggestion=suggestion,
|
|
500
|
+
)
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
# token
|
|
504
|
+
token = identity.get("token")
|
|
505
|
+
if token is not None:
|
|
506
|
+
if not isinstance(token, str):
|
|
507
|
+
issues.append(
|
|
508
|
+
ConfigIssue(
|
|
509
|
+
path=f"identities.{identity_name}.token",
|
|
510
|
+
message="Identity token must be a string or null",
|
|
511
|
+
expected="string or null",
|
|
512
|
+
actual=safe_diagnostic_value(token),
|
|
513
|
+
)
|
|
514
|
+
)
|
|
515
|
+
elif any(c in token for c in "\r\n\t"):
|
|
516
|
+
issues.append(
|
|
517
|
+
ConfigIssue(
|
|
518
|
+
path=f"identities.{identity_name}.token",
|
|
519
|
+
message="Identity token must not contain line breaks or control characters",
|
|
520
|
+
expected="single-line clean string",
|
|
521
|
+
# Do NOT leak token value!
|
|
522
|
+
actual="<token string with line breaks/control chars>",
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# id
|
|
527
|
+
ident_id = identity.get("id")
|
|
528
|
+
if ident_id is not None:
|
|
529
|
+
if isinstance(ident_id, bool) or not isinstance(ident_id, (str, int)):
|
|
530
|
+
issues.append(
|
|
531
|
+
ConfigIssue(
|
|
532
|
+
path=f"identities.{identity_name}.id",
|
|
533
|
+
message="Identity id must be a string, integer, or null",
|
|
534
|
+
expected="string, integer, or null",
|
|
535
|
+
actual=safe_diagnostic_value(ident_id),
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
elif isinstance(ident_id, str) and any(c in ident_id for c in "\r\n\t"):
|
|
539
|
+
issues.append(
|
|
540
|
+
ConfigIssue(
|
|
541
|
+
path=f"identities.{identity_name}.id",
|
|
542
|
+
message="Identity id must not contain line breaks or control characters",
|
|
543
|
+
expected="clean identifier string",
|
|
544
|
+
actual=safe_diagnostic_value(ident_id),
|
|
545
|
+
)
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# headers
|
|
549
|
+
headers = identity.get("headers")
|
|
550
|
+
if headers is not None:
|
|
551
|
+
if not isinstance(headers, dict):
|
|
552
|
+
issues.append(
|
|
553
|
+
ConfigIssue(
|
|
554
|
+
path=f"identities.{identity_name}.headers",
|
|
555
|
+
message=f"Headers for '{identity_name}' must be an object",
|
|
556
|
+
expected="dict / JSON object",
|
|
557
|
+
actual=safe_diagnostic_value(headers),
|
|
558
|
+
)
|
|
559
|
+
)
|
|
560
|
+
else:
|
|
561
|
+
seen_headers: Set[str] = set()
|
|
562
|
+
for h_key, h_val in headers.items():
|
|
563
|
+
norm_h = str(h_key).strip().lower()
|
|
564
|
+
if norm_h in seen_headers:
|
|
565
|
+
issues.append(
|
|
566
|
+
ConfigIssue(
|
|
567
|
+
path=f"identities.{identity_name}.headers.{h_key}",
|
|
568
|
+
message=f"Duplicate case-insensitive header '{norm_h}'",
|
|
569
|
+
expected="unique case-insensitive header names",
|
|
570
|
+
actual=safe_diagnostic_value(h_key),
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
seen_headers.add(norm_h)
|
|
574
|
+
|
|
575
|
+
if not isinstance(h_val, str):
|
|
576
|
+
issues.append(
|
|
577
|
+
ConfigIssue(
|
|
578
|
+
path=f"identities.{identity_name}.headers.{h_key}",
|
|
579
|
+
message=f"Header value for '{h_key}' must be a string",
|
|
580
|
+
expected="string",
|
|
581
|
+
actual=safe_diagnostic_value(h_val),
|
|
582
|
+
)
|
|
583
|
+
)
|
|
584
|
+
elif any(c in str(h_key) + str(h_val) for c in "\r\n\t"):
|
|
585
|
+
issues.append(
|
|
586
|
+
ConfigIssue(
|
|
587
|
+
path=f"identities.{identity_name}.headers.{h_key}",
|
|
588
|
+
message="Identity header names and values must not contain line breaks or control characters",
|
|
589
|
+
expected="single-line header key/value",
|
|
590
|
+
actual=f"{safe_diagnostic_value(h_key)}: [VALUE WITH LINEBREAKS REDACTED]",
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
# parameters
|
|
595
|
+
parameters = identity.get("parameters")
|
|
596
|
+
if parameters is not None:
|
|
597
|
+
if not isinstance(parameters, dict):
|
|
598
|
+
issues.append(
|
|
599
|
+
ConfigIssue(
|
|
600
|
+
path=f"identities.{identity_name}.parameters",
|
|
601
|
+
message="Identity parameters must be an object",
|
|
602
|
+
expected="dict / JSON object",
|
|
603
|
+
actual=safe_diagnostic_value(parameters),
|
|
604
|
+
)
|
|
605
|
+
)
|
|
606
|
+
else:
|
|
607
|
+
for p_k in parameters.keys():
|
|
608
|
+
if not isinstance(p_k, str) or not p_k.strip():
|
|
609
|
+
issues.append(
|
|
610
|
+
ConfigIssue(
|
|
611
|
+
path=f"identities.{identity_name}.parameters",
|
|
612
|
+
message="Parameter names must be non-empty strings",
|
|
613
|
+
expected="non-empty string key",
|
|
614
|
+
actual=safe_diagnostic_value(p_k),
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
# auth_header_names
|
|
619
|
+
auth_names = identity.get("auth_header_names")
|
|
620
|
+
if auth_names is not None:
|
|
621
|
+
if not isinstance(auth_names, list) or any(not isinstance(k, str) or not k.strip() for k in auth_names):
|
|
622
|
+
issues.append(
|
|
623
|
+
ConfigIssue(
|
|
624
|
+
path=f"identities.{identity_name}.auth_header_names",
|
|
625
|
+
message="auth_header_names must be a list of non-empty header name strings",
|
|
626
|
+
expected="list of non-empty strings",
|
|
627
|
+
actual=safe_diagnostic_value(auth_names),
|
|
628
|
+
)
|
|
629
|
+
)
|
|
630
|
+
else:
|
|
631
|
+
for k in auth_names:
|
|
632
|
+
if any(c in k for c in "\r\n\t"):
|
|
633
|
+
issues.append(
|
|
634
|
+
ConfigIssue(
|
|
635
|
+
path=f"identities.{identity_name}.auth_header_names",
|
|
636
|
+
message="Header names in auth_header_names must not contain line breaks or control characters",
|
|
637
|
+
expected="clean header name",
|
|
638
|
+
actual=safe_diagnostic_value(k),
|
|
639
|
+
)
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
@classmethod
|
|
643
|
+
def _extract_effective_auth_material(
|
|
644
|
+
cls,
|
|
645
|
+
identity_name: str,
|
|
646
|
+
identity: Dict[str, Any],
|
|
647
|
+
custom_names: Optional[Iterable[str]] = None,
|
|
648
|
+
spec_names: Optional[Iterable[str]] = None,
|
|
649
|
+
) -> Tuple[Tuple[str, str], ...]:
|
|
650
|
+
combined_custom: Set[str] = set()
|
|
651
|
+
if custom_names:
|
|
652
|
+
combined_custom.update(str(k).lower() for k in custom_names if isinstance(k, str))
|
|
653
|
+
for k in (identity.get("auth_header_names") or []):
|
|
654
|
+
if isinstance(k, str) and k.strip():
|
|
655
|
+
combined_custom.add(k.strip().lower())
|
|
656
|
+
|
|
657
|
+
headers: Dict[str, str] = {}
|
|
658
|
+
for k, v in (identity.get("headers") or {}).items():
|
|
659
|
+
if isinstance(v, str):
|
|
660
|
+
headers[str(k).strip().lower()] = str(v).strip()
|
|
661
|
+
token = identity.get("token")
|
|
662
|
+
if isinstance(token, str) and token.strip():
|
|
663
|
+
headers["authorization"] = token.strip()
|
|
664
|
+
|
|
665
|
+
material = []
|
|
666
|
+
for key, value in headers.items():
|
|
667
|
+
if cls.is_auth_header(key, combined_custom, spec_names) and value:
|
|
668
|
+
normalized = value
|
|
669
|
+
if key == "authorization" and " " in normalized:
|
|
670
|
+
scheme, credential = normalized.split(None, 1)
|
|
671
|
+
normalized = scheme.lower() + " " + credential.strip()
|
|
672
|
+
material.append((key, normalized))
|
|
673
|
+
return tuple(sorted(material))
|
|
674
|
+
|
|
675
|
+
@classmethod
|
|
676
|
+
def _validate_identity_authentication_integrity(
|
|
677
|
+
cls,
|
|
678
|
+
identities: Dict[str, Any],
|
|
679
|
+
issues: List[ConfigIssue],
|
|
680
|
+
custom_names: Optional[Iterable[str]] = None,
|
|
681
|
+
spec_names: Optional[Iterable[str]] = None,
|
|
682
|
+
) -> None:
|
|
683
|
+
owner_mat = cls._extract_effective_auth_material("owner", identities["owner"], custom_names, spec_names)
|
|
684
|
+
visitor_mat = cls._extract_effective_auth_material("visitor", identities["visitor"], custom_names, spec_names)
|
|
685
|
+
anon_mat = cls._extract_effective_auth_material("anonymous", identities["anonymous"], custom_names, spec_names)
|
|
686
|
+
|
|
687
|
+
if not owner_mat:
|
|
688
|
+
issues.append(
|
|
689
|
+
ConfigIssue(
|
|
690
|
+
path="identities.owner",
|
|
691
|
+
message="Owner identity has no effective authentication material (token, Authorization, or custom auth header)",
|
|
692
|
+
expected="non-empty authentication credentials",
|
|
693
|
+
actual="empty auth material",
|
|
694
|
+
)
|
|
695
|
+
)
|
|
696
|
+
if not visitor_mat:
|
|
697
|
+
issues.append(
|
|
698
|
+
ConfigIssue(
|
|
699
|
+
path="identities.visitor",
|
|
700
|
+
message="Visitor identity has no effective authentication material (token, Authorization, or custom auth header)",
|
|
701
|
+
expected="non-empty authentication credentials",
|
|
702
|
+
actual="empty auth material",
|
|
703
|
+
)
|
|
704
|
+
)
|
|
705
|
+
if owner_mat and visitor_mat and owner_mat == visitor_mat:
|
|
706
|
+
issues.append(
|
|
707
|
+
ConfigIssue(
|
|
708
|
+
path="identities.visitor",
|
|
709
|
+
message="Owner and visitor must use distinct effective authentication credentials to verify authorization boundaries",
|
|
710
|
+
expected="distinct credentials from owner",
|
|
711
|
+
actual="identical auth material to owner (redacted)",
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
if anon_mat:
|
|
715
|
+
auth_header_keys = [k for k, _ in anon_mat]
|
|
716
|
+
issues.append(
|
|
717
|
+
ConfigIssue(
|
|
718
|
+
path="identities.anonymous",
|
|
719
|
+
message="Anonymous identity must not define authentication credentials or auth headers",
|
|
720
|
+
expected="empty authentication material",
|
|
721
|
+
# Never leak token value!
|
|
722
|
+
actual=f"credentials configured under: {auth_header_keys}",
|
|
723
|
+
)
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
# Anonymous structure constraints
|
|
727
|
+
anon = identities.get("anonymous", {})
|
|
728
|
+
if isinstance(anon, dict):
|
|
729
|
+
if anon.get("id") is not None:
|
|
730
|
+
issues.append(
|
|
731
|
+
ConfigIssue(
|
|
732
|
+
path="identities.anonymous.id",
|
|
733
|
+
message="Anonymous identity id must be null",
|
|
734
|
+
expected="null",
|
|
735
|
+
actual=safe_diagnostic_value(anon.get("id")),
|
|
736
|
+
)
|
|
737
|
+
)
|
|
738
|
+
if anon.get("token") is not None:
|
|
739
|
+
issues.append(
|
|
740
|
+
ConfigIssue(
|
|
741
|
+
path="identities.anonymous.token",
|
|
742
|
+
message="Anonymous identity token must be null",
|
|
743
|
+
expected="null",
|
|
744
|
+
actual="[TOKEN PRESENT REDACTED]",
|
|
745
|
+
)
|
|
746
|
+
)
|
|
747
|
+
if anon.get("headers"):
|
|
748
|
+
issues.append(
|
|
749
|
+
ConfigIssue(
|
|
750
|
+
path="identities.anonymous.headers",
|
|
751
|
+
message="Anonymous identity headers must be empty",
|
|
752
|
+
expected="empty object",
|
|
753
|
+
actual=f"{len(anon['headers'])} header(s) configured",
|
|
754
|
+
)
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
@classmethod
|
|
758
|
+
def _validate_write_allowlist_section(
|
|
759
|
+
cls,
|
|
760
|
+
allowlist: Any,
|
|
761
|
+
issues: List[ConfigIssue],
|
|
762
|
+
summary: ValidationSummary,
|
|
763
|
+
allowlisted_set: Set[str],
|
|
764
|
+
) -> None:
|
|
765
|
+
if not isinstance(allowlist, list):
|
|
766
|
+
issues.append(
|
|
767
|
+
ConfigIssue(
|
|
768
|
+
path="write_allowlist",
|
|
769
|
+
message="'write_allowlist' must be a list of 'PATCH /path' strings",
|
|
770
|
+
expected="list of strings",
|
|
771
|
+
actual=safe_diagnostic_value(allowlist),
|
|
772
|
+
)
|
|
773
|
+
)
|
|
774
|
+
return
|
|
775
|
+
|
|
776
|
+
for index, item in enumerate(allowlist):
|
|
777
|
+
is_valid, method, path, canonical, err_msg = parse_operation_key(item)
|
|
778
|
+
if not is_valid:
|
|
779
|
+
issues.append(
|
|
780
|
+
ConfigIssue(
|
|
781
|
+
path=f"write_allowlist[{index}]",
|
|
782
|
+
message=f"Write allowlist entry is invalid: {err_msg}",
|
|
783
|
+
expected="'PATCH /case-sensitive-path'",
|
|
784
|
+
actual=safe_diagnostic_value(item),
|
|
785
|
+
suggestion="Ensure method is uppercase 'PATCH' followed by a space and a path starting with '/'",
|
|
786
|
+
)
|
|
787
|
+
)
|
|
788
|
+
elif method != "PATCH":
|
|
789
|
+
issues.append(
|
|
790
|
+
ConfigIssue(
|
|
791
|
+
path=f"write_allowlist[{index}]",
|
|
792
|
+
message=f"Write allowlist only supports 'PATCH' operations (got '{method}')",
|
|
793
|
+
expected="'PATCH /path'",
|
|
794
|
+
actual=safe_diagnostic_value(item),
|
|
795
|
+
suggestion="Change method to 'PATCH'",
|
|
796
|
+
)
|
|
797
|
+
)
|
|
798
|
+
else:
|
|
799
|
+
allowlisted_set.add(canonical)
|
|
800
|
+
summary.allowlisted_endpoints += 1
|
|
801
|
+
|
|
802
|
+
@classmethod
|
|
803
|
+
def _validate_readbacks_section(
|
|
804
|
+
cls,
|
|
805
|
+
readbacks: Any,
|
|
806
|
+
issues: List[ConfigIssue],
|
|
807
|
+
summary: ValidationSummary,
|
|
808
|
+
readbacks_set: Set[str],
|
|
809
|
+
) -> None:
|
|
810
|
+
if not isinstance(readbacks, dict):
|
|
811
|
+
issues.append(
|
|
812
|
+
ConfigIssue(
|
|
813
|
+
path="readbacks",
|
|
814
|
+
message="'readbacks' must be an object mapping 'PATCH /path' to readback definitions",
|
|
815
|
+
expected="dict / JSON object",
|
|
816
|
+
actual=safe_diagnostic_value(readbacks),
|
|
817
|
+
)
|
|
818
|
+
)
|
|
819
|
+
return
|
|
820
|
+
|
|
821
|
+
for op_key, rb_data in readbacks.items():
|
|
822
|
+
is_valid, method, path, canonical, err_msg = parse_operation_key(op_key)
|
|
823
|
+
if not is_valid:
|
|
824
|
+
issues.append(
|
|
825
|
+
ConfigIssue(
|
|
826
|
+
path=f"readbacks.{op_key}",
|
|
827
|
+
message=f"Invalid readback operation key: {err_msg}",
|
|
828
|
+
expected="'PATCH /path'",
|
|
829
|
+
actual=safe_diagnostic_value(op_key),
|
|
830
|
+
)
|
|
831
|
+
)
|
|
832
|
+
elif method != "PATCH":
|
|
833
|
+
issues.append(
|
|
834
|
+
ConfigIssue(
|
|
835
|
+
path=f"readbacks.{op_key}",
|
|
836
|
+
message=f"Readback keys must strictly be 'PATCH /path' operations (got '{method}')",
|
|
837
|
+
expected="'PATCH /path'",
|
|
838
|
+
actual=safe_diagnostic_value(op_key),
|
|
839
|
+
)
|
|
840
|
+
)
|
|
841
|
+
else:
|
|
842
|
+
readbacks_set.add(canonical)
|
|
843
|
+
|
|
844
|
+
if not isinstance(rb_data, dict):
|
|
845
|
+
issues.append(
|
|
846
|
+
ConfigIssue(
|
|
847
|
+
path=f"readbacks.{op_key}",
|
|
848
|
+
message=f"Readback definition for '{op_key}' must be an object",
|
|
849
|
+
expected="dict / JSON object",
|
|
850
|
+
actual=safe_diagnostic_value(rb_data),
|
|
851
|
+
)
|
|
852
|
+
)
|
|
853
|
+
continue
|
|
854
|
+
|
|
855
|
+
summary.readbacks_count += 1
|
|
856
|
+
cls._validate_single_readback(op_key, rb_data, issues)
|
|
857
|
+
|
|
858
|
+
@classmethod
|
|
859
|
+
def _validate_single_readback(
|
|
860
|
+
cls,
|
|
861
|
+
op_key: str,
|
|
862
|
+
readback: Dict[str, Any],
|
|
863
|
+
issues: List[ConfigIssue],
|
|
864
|
+
) -> None:
|
|
865
|
+
# Check unknown keys
|
|
866
|
+
for key in readback.keys():
|
|
867
|
+
if key not in cls.KNOWN_READBACK_KEYS:
|
|
868
|
+
closest = cls._find_closest_match(str(key), sorted(cls.KNOWN_READBACK_KEYS))
|
|
869
|
+
suggestion = f"Did you mean '{closest}'?" if closest else None
|
|
870
|
+
issues.append(
|
|
871
|
+
ConfigIssue(
|
|
872
|
+
path=f"readbacks.{op_key}.{key}",
|
|
873
|
+
message=f"Unrecognized field '{key}' in readback configuration",
|
|
874
|
+
expected=f"one of {sorted(cls.KNOWN_READBACK_KEYS)}",
|
|
875
|
+
actual=safe_diagnostic_value(key),
|
|
876
|
+
suggestion=suggestion,
|
|
877
|
+
)
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
# method (Required to be uppercase GET)
|
|
881
|
+
method = readback.get("method")
|
|
882
|
+
if method is None:
|
|
883
|
+
issues.append(
|
|
884
|
+
ConfigIssue(
|
|
885
|
+
path=f"readbacks.{op_key}.method",
|
|
886
|
+
message="Readback requires an explicit 'method' specifying 'GET'",
|
|
887
|
+
expected="'GET'",
|
|
888
|
+
actual="missing",
|
|
889
|
+
)
|
|
890
|
+
)
|
|
891
|
+
elif not isinstance(method, str):
|
|
892
|
+
issues.append(
|
|
893
|
+
ConfigIssue(
|
|
894
|
+
path=f"readbacks.{op_key}.method",
|
|
895
|
+
message="Readback method must be a string",
|
|
896
|
+
expected="'GET'",
|
|
897
|
+
actual=safe_diagnostic_value(method),
|
|
898
|
+
)
|
|
899
|
+
)
|
|
900
|
+
elif method != "GET":
|
|
901
|
+
issues.append(
|
|
902
|
+
ConfigIssue(
|
|
903
|
+
path=f"readbacks.{op_key}.method",
|
|
904
|
+
message=f"Readback method must strictly be uppercase 'GET' (got '{method}')",
|
|
905
|
+
expected="'GET'",
|
|
906
|
+
actual=safe_diagnostic_value(method),
|
|
907
|
+
suggestion="Set method to 'GET'",
|
|
908
|
+
)
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
# path (Required, starts with /)
|
|
912
|
+
path = readback.get("path")
|
|
913
|
+
if path is None:
|
|
914
|
+
issues.append(
|
|
915
|
+
ConfigIssue(
|
|
916
|
+
path=f"readbacks.{op_key}.path",
|
|
917
|
+
message="Readback requires an explicit 'path'",
|
|
918
|
+
expected="string path starting with '/'",
|
|
919
|
+
actual="missing",
|
|
920
|
+
)
|
|
921
|
+
)
|
|
922
|
+
elif not isinstance(path, str) or not path.startswith("/") or any(c in path for c in "\r\n\t "):
|
|
923
|
+
issues.append(
|
|
924
|
+
ConfigIssue(
|
|
925
|
+
path=f"readbacks.{op_key}.path",
|
|
926
|
+
message="Readback path must be a clean string starting with '/' without whitespace",
|
|
927
|
+
expected="string starting with '/'",
|
|
928
|
+
actual=safe_diagnostic_value(path),
|
|
929
|
+
)
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
# The readback URL builder consumes a list of Parameter Objects directly;
|
|
933
|
+
# mappings, reference objects, and unsupported locations are not expanded.
|
|
934
|
+
if "parameters" in readback:
|
|
935
|
+
parameters = readback["parameters"]
|
|
936
|
+
if not isinstance(parameters, list):
|
|
937
|
+
issues.append(ConfigIssue(
|
|
938
|
+
path=f"readbacks.{op_key}.parameters",
|
|
939
|
+
message="Readback parameters must be an array of path/query parameter objects",
|
|
940
|
+
expected="list of objects with name, in, and schema",
|
|
941
|
+
actual=safe_diagnostic_value(parameters),
|
|
942
|
+
))
|
|
943
|
+
else:
|
|
944
|
+
identities: Set[Tuple[str, str]] = set()
|
|
945
|
+
for index, parameter in enumerate(parameters):
|
|
946
|
+
parameter_path = f"readbacks.{op_key}.parameters[{index}]"
|
|
947
|
+
if not isinstance(parameter, dict):
|
|
948
|
+
issues.append(ConfigIssue(
|
|
949
|
+
path=parameter_path,
|
|
950
|
+
message="Readback parameter must be an object",
|
|
951
|
+
expected="object with name, in, and schema",
|
|
952
|
+
actual=safe_diagnostic_value(parameter),
|
|
953
|
+
))
|
|
954
|
+
continue
|
|
955
|
+
name, location = parameter.get("name"), parameter.get("in")
|
|
956
|
+
valid_name = isinstance(name, str) and bool(name) and not any(
|
|
957
|
+
character.isspace() or ord(character) < 32 for character in name
|
|
958
|
+
)
|
|
959
|
+
if not valid_name:
|
|
960
|
+
issues.append(ConfigIssue(
|
|
961
|
+
path=parameter_path + ".name",
|
|
962
|
+
message="Readback parameter name must be a non-empty clean string",
|
|
963
|
+
expected="non-empty name without whitespace or control characters",
|
|
964
|
+
actual=safe_diagnostic_value(name),
|
|
965
|
+
))
|
|
966
|
+
if location not in ("path", "query"):
|
|
967
|
+
issues.append(ConfigIssue(
|
|
968
|
+
path=parameter_path + ".in",
|
|
969
|
+
message="Readback parameter location must be supported by the URL builder",
|
|
970
|
+
expected="'path' or 'query'",
|
|
971
|
+
actual=safe_diagnostic_value(location),
|
|
972
|
+
))
|
|
973
|
+
if not isinstance(parameter.get("schema"), dict):
|
|
974
|
+
issues.append(ConfigIssue(
|
|
975
|
+
path=parameter_path + ".schema",
|
|
976
|
+
message="Readback parameter requires a schema object",
|
|
977
|
+
expected="JSON object",
|
|
978
|
+
actual=safe_diagnostic_value(parameter.get("schema")),
|
|
979
|
+
))
|
|
980
|
+
if valid_name and location in ("path", "query"):
|
|
981
|
+
identity = (name, location)
|
|
982
|
+
if identity in identities:
|
|
983
|
+
issues.append(ConfigIssue(
|
|
984
|
+
path=parameter_path,
|
|
985
|
+
message="Duplicate readback parameter name and location",
|
|
986
|
+
expected="unique (name, in) pair",
|
|
987
|
+
actual=safe_diagnostic_value(name),
|
|
988
|
+
))
|
|
989
|
+
identities.add(identity)
|
|
990
|
+
|
|
991
|
+
if "parameter_values" in readback:
|
|
992
|
+
values = readback["parameter_values"]
|
|
993
|
+
if not isinstance(values, dict):
|
|
994
|
+
issues.append(ConfigIssue(
|
|
995
|
+
path=f"readbacks.{op_key}.parameter_values",
|
|
996
|
+
message="Readback parameter_values must be an object",
|
|
997
|
+
expected="parameter name -> value object",
|
|
998
|
+
actual=safe_diagnostic_value(values),
|
|
999
|
+
))
|
|
1000
|
+
else:
|
|
1001
|
+
for name in values:
|
|
1002
|
+
if not isinstance(name, str) or not name or any(
|
|
1003
|
+
character.isspace() or ord(character) < 32 for character in name
|
|
1004
|
+
):
|
|
1005
|
+
issues.append(ConfigIssue(
|
|
1006
|
+
path=f"readbacks.{op_key}.parameter_values",
|
|
1007
|
+
message="Readback parameter value names must be non-empty clean strings",
|
|
1008
|
+
expected="non-empty names without whitespace or control characters",
|
|
1009
|
+
actual=safe_diagnostic_value(name),
|
|
1010
|
+
))
|
|
1011
|
+
|
|
1012
|
+
# field_map (Required, non-empty dict of valid_path -> valid_path)
|
|
1013
|
+
field_map = readback.get("field_map")
|
|
1014
|
+
parsed_written_paths: List[Tuple[Any, ...]] = []
|
|
1015
|
+
parsed_readback_paths: List[Tuple[Any, ...]] = []
|
|
1016
|
+
if field_map is None:
|
|
1017
|
+
issues.append(
|
|
1018
|
+
ConfigIssue(
|
|
1019
|
+
path=f"readbacks.{op_key}.field_map",
|
|
1020
|
+
message="Readback requires an explicit non-empty 'field_map'",
|
|
1021
|
+
expected="non-empty object mapping write field path to readback field path",
|
|
1022
|
+
actual="missing",
|
|
1023
|
+
)
|
|
1024
|
+
)
|
|
1025
|
+
elif not isinstance(field_map, dict) or len(field_map) == 0:
|
|
1026
|
+
issues.append(
|
|
1027
|
+
ConfigIssue(
|
|
1028
|
+
path=f"readbacks.{op_key}.field_map",
|
|
1029
|
+
message="'field_map' must be a non-empty object mapping write fields to readback paths",
|
|
1030
|
+
expected="non-empty dict",
|
|
1031
|
+
actual=safe_diagnostic_value(field_map),
|
|
1032
|
+
)
|
|
1033
|
+
)
|
|
1034
|
+
else:
|
|
1035
|
+
for w_path, r_path in field_map.items():
|
|
1036
|
+
if not isinstance(w_path, str) or not isinstance(r_path, str):
|
|
1037
|
+
issues.append(
|
|
1038
|
+
ConfigIssue(
|
|
1039
|
+
path=f"readbacks.{op_key}.field_map",
|
|
1040
|
+
message="field_map keys and values must be string path expressions",
|
|
1041
|
+
expected="string -> string",
|
|
1042
|
+
actual=f"{safe_diagnostic_value(w_path)} -> {safe_diagnostic_value(r_path)}",
|
|
1043
|
+
)
|
|
1044
|
+
)
|
|
1045
|
+
continue
|
|
1046
|
+
|
|
1047
|
+
# Verify dot/bracket path syntax compatible with transactions.path_parts
|
|
1048
|
+
try:
|
|
1049
|
+
parts_w = path_parts(w_path)
|
|
1050
|
+
parsed_written_paths.append(parts_w)
|
|
1051
|
+
except ValueError as exc:
|
|
1052
|
+
issues.append(
|
|
1053
|
+
ConfigIssue(
|
|
1054
|
+
path=f"readbacks.{op_key}.field_map.{w_path}",
|
|
1055
|
+
message=f"Invalid mutation path expression in field_map: {exc}",
|
|
1056
|
+
expected="valid dot/bracket path (e.g. 'role', 'members[0].role')",
|
|
1057
|
+
actual=safe_diagnostic_value(w_path),
|
|
1058
|
+
)
|
|
1059
|
+
)
|
|
1060
|
+
try:
|
|
1061
|
+
parts_r = path_parts(r_path)
|
|
1062
|
+
parsed_readback_paths.append(parts_r)
|
|
1063
|
+
except ValueError as exc:
|
|
1064
|
+
issues.append(
|
|
1065
|
+
ConfigIssue(
|
|
1066
|
+
path=f"readbacks.{op_key}.field_map.{w_path}",
|
|
1067
|
+
message=f"Invalid readback path expression in field_map: {exc}",
|
|
1068
|
+
expected="valid dot/bracket path (e.g. 'data.role')",
|
|
1069
|
+
actual=safe_diagnostic_value(r_path),
|
|
1070
|
+
)
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
# consistency (Optional enum: strong, weak, eventual)
|
|
1074
|
+
consistency = readback.get("consistency")
|
|
1075
|
+
if consistency is not None:
|
|
1076
|
+
if not isinstance(consistency, str) or consistency not in cls.VALID_CONSISTENCY_VALUES:
|
|
1077
|
+
closest = cls._find_closest_match(
|
|
1078
|
+
str(consistency).lower(), cls.VALID_CONSISTENCY_VALUES
|
|
1079
|
+
)
|
|
1080
|
+
suggestion = f"Did you mean '{closest}'?" if closest else None
|
|
1081
|
+
issues.append(
|
|
1082
|
+
ConfigIssue(
|
|
1083
|
+
path=f"readbacks.{op_key}.consistency",
|
|
1084
|
+
message=f"Invalid readback consistency '{consistency}'",
|
|
1085
|
+
expected=f"one of {list(cls.VALID_CONSISTENCY_VALUES)}",
|
|
1086
|
+
actual=safe_diagnostic_value(consistency),
|
|
1087
|
+
suggestion=suggestion,
|
|
1088
|
+
)
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
# readback_attempts (int in [1, 10], not bool)
|
|
1092
|
+
attempts = readback.get("readback_attempts")
|
|
1093
|
+
if attempts is not None:
|
|
1094
|
+
if not isinstance(attempts, int) or isinstance(attempts, bool) or attempts < 1 or attempts > 10:
|
|
1095
|
+
issues.append(
|
|
1096
|
+
ConfigIssue(
|
|
1097
|
+
path=f"readbacks.{op_key}.readback_attempts",
|
|
1098
|
+
message="readback_attempts must be an integer between 1 and 10",
|
|
1099
|
+
expected="integer in range [1, 10]",
|
|
1100
|
+
actual=safe_diagnostic_value(attempts),
|
|
1101
|
+
)
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
# readback_delay (float or int in [0.0, 5.0], not bool, finite)
|
|
1105
|
+
delay = readback.get("readback_delay")
|
|
1106
|
+
if delay is not None:
|
|
1107
|
+
if (
|
|
1108
|
+
not isinstance(delay, (int, float))
|
|
1109
|
+
or isinstance(delay, bool)
|
|
1110
|
+
or math.isnan(delay)
|
|
1111
|
+
or math.isinf(delay)
|
|
1112
|
+
or delay < 0.0
|
|
1113
|
+
or delay > 5.0
|
|
1114
|
+
):
|
|
1115
|
+
issues.append(
|
|
1116
|
+
ConfigIssue(
|
|
1117
|
+
path=f"readbacks.{op_key}.readback_delay",
|
|
1118
|
+
message="readback_delay must be a finite number between 0.0 and 5.0 seconds",
|
|
1119
|
+
expected="number in range [0.0, 5.0]",
|
|
1120
|
+
actual=safe_diagnostic_value(delay),
|
|
1121
|
+
)
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
# snapshot_path
|
|
1125
|
+
snapshot_path = readback.get("snapshot_path")
|
|
1126
|
+
if snapshot_path is not None:
|
|
1127
|
+
if not isinstance(snapshot_path, str):
|
|
1128
|
+
issues.append(
|
|
1129
|
+
ConfigIssue(
|
|
1130
|
+
path=f"readbacks.{op_key}.snapshot_path",
|
|
1131
|
+
message="snapshot_path must be a string dot/bracket path",
|
|
1132
|
+
expected="string path",
|
|
1133
|
+
actual=safe_diagnostic_value(snapshot_path),
|
|
1134
|
+
)
|
|
1135
|
+
)
|
|
1136
|
+
else:
|
|
1137
|
+
try:
|
|
1138
|
+
parts_sp = path_parts(snapshot_path)
|
|
1139
|
+
parsed_readback_paths.append(parts_sp)
|
|
1140
|
+
except ValueError as exc:
|
|
1141
|
+
issues.append(
|
|
1142
|
+
ConfigIssue(
|
|
1143
|
+
path=f"readbacks.{op_key}.snapshot_path",
|
|
1144
|
+
message=f"Invalid snapshot_path expression: {exc}",
|
|
1145
|
+
expected="valid dot/bracket path",
|
|
1146
|
+
actual=safe_diagnostic_value(snapshot_path),
|
|
1147
|
+
)
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
# snapshot_field_map
|
|
1151
|
+
snapshot_field_map = readback.get("snapshot_field_map")
|
|
1152
|
+
if snapshot_field_map is not None:
|
|
1153
|
+
if not isinstance(snapshot_field_map, dict):
|
|
1154
|
+
issues.append(
|
|
1155
|
+
ConfigIssue(
|
|
1156
|
+
path=f"readbacks.{op_key}.snapshot_field_map",
|
|
1157
|
+
message="snapshot_field_map must be an object",
|
|
1158
|
+
expected="dict / JSON object",
|
|
1159
|
+
actual=safe_diagnostic_value(snapshot_field_map),
|
|
1160
|
+
)
|
|
1161
|
+
)
|
|
1162
|
+
else:
|
|
1163
|
+
for k, v in snapshot_field_map.items():
|
|
1164
|
+
if not isinstance(k, str) or not isinstance(v, str):
|
|
1165
|
+
issues.append(
|
|
1166
|
+
ConfigIssue(
|
|
1167
|
+
path=f"readbacks.{op_key}.snapshot_field_map",
|
|
1168
|
+
message="snapshot_field_map keys and values must be strings",
|
|
1169
|
+
expected="string -> string",
|
|
1170
|
+
actual=f"{safe_diagnostic_value(k)} -> {safe_diagnostic_value(v)}",
|
|
1171
|
+
)
|
|
1172
|
+
)
|
|
1173
|
+
else:
|
|
1174
|
+
for p in (k, v):
|
|
1175
|
+
try:
|
|
1176
|
+
path_parts(p)
|
|
1177
|
+
except ValueError as exc:
|
|
1178
|
+
issues.append(
|
|
1179
|
+
ConfigIssue(
|
|
1180
|
+
path=f"readbacks.{op_key}.snapshot_field_map",
|
|
1181
|
+
message=f"Invalid path in snapshot_field_map: {exc}",
|
|
1182
|
+
expected="valid dot/bracket path",
|
|
1183
|
+
actual=safe_diagnostic_value(p),
|
|
1184
|
+
)
|
|
1185
|
+
)
|
|
1186
|
+
try:
|
|
1187
|
+
parsed_written_paths.append(path_parts(k))
|
|
1188
|
+
parsed_readback_paths.append(path_parts(v))
|
|
1189
|
+
except ValueError:
|
|
1190
|
+
pass
|
|
1191
|
+
|
|
1192
|
+
# baseline_payload
|
|
1193
|
+
baseline_payload = readback.get("baseline_payload")
|
|
1194
|
+
if baseline_payload is not None and not isinstance(baseline_payload, dict):
|
|
1195
|
+
issues.append(
|
|
1196
|
+
ConfigIssue(
|
|
1197
|
+
path=f"readbacks.{op_key}.baseline_payload",
|
|
1198
|
+
message="baseline_payload must be an object",
|
|
1199
|
+
expected="dict / JSON object",
|
|
1200
|
+
actual=safe_diagnostic_value(baseline_payload),
|
|
1201
|
+
)
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
# ignore_readback_paths & collision check with written and readback fields (parent, child, exact)
|
|
1205
|
+
ignored_paths = readback.get("ignore_readback_paths")
|
|
1206
|
+
if ignored_paths is not None:
|
|
1207
|
+
if not isinstance(ignored_paths, list) or any(not isinstance(p, str) for p in ignored_paths):
|
|
1208
|
+
issues.append(
|
|
1209
|
+
ConfigIssue(
|
|
1210
|
+
path=f"readbacks.{op_key}.ignore_readback_paths",
|
|
1211
|
+
message="ignore_readback_paths must be a list of field path strings",
|
|
1212
|
+
expected="list of strings",
|
|
1213
|
+
actual=safe_diagnostic_value(ignored_paths),
|
|
1214
|
+
)
|
|
1215
|
+
)
|
|
1216
|
+
else:
|
|
1217
|
+
for ignored in ignored_paths:
|
|
1218
|
+
try:
|
|
1219
|
+
ig_parts = path_parts(ignored)
|
|
1220
|
+
except ValueError as exc:
|
|
1221
|
+
issues.append(
|
|
1222
|
+
ConfigIssue(
|
|
1223
|
+
path=f"readbacks.{op_key}.ignore_readback_paths",
|
|
1224
|
+
message=f"Invalid path in ignore_readback_paths: {exc}",
|
|
1225
|
+
expected="valid dot/bracket path",
|
|
1226
|
+
actual=safe_diagnostic_value(ignored),
|
|
1227
|
+
)
|
|
1228
|
+
)
|
|
1229
|
+
continue
|
|
1230
|
+
|
|
1231
|
+
# Collision: ignored path cannot match, be a parent of, or be a child of any written field
|
|
1232
|
+
for w_parts in parsed_written_paths:
|
|
1233
|
+
if paths_collide(ig_parts, w_parts):
|
|
1234
|
+
w_str = ".".join(str(p) for p in w_parts)
|
|
1235
|
+
issues.append(
|
|
1236
|
+
ConfigIssue(
|
|
1237
|
+
path=f"readbacks.{op_key}.ignore_readback_paths",
|
|
1238
|
+
message=f"Dangerous ignore_readback_paths entry '{ignored}' collides with written field '{w_str}' in field_map",
|
|
1239
|
+
expected="only non-written volatile fields (e.g. 'updatedAt')",
|
|
1240
|
+
actual=f"written field or its parent/child path '{ignored}' is ignored",
|
|
1241
|
+
suggestion="Never ignore written probe fields or their parent/child paths during rollback verification",
|
|
1242
|
+
)
|
|
1243
|
+
)
|
|
1244
|
+
# Collision: ignored path cannot match, be a parent of, or be a child of any readback field
|
|
1245
|
+
for r_parts in parsed_readback_paths:
|
|
1246
|
+
if paths_collide(ig_parts, r_parts):
|
|
1247
|
+
r_str = ".".join(str(p) for p in r_parts)
|
|
1248
|
+
issues.append(
|
|
1249
|
+
ConfigIssue(
|
|
1250
|
+
path=f"readbacks.{op_key}.ignore_readback_paths",
|
|
1251
|
+
message=f"Dangerous ignore_readback_paths entry '{ignored}' collides with readback field '{r_str}' in field_map",
|
|
1252
|
+
expected="only non-written volatile fields (e.g. 'updatedAt')",
|
|
1253
|
+
actual=f"readback field or its parent/child path '{ignored}' is ignored",
|
|
1254
|
+
suggestion="Never ignore readback target fields or their parent/child paths during rollback verification",
|
|
1255
|
+
)
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
@classmethod
|
|
1259
|
+
def _validate_parameter_values_section(
|
|
1260
|
+
cls,
|
|
1261
|
+
parameter_values: Any,
|
|
1262
|
+
issues: List[ConfigIssue],
|
|
1263
|
+
summary: ValidationSummary,
|
|
1264
|
+
) -> None:
|
|
1265
|
+
if not isinstance(parameter_values, dict):
|
|
1266
|
+
issues.append(
|
|
1267
|
+
ConfigIssue(
|
|
1268
|
+
path="parameter_values",
|
|
1269
|
+
message="'parameter_values' must be an object indexed by 'METHOD /path'",
|
|
1270
|
+
expected="dict / JSON object",
|
|
1271
|
+
actual=safe_diagnostic_value(parameter_values),
|
|
1272
|
+
)
|
|
1273
|
+
)
|
|
1274
|
+
return
|
|
1275
|
+
|
|
1276
|
+
for ep_key, values in parameter_values.items():
|
|
1277
|
+
is_valid, method, path, canonical, err_msg = parse_operation_key(ep_key)
|
|
1278
|
+
if not is_valid:
|
|
1279
|
+
issues.append(
|
|
1280
|
+
ConfigIssue(
|
|
1281
|
+
path=f"parameter_values.{ep_key}",
|
|
1282
|
+
message=f"Invalid operation key in parameter_values: {err_msg}",
|
|
1283
|
+
expected="'METHOD /path' (e.g. 'GET /api/users/{id}')",
|
|
1284
|
+
actual=safe_diagnostic_value(ep_key),
|
|
1285
|
+
)
|
|
1286
|
+
)
|
|
1287
|
+
if not isinstance(values, dict):
|
|
1288
|
+
issues.append(
|
|
1289
|
+
ConfigIssue(
|
|
1290
|
+
path=f"parameter_values.{ep_key}",
|
|
1291
|
+
message=f"Parameter values for '{ep_key}' must be an object",
|
|
1292
|
+
expected="dict / JSON object",
|
|
1293
|
+
actual=safe_diagnostic_value(values),
|
|
1294
|
+
)
|
|
1295
|
+
)
|
|
1296
|
+
else:
|
|
1297
|
+
summary.parameter_endpoints += 1
|
|
1298
|
+
for k, v in values.items():
|
|
1299
|
+
if not isinstance(k, str) or not k.strip():
|
|
1300
|
+
issues.append(
|
|
1301
|
+
ConfigIssue(
|
|
1302
|
+
path=f"parameter_values.{ep_key}",
|
|
1303
|
+
message="Parameter names must be non-empty strings",
|
|
1304
|
+
expected="non-empty string key",
|
|
1305
|
+
actual=safe_diagnostic_value(k),
|
|
1306
|
+
)
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
@classmethod
|
|
1310
|
+
def _validate_bola_section(
|
|
1311
|
+
cls,
|
|
1312
|
+
bola: Any,
|
|
1313
|
+
issues: List[ConfigIssue],
|
|
1314
|
+
summary: ValidationSummary,
|
|
1315
|
+
) -> None:
|
|
1316
|
+
if not isinstance(bola, dict):
|
|
1317
|
+
issues.append(
|
|
1318
|
+
ConfigIssue(
|
|
1319
|
+
path="bola",
|
|
1320
|
+
message="'bola' section must be an object indexed by 'METHOD /path'",
|
|
1321
|
+
expected="dict / JSON object",
|
|
1322
|
+
actual=safe_diagnostic_value(bola),
|
|
1323
|
+
)
|
|
1324
|
+
)
|
|
1325
|
+
return
|
|
1326
|
+
|
|
1327
|
+
for ep_key, policy in bola.items():
|
|
1328
|
+
is_valid, method, path, canonical, err_msg = parse_operation_key(ep_key)
|
|
1329
|
+
if not is_valid:
|
|
1330
|
+
issues.append(
|
|
1331
|
+
ConfigIssue(
|
|
1332
|
+
path=f"bola.{ep_key}",
|
|
1333
|
+
message=f"Invalid operation key in bola section: {err_msg}",
|
|
1334
|
+
expected="'METHOD /path' (e.g. 'GET /records/{id}')",
|
|
1335
|
+
actual=safe_diagnostic_value(ep_key),
|
|
1336
|
+
)
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1339
|
+
if not isinstance(policy, dict):
|
|
1340
|
+
issues.append(
|
|
1341
|
+
ConfigIssue(
|
|
1342
|
+
path=f"bola.{ep_key}",
|
|
1343
|
+
message=f"BOLA policy for '{ep_key}' must be an object",
|
|
1344
|
+
expected="dict / JSON object",
|
|
1345
|
+
actual=safe_diagnostic_value(policy),
|
|
1346
|
+
)
|
|
1347
|
+
)
|
|
1348
|
+
continue
|
|
1349
|
+
|
|
1350
|
+
summary.bola_policies_count += 1
|
|
1351
|
+
for p_key in policy.keys():
|
|
1352
|
+
if p_key not in cls.KNOWN_BOLA_KEYS:
|
|
1353
|
+
closest = cls._find_closest_match(str(p_key), sorted(cls.KNOWN_BOLA_KEYS))
|
|
1354
|
+
suggestion = f"Did you mean '{closest}'?" if closest else None
|
|
1355
|
+
issues.append(
|
|
1356
|
+
ConfigIssue(
|
|
1357
|
+
path=f"bola.{ep_key}.{p_key}",
|
|
1358
|
+
message=f"Unrecognized BOLA policy setting '{p_key}'",
|
|
1359
|
+
expected=f"one of {sorted(cls.KNOWN_BOLA_KEYS)}",
|
|
1360
|
+
actual=safe_diagnostic_value(p_key),
|
|
1361
|
+
suggestion=suggestion,
|
|
1362
|
+
)
|
|
1363
|
+
)
|
|
1364
|
+
|
|
1365
|
+
if "expected_public" in policy and not isinstance(policy["expected_public"], bool):
|
|
1366
|
+
issues.append(
|
|
1367
|
+
ConfigIssue(
|
|
1368
|
+
path=f"bola.{ep_key}.expected_public",
|
|
1369
|
+
message="'expected_public' must be a boolean",
|
|
1370
|
+
expected="boolean",
|
|
1371
|
+
actual=safe_diagnostic_value(policy["expected_public"]),
|
|
1372
|
+
)
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
if "expected_visitor_access" in policy and policy["expected_visitor_access"] not in ("allow", "deny"):
|
|
1376
|
+
issues.append(
|
|
1377
|
+
ConfigIssue(
|
|
1378
|
+
path=f"bola.{ep_key}.expected_visitor_access",
|
|
1379
|
+
message="'expected_visitor_access' must explicitly be 'allow' or 'deny'",
|
|
1380
|
+
expected="allow or deny (defaults to deny)",
|
|
1381
|
+
actual=safe_diagnostic_value(policy["expected_visitor_access"]),
|
|
1382
|
+
)
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
if "resource_id_paths" in policy:
|
|
1386
|
+
paths = policy["resource_id_paths"]
|
|
1387
|
+
if not isinstance(paths, list) or any(not isinstance(p, str) for p in paths):
|
|
1388
|
+
issues.append(
|
|
1389
|
+
ConfigIssue(
|
|
1390
|
+
path=f"bola.{ep_key}.resource_id_paths",
|
|
1391
|
+
message="'resource_id_paths' must be a list of string field paths",
|
|
1392
|
+
expected="list of strings",
|
|
1393
|
+
actual=safe_diagnostic_value(paths),
|
|
1394
|
+
)
|
|
1395
|
+
)
|
|
1396
|
+
else:
|
|
1397
|
+
for p in paths:
|
|
1398
|
+
try:
|
|
1399
|
+
path_parts(p)
|
|
1400
|
+
except ValueError as exc:
|
|
1401
|
+
issues.append(
|
|
1402
|
+
ConfigIssue(
|
|
1403
|
+
path=f"bola.{ep_key}.resource_id_paths",
|
|
1404
|
+
message=f"Invalid path expression in resource_id_paths: {exc}",
|
|
1405
|
+
expected="valid dot/bracket path",
|
|
1406
|
+
actual=safe_diagnostic_value(p),
|
|
1407
|
+
)
|
|
1408
|
+
)
|