diff --git a/charts/miroir/tests/good-production.yaml b/charts/miroir/tests/good-production.yaml index f88137a..96f9183 100644 --- a/charts/miroir/tests/good-production.yaml +++ b/charts/miroir/tests/good-production.yaml @@ -14,5 +14,5 @@ search_ui: rate_limit: backend: redis admin_ui: - login_rate_limit: + rate_limit: backend: redis diff --git a/charts/miroir/tests/test_schema.py b/charts/miroir/tests/test_schema.py index 590899b..50b4cd2 100755 --- a/charts/miroir/tests/test_schema.py +++ b/charts/miroir/tests/test_schema.py @@ -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 diff --git a/charts/miroir/values.schema.json b/charts/miroir/values.schema.json index 696179e..70d2af6 100644 --- a/charts/miroir/values.schema.json +++ b/charts/miroir/values.schema.json @@ -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)" + } } ] }, diff --git a/charts/miroir/values.yaml b/charts/miroir/values.yaml index bacc438..0ed11ec 100644 --- a/charts/miroir/values.yaml +++ b/charts/miroir/values.yaml @@ -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: