From c70188e9150f9631471e4dbd7c26ab677accec0b Mon Sep 17 00:00:00 2001 From: Patrick Hickey Date: Fri, 6 Nov 2026 23:27:57 -0700 Subject: [PATCH] feat(validation): capture scoped HTTP authorization Assisted-by: GPT-6.1 Terra --- README.md | 12 ++++ .../v1/validation-authorization.schema.json | 54 ++++++++++++++++- .../v1/validation-plan.schema.json | 30 +++++++++- enums/v1/validation-http-method.json | 7 +++ .../v1/validation-http-node-address-type.json | 7 +++ enums/v1/validation-http-probe-mode.json | 7 +++ enums/v1/validation-http-scheme.json | 7 +++ enums/v1/validation-http-target-resource.json | 7 +++ tests/test_validation_authorization.py | 57 ++++++++++++++++++ tests/test_validation_plan.py | 58 +++++++++++++++++++ 10 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 enums/v1/validation-http-method.json create mode 100644 enums/v1/validation-http-node-address-type.json create mode 100644 enums/v1/validation-http-probe-mode.json create mode 100644 enums/v1/validation-http-scheme.json create mode 100644 enums/v1/validation-http-target-resource.json diff --git a/README.md b/README.md index d832313..10ad213 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,11 @@ chains. Its allowed keys come from `Step.to_dict()`, `Chain.to_dict()` and `writ techniques, adapters, classifications and scope modes reuse the validation report's vocabularies. Unknown fields and invalid types fail, including agent-supplied approval or scope grants. +A step may contain a structured `target.http` probe with method, destination +selection, headers and authentication preconditions. Such a step cannot carry +command or rollback text. The shape does not grant endpoint access; the +executor checks the engagement and method before issuing the request. + This schema **does not authorize execution**. A caller must bind the approved artifact digest to a verified principal and current scope, classify each command against its actual operands, and re-check the target immediately before dispatch. Browser plans use a different envelope and @@ -227,6 +232,13 @@ precondition references and chain links also need service checks: JSON Schema va item, not relationships among items. An edited or recon-extended plan is a new artifact and needs a new authorization decision. +`validation-authorization.schema.json` also preserves the approved scope snapshot. +Its optional `expires_at` is a timezone-aware deadline; date-only `expires` retains +its existing meaning. The snapshot includes exact `http_targets` and each cluster's +API identity and HTTP discovery policy, including route domains, node networks, +credential permissions and TLS/session settings. These fields record grants; the +execution service enforces their expiry and scope. + ## Complete impact preparation snapshots `impact-analysis` metadata may name a `preparation_scope`: a content digest and diff --git a/artifacts_schema/v1/validation-authorization.schema.json b/artifacts_schema/v1/validation-authorization.schema.json index a7ae37c..58c2216 100644 --- a/artifacts_schema/v1/validation-authorization.schema.json +++ b/artifacts_schema/v1/validation-authorization.schema.json @@ -47,6 +47,7 @@ "authorized_by": {"type": ["string", "null"]}, "environment": {"type": ["string", "null"]}, "expires": {"oneOf": [{"type": "string", "format": "date"}, {"type": "null"}]}, + "expires_at": {"oneOf": [{"type": "string", "format": "date-time"}, {"type": "null"}]}, "modes": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { @@ -57,6 +58,7 @@ } }, "clusters": {"type": "array", "items": {"$ref": "#/$defs/cluster"}}, + "http_targets": {"type": "array", "items": {"$ref": "#/$defs/http_target"}}, "off_limits": {"type": "array", "items": {"$ref": "#/$defs/off_limit"}}, "browser": {"type": "array", "items": {"$ref": "#/$defs/browser"}}, "containers": {"$ref": "#/$defs/names"}, @@ -75,7 +77,57 @@ "context": {"type": "string", "minLength": 1}, "namespaces": {"$ref": "#/$defs/names"}, "explicit_namespaces": {"$ref": "#/$defs/names"}, - "verbs_denied": {"$ref": "#/$defs/names"} + "verbs_denied": {"$ref": "#/$defs/names"}, + "api": {"type": ["string", "null"]}, + "http_discovery": {"oneOf": [{"$ref": "#/$defs/http_discovery"}, {"type": "null"}]} + } + }, + "http_target": { + "type": "object", + "required": ["host", "context"], + "additionalProperties": false, + "properties": { + "host": {"type": "string", "minLength": 1}, + "context": {"type": "string", "minLength": 1}, + "namespace": {"type": ["string", "null"]}, + "resource": {"enum": ["routes", "nodes", "http_targets", null]}, + "name": {"type": ["string", "null"]}, + "credentials": {"type": "boolean"} + } + }, + "http_discovery": { + "type": "object", + "additionalProperties": false, + "properties": { + "routes": {"type": "array", "items": {"$ref": "#/$defs/http_route_grant"}}, + "nodes": {"oneOf": [{"$ref": "#/$defs/http_node_grant"}, {"type": "null"}]}, + "port_forward_credentials": {"type": "boolean"}, + "kube_env": {"type": "array", "items": {"type": "string"}}, + "ca_bundle": {"type": ["string", "null"]}, + "insecure_tls": {"type": "boolean"}, + "session_check_path": {"type": ["string", "null"]}, + "session_check_contains": {"type": ["string", "null"]} + } + }, + "http_route_grant": { + "type": "object", + "required": ["namespace"], + "additionalProperties": false, + "properties": { + "namespace": {"type": "string", "minLength": 1}, + "names": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "domains": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "credentials": {"type": "boolean"} + } + }, + "http_node_grant": { + "type": "object", + "additionalProperties": false, + "properties": { + "names": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "networks": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "address_types": {"type": "array", "items": {"enum": ["InternalIP", "ExternalIP"]}}, + "credentials": {"type": "boolean"} } }, "off_limit": { diff --git a/artifacts_schema/v1/validation-plan.schema.json b/artifacts_schema/v1/validation-plan.schema.json index 8349024..1106094 100644 --- a/artifacts_schema/v1/validation-plan.schema.json +++ b/artifacts_schema/v1/validation-plan.schema.json @@ -51,7 +51,13 @@ "skip": {"type": "string", "minLength": 1}, "summary": {"type": "string"}, "triage_was_needs_review": {"type": "boolean"} - } + }, + "allOf": [ + { + "if": {"properties": {"target": {"required": ["http"]}}, "required": ["target"]}, + "then": {"properties": {"cmd": {"maxLength": 0}, "rollback": {"maxLength": 0}}} + } + ] }, "step_target": { "type": "object", @@ -67,7 +73,27 @@ "method": {"type": "string"}, "path": {"type": "string"}, "port": {"type": "integer", "minimum": 1, "maximum": 65535}, - "pqc": {"type": "boolean"} + "pqc": {"type": "boolean"}, + "http": {"$ref": "#/$defs/http_probe"} + } + }, + "http_probe": { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": {"enum": ["route", "service", "node", "direct"]}, + "method": {"enum": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]}, + "path": {"type": "string"}, + "port": {"type": ["integer", "null"], "minimum": 1, "maximum": 65535}, + "scheme": {"enum": ["auto", "http", "https"]}, + "hint": {"type": "string"}, + "namespaces": {"type": "array", "items": {"type": "string"}}, + "name": {"type": ["string", "null"]}, + "url": {"type": ["string", "null"]}, + "headers": {"type": "object", "additionalProperties": {"type": "string"}}, + "authenticate": {"type": "boolean"}, + "csrf": {"type": "boolean"}, + "service_account": {"type": ["string", "null"]} } } } diff --git a/enums/v1/validation-http-method.json b/enums/v1/validation-http-method.json new file mode 100644 index 0000000..654cb16 --- /dev/null +++ b/enums/v1/validation-http-method.json @@ -0,0 +1,7 @@ +{ + "name": "validation_http_method", + "source_schema": "validation-plan.schema.json#/$defs/http_probe/properties/method", + "description": "HTTP methods supported by structured validation probes.", + "values": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + "used_in_schemas": ["validation-plan.schema.json"] +} diff --git a/enums/v1/validation-http-node-address-type.json b/enums/v1/validation-http-node-address-type.json new file mode 100644 index 0000000..ecc37b8 --- /dev/null +++ b/enums/v1/validation-http-node-address-type.json @@ -0,0 +1,7 @@ +{ + "name": "validation_http_node_address_type", + "source_schema": "validation-authorization.schema.json#/$defs/http_node_grant/properties/address_types/items", + "description": "Kubernetes node address types eligible for HTTP discovery.", + "values": ["InternalIP", "ExternalIP"], + "used_in_schemas": ["validation-authorization.schema.json"] +} diff --git a/enums/v1/validation-http-probe-mode.json b/enums/v1/validation-http-probe-mode.json new file mode 100644 index 0000000..b6525dc --- /dev/null +++ b/enums/v1/validation-http-probe-mode.json @@ -0,0 +1,7 @@ +{ + "name": "validation_http_probe_mode", + "source_schema": "validation-plan.schema.json#/$defs/http_probe/properties/mode", + "description": "How a structured HTTP validation probe selects its endpoint.", + "values": ["route", "service", "node", "direct"], + "used_in_schemas": ["validation-plan.schema.json"] +} diff --git a/enums/v1/validation-http-scheme.json b/enums/v1/validation-http-scheme.json new file mode 100644 index 0000000..93d5419 --- /dev/null +++ b/enums/v1/validation-http-scheme.json @@ -0,0 +1,7 @@ +{ + "name": "validation_http_scheme", + "source_schema": "validation-plan.schema.json#/$defs/http_probe/properties/scheme", + "description": "Transport choices for structured HTTP validation probes.", + "values": ["auto", "http", "https"], + "used_in_schemas": ["validation-plan.schema.json"] +} diff --git a/enums/v1/validation-http-target-resource.json b/enums/v1/validation-http-target-resource.json new file mode 100644 index 0000000..fcf9912 --- /dev/null +++ b/enums/v1/validation-http-target-resource.json @@ -0,0 +1,7 @@ +{ + "name": "validation_http_target_resource", + "source_schema": "validation-authorization.schema.json#/$defs/http_target/properties/resource", + "description": "Resource kinds for an explicit HTTP validation target.", + "values": ["routes", "nodes", "http_targets"], + "used_in_schemas": ["validation-authorization.schema.json"] +} diff --git a/tests/test_validation_authorization.py b/tests/test_validation_authorization.py index 9b31da2..e6f8e06 100644 --- a/tests/test_validation_authorization.py +++ b/tests/test_validation_authorization.py @@ -67,6 +67,59 @@ def test_authorization_pins_plan_and_complete_scope(validator: Draft202012Valida validator.validate(inline) +@pytest.mark.parametrize( + "nodes", + [ + None, + { + "names": ["worker-0"], + "networks": ["10.0.0.0/24"], + "address_types": ["InternalIP"], + "credentials": False, + }, + ], +) +def test_authorization_preserves_complete_http_grants_and_expiry( + validator: Draft202012Validator, nodes: dict[str, Any] | None +) -> None: + approval = deepcopy(APPROVAL) + scope = approval["scope"] + scope["expires_at"] = "2026-12-31T16:30:00Z" + scope["http_targets"] = [ + { + "host": "console.example", + "context": "example-lab", + "namespace": "example-a", + "resource": "routes", + "name": "console", + "credentials": True, + } + ] + cluster = scope["clusters"][0] + cluster["api"] = "https://api.example:6443" + cluster["http_discovery"] = { + "routes": [ + { + "namespace": "example-a", + "names": ["console"], + "domains": ["apps.example"], + "credentials": True, + } + ], + "nodes": nodes, + "port_forward_credentials": False, + "kube_env": [], + "ca_bundle": None, + "insecure_tls": False, + "session_check_path": None, + "session_check_contains": None, + } + validator.validate(approval) + scope["expires_at"] = None + cluster["http_discovery"] = None + validator.validate(approval) + + @pytest.mark.parametrize( ("path", "value"), [ @@ -84,6 +137,10 @@ def test_authorization_pins_plan_and_complete_scope(validator: Draft202012Valida (("scope", "clusters", 0, "unsafe"), True), (("scope", "off_limits", 0, "unsafe"), True), (("scope", "expires"), "tomorrow"), + (("scope", "expires_at"), "2026-12-31T16:30:00"), + (("scope", "http_targets"), [{"context": "example-lab"}]), + (("scope", "clusters", 0, "http_discovery"), {"nodes": {"address_types": ["DNS"]}}), + (("scope", "clusters", 0, "api"), 1), (("scope", "wasm_artifact_ids"), ["file:///var/example.wasm"]), ], ) diff --git a/tests/test_validation_plan.py b/tests/test_validation_plan.py index 6c60785..7b77c59 100644 --- a/tests/test_validation_plan.py +++ b/tests/test_validation_plan.py @@ -78,6 +78,64 @@ def test_validate_findings_plan_shape(validator: Draft202012Validator) -> None: validator.validate({**PLAN, "steps": [recon]}) +def http_plan() -> dict[str, Any]: + plan = deepcopy(PLAN) + step = plan["steps"][0] + step.pop("payload") + step.pop("rollback") + step["verb"] = "port-forward+http" + step["target"] = { + "context": "authorized-lab", + "namespace": "example-a", + "resource": "routes", + "method": "GET", + "path": "/healthz", + "http": { + "mode": "route", + "method": "GET", + "path": "/healthz", + "port": None, + "scheme": "auto", + "hint": "console", + "namespaces": ["example-a"], + "name": None, + "url": None, + "headers": {}, + "authenticate": False, + "csrf": False, + "service_account": None, + }, + } + return plan + + +def test_structured_http_probe_is_a_valid_plan(validator: Draft202012Validator) -> None: + validator.validate(http_plan()) + + +@pytest.mark.parametrize("field", ["cmd", "rollback"]) +def test_structured_http_probe_rejects_command_text( + validator: Draft202012Validator, field: str +) -> None: + plan = http_plan() + plan["steps"][0][field] = "curl https://example.invalid" + with pytest.raises(ValidationError): + validator.validate(plan) + + +@pytest.mark.parametrize( + ("field", "value"), + [("method", "CONNECT"), ("port", 0), ("unexpected", True), ("headers", {"X": 1})], +) +def test_structured_http_probe_rejects_invalid_fields( + validator: Draft202012Validator, field: str, value: object +) -> None: + plan = http_plan() + plan["steps"][0]["target"]["http"][field] = value + with pytest.raises(ValidationError): + validator.validate(plan) + + @pytest.mark.parametrize( ("path", "value"), [