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:
jedarden 2026-05-24 07:36:37 -04:00
parent 56a9a93ac9
commit 21d83cee71
4 changed files with 292 additions and 8 deletions

View file

@ -14,5 +14,5 @@ search_ui:
rate_limit:
backend: redis
admin_ui:
login_rate_limit:
rate_limit:
backend: redis

View file

@ -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

View file

@ -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)"
}
}
]
},

View file

@ -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: