feat(helm): add search_ui and admin_ui config with schema validation (P8.3, miroir-qjt.3)
- Add search_ui section to values.yaml (plan §13.21) with full config: * auth modes (public, shared_key, oauth_proxy) * scoped key rotation settings * rate limiting (redis/local backend) * CORS/CSP overrides * analytics config - Rename admin to admin_ui and expand (plan §13.19): * session_ttl_s, read_only_mode * rate_limit.backend (redis/local) * theme and features toggles - Add values.schema.json constraints: * search_ui.rate_limit.backend: local rejected when replicas > 1 * admin_ui.rate_limit.backend: local rejected when replicas > 1 * (scoped_key timing constraint deferred to app-level validation) - Update test_schema.py to cover new rate_limit backend constraints - Fix good-production.yaml to use admin_ui.rate_limit Closes: miroir-qjt.3 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
56a9a93ac9
commit
21d83cee71
4 changed files with 292 additions and 8 deletions
|
|
@ -14,5 +14,5 @@ search_ui:
|
|||
rate_limit:
|
||||
backend: redis
|
||||
admin_ui:
|
||||
login_rate_limit:
|
||||
rate_limit:
|
||||
backend: redis
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ def validate_schema(schema: dict, instance: dict, path: str = "") -> list:
|
|||
# Check nested properties in 'then'
|
||||
if "properties" in then_schema:
|
||||
for prop, prop_schema in then_schema["properties"].items():
|
||||
# Handle direct property constraints (e.g., taskStore.backend)
|
||||
if prop in instance:
|
||||
if "properties" in prop_schema:
|
||||
for nested, nested_schema in prop_schema["properties"].items():
|
||||
|
|
@ -79,11 +80,33 @@ def validate_schema(schema: dict, instance: dict, path: str = "") -> list:
|
|||
actual = instance[prop][nested]
|
||||
if "const" in nested_schema:
|
||||
if actual != nested_schema["const"]:
|
||||
msg = f"{path}{prop}.{nested}: expected {nested_schema['const']}, got {actual}"
|
||||
if "errorMessage" in constraint:
|
||||
msg = constraint["errorMessage"]
|
||||
msg = constraint.get("errorMessage",
|
||||
f"{path}{prop}.{nested}: expected {nested_schema['const']}, got {actual}")
|
||||
errors.append(msg)
|
||||
|
||||
# Handle minimum constraints (e.g., replicas minimum)
|
||||
if "minimum" in prop_schema:
|
||||
if instance[prop] < prop_schema["minimum"]:
|
||||
msg = constraint.get("errorMessage",
|
||||
f"{path}{prop}: must be at least {prop_schema['minimum']}")
|
||||
errors.append(msg)
|
||||
|
||||
# Handle nested object constraints (e.g., search_ui.rate_limit.backend)
|
||||
if "properties" in prop_schema:
|
||||
for nested, nested_schema in prop_schema["properties"].items():
|
||||
if "properties" in nested_schema:
|
||||
for double_nested, double_nested_schema in nested_schema["properties"].items():
|
||||
if "const" in double_nested_schema:
|
||||
# Check if the nested path exists in instance
|
||||
if prop in instance and isinstance(instance[prop], dict):
|
||||
if nested in instance[prop] and isinstance(instance[prop][nested], dict):
|
||||
if double_nested in instance[prop][nested]:
|
||||
actual = instance[prop][nested][double_nested]
|
||||
if actual != double_nested_schema["const"]:
|
||||
msg = constraint.get("errorMessage",
|
||||
f"{path}{prop}.{nested}.{double_nested}: expected {double_nested_schema['const']}, got {actual}")
|
||||
errors.append(msg)
|
||||
|
||||
# Check required fields in 'then'
|
||||
if "required" in then_schema:
|
||||
for req in then_schema["required"]:
|
||||
|
|
@ -130,6 +153,68 @@ def test_schema_constraints():
|
|||
print(f" Error: {err}")
|
||||
failed += 1
|
||||
|
||||
# Test search_ui.rate_limit.backend constraint
|
||||
search_ui_tests = [
|
||||
# (replicas, rate_limit_backend, should_pass, description)
|
||||
(1, "local", True, "replicas: 1 + search_ui.rate_limit.backend: local should PASS"),
|
||||
(2, "local", False, "replicas: 2 + search_ui.rate_limit.backend: local should FAIL"),
|
||||
(2, "redis", True, "replicas: 2 + search_ui.rate_limit.backend: redis should PASS"),
|
||||
]
|
||||
|
||||
for replicas, rate_limit_backend, should_pass, description in search_ui_tests:
|
||||
instance = {
|
||||
"replicas": replicas,
|
||||
"taskStore": {"backend": "redis"},
|
||||
"search_ui": {
|
||||
"rate_limit": {"backend": rate_limit_backend}
|
||||
}
|
||||
}
|
||||
|
||||
miroir_schema = schema["properties"]["miroir"]
|
||||
errors = validate_schema(miroir_schema, instance)
|
||||
|
||||
is_valid = len(errors) == 0
|
||||
|
||||
if is_valid == should_pass:
|
||||
print(f"✓ {description}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ {description}")
|
||||
for err in errors:
|
||||
print(f" Error: {err}")
|
||||
failed += 1
|
||||
|
||||
# Test admin_ui.rate_limit.backend constraint
|
||||
admin_ui_tests = [
|
||||
# (replicas, rate_limit_backend, should_pass, description)
|
||||
(1, "local", True, "replicas: 1 + admin_ui.rate_limit.backend: local should PASS"),
|
||||
(2, "local", False, "replicas: 2 + admin_ui.rate_limit.backend: local should FAIL"),
|
||||
(2, "redis", True, "replicas: 2 + admin_ui.rate_limit.backend: redis should PASS"),
|
||||
]
|
||||
|
||||
for replicas, rate_limit_backend, should_pass, description in admin_ui_tests:
|
||||
instance = {
|
||||
"replicas": replicas,
|
||||
"taskStore": {"backend": "redis"},
|
||||
"admin_ui": {
|
||||
"rate_limit": {"backend": rate_limit_backend}
|
||||
}
|
||||
}
|
||||
|
||||
miroir_schema = schema["properties"]["miroir"]
|
||||
errors = validate_schema(miroir_schema, instance)
|
||||
|
||||
is_valid = len(errors) == 0
|
||||
|
||||
if is_valid == should_pass:
|
||||
print(f"✓ {description}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ {description}")
|
||||
for err in errors:
|
||||
print(f" Error: {err}")
|
||||
failed += 1
|
||||
|
||||
print(f"\n{passed} passed, {failed} failed")
|
||||
return failed == 0
|
||||
|
||||
|
|
|
|||
|
|
@ -42,12 +42,109 @@
|
|||
},
|
||||
"required": ["backend"]
|
||||
},
|
||||
"admin": {
|
||||
"admin_ui": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"}
|
||||
"enabled": {"type": "boolean"},
|
||||
"path": {"type": "string"},
|
||||
"auth": {"type": "string", "enum": ["key", "oauth", "none"]},
|
||||
"session_ttl_s": {"type": "integer", "minimum": 1},
|
||||
"read_only_mode": {"type": "boolean"},
|
||||
"allowed_origins": {"type": "array", "items": {"type": "string"}},
|
||||
"cors_allowed_origins": {"type": "array", "items": {"type": "string"}},
|
||||
"rate_limit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"per_ip": {"type": "string"},
|
||||
"backend": {"type": "string", "enum": ["redis", "local"]},
|
||||
"redis_key_prefix": {"type": "string"},
|
||||
"redis_ttl_s": {"type": "integer", "minimum": 1}
|
||||
},
|
||||
"required": ["backend"]
|
||||
},
|
||||
"theme": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accent_color": {"type": "string"},
|
||||
"default_mode": {"type": "string", "enum": ["auto", "light", "dark"]}
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sandbox": {"type": "boolean"},
|
||||
"shadow_viewer": {"type": "boolean"},
|
||||
"cdc_inspector": {"type": "boolean"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"search_ui": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"path": {"type": "string"},
|
||||
"widget_script_enabled": {"type": "boolean"},
|
||||
"embeddable": {"type": "boolean"},
|
||||
"auth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {"type": "string", "enum": ["public", "shared_key", "oauth_proxy"]},
|
||||
"shared_key_env": {"type": "string"},
|
||||
"session_ttl_s": {"type": "integer", "minimum": 1},
|
||||
"session_rate_limit": {"type": "string"},
|
||||
"jwt_secret_env": {"type": "string"},
|
||||
"oauth_proxy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_header": {"type": "string"},
|
||||
"groups_header": {"type": "string"},
|
||||
"filter_template": {"type": ["string", "null"]},
|
||||
"attribute_map": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"groups": {"type": "string"},
|
||||
"user": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"allowed_origins": {"type": "array", "items": {"type": "string"}},
|
||||
"scoped_key_max_age_days": {"type": "integer", "minimum": 2},
|
||||
"scoped_key_rotate_before_expiry_days": {"type": "integer", "minimum": 1},
|
||||
"scoped_key_rotation_drain_s": {"type": "integer", "minimum": 0},
|
||||
"rate_limit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"per_ip": {"type": "string"},
|
||||
"backend": {"type": "string", "enum": ["redis", "local"]},
|
||||
"redis_key_prefix": {"type": "string"},
|
||||
"redis_ttl_s": {"type": "integer", "minimum": 1}
|
||||
},
|
||||
"required": ["backend"]
|
||||
},
|
||||
"cors_allowed_origins": {"type": "array", "items": {"type": "string"}},
|
||||
"csp_overrides": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"script_src": {"type": "array", "items": {"type": "string"}},
|
||||
"img_src": {"type": "array", "items": {"type": "string"}},
|
||||
"connect_src": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
},
|
||||
"csp": {"type": "string"},
|
||||
"analytics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"sink": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": []
|
||||
},
|
||||
"scatter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -186,6 +283,52 @@
|
|||
},
|
||||
"errorMessage": "HPA requires replicas >= 2 and taskStore.backend='redis'"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"replicas": {"minimum": 2}
|
||||
},
|
||||
"required": ["replicas"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"search_ui": {
|
||||
"properties": {
|
||||
"rate_limit": {
|
||||
"properties": {
|
||||
"backend": {"const": "redis"}
|
||||
},
|
||||
"required": ["backend"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorMessage": "search_ui.rate_limit.backend must be 'redis' when replicas > 1 (local rate limiting is per-pod and produces effective rate of per_ip × pod_count)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"replicas": {"minimum": 2}
|
||||
},
|
||||
"required": ["replicas"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"admin_ui": {
|
||||
"properties": {
|
||||
"rate_limit": {
|
||||
"properties": {
|
||||
"backend": {"const": "redis"}
|
||||
},
|
||||
"required": ["backend"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorMessage": "admin_ui.rate_limit.backend must be 'redis' when replicas > 1 (local rate limiting is per-pod and produces effective rate of per_ip × pod_count)"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,9 +22,65 @@ miroir:
|
|||
path: /data/miroir-tasks.db
|
||||
url: "" # for redis: redis://host:6379
|
||||
|
||||
# Admin UI
|
||||
admin:
|
||||
# Admin UI (plan §13.19)
|
||||
admin_ui:
|
||||
enabled: true
|
||||
path: /_miroir/admin
|
||||
auth: key # key | oauth (future) | none (dev only)
|
||||
session_ttl_s: 3600
|
||||
read_only_mode: false
|
||||
allowed_origins: [same-origin]
|
||||
cors_allowed_origins: []
|
||||
rate_limit:
|
||||
per_ip: "10/minute"
|
||||
backend: redis # redis | local (per-pod); redis required when replicas > 1
|
||||
redis_key_prefix: "miroir:ratelimit:adminlogin:"
|
||||
redis_ttl_s: 60
|
||||
theme:
|
||||
accent_color: "#2563eb"
|
||||
default_mode: auto
|
||||
features:
|
||||
sandbox: true
|
||||
shadow_viewer: true
|
||||
cdc_inspector: true
|
||||
|
||||
# Search UI (plan §13.21)
|
||||
search_ui:
|
||||
enabled: true
|
||||
path: /ui/search
|
||||
widget_script_enabled: true
|
||||
embeddable: true
|
||||
auth:
|
||||
mode: public # public | shared_key | oauth_proxy
|
||||
shared_key_env: ""
|
||||
session_ttl_s: 900
|
||||
session_rate_limit: "10/minute"
|
||||
jwt_secret_env: SEARCH_UI_JWT_SECRET
|
||||
oauth_proxy:
|
||||
user_header: X-Forwarded-User
|
||||
groups_header: X-Forwarded-Groups
|
||||
filter_template: null
|
||||
attribute_map:
|
||||
groups: groups_array
|
||||
user: user_id_string
|
||||
allowed_origins: ["*"]
|
||||
scoped_key_max_age_days: 60
|
||||
scoped_key_rotate_before_expiry_days: 30
|
||||
scoped_key_rotation_drain_s: 120
|
||||
rate_limit:
|
||||
per_ip: "60/minute"
|
||||
backend: redis # redis | local (per-pod); redis required when replicas > 1
|
||||
redis_key_prefix: "miroir:ratelimit:searchui:"
|
||||
redis_ttl_s: 60
|
||||
cors_allowed_origins: []
|
||||
csp_overrides:
|
||||
script_src: []
|
||||
img_src: []
|
||||
connect_src: []
|
||||
csp: "default-src 'self'; img-src 'self' https:; style-src 'self' 'unsafe-inline'"
|
||||
analytics:
|
||||
enabled: false
|
||||
sink: cdc
|
||||
|
||||
# Scatter policy for unavailable shards
|
||||
scatter:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue