diff --git a/CHANGELOG.md b/CHANGELOG.md index 03dad1d1a..246ecb109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ All notable changes to Orgmetra will be documented in this file. ### Changed - Repository database validation now treats PostgreSQL's provided `public` schema as an external boundary and keeps the exact product-composition control-plane table set outside HR tenant/RLS rules, while continuing to enforce two-word lowercase `snake_case` names on Orgmetra-owned tables and custom schemas. +- Product-composition request-routing fixtures now carry the required schema version so declaration, availability, and currentness behavior executes under the canonical generation contract. - Product-composition activation/currentness fixtures now carry the required schema version, migration tests validate semantic ordering after explanatory comments, and mutated nested deployment identities fail through the authorization boundary instead of leaking registry errors. - Product-composition generation-registry fixtures now supply the required schema version, and migration assertions follow schema-qualified durable object names without weakening append-only or truncate guards. - Consolidated repository-owned PR validation from twelve workflows into one Foundation CI job, while keeping the dual-cluster recovery rehearsal separately path-scoped. Central required review and security workflows remain organization-owned. diff --git a/manifest.json b/manifest.json index 672162a97..ce33384c0 100644 --- a/manifest.json +++ b/manifest.json @@ -29,9 +29,9 @@ }, { "path": "CHANGELOG.md", - "sha256": "90ae7f8b411a35c0ffabc228122fd31e1a41c88c8e67e238422038af936d1cb0", - "bytes": 18341, - "lines": 80 + "sha256": "9cfb04550f80cbe3dcdee76cc90061641df4325353a3a5ddfc088285b3991024", + "bytes": 18531, + "lines": 81 }, { "path": "CLAUDE.md", diff --git a/services/product-composition-api/src/orgmetra_product_composition/__init__.py b/services/product-composition-api/src/orgmetra_product_composition/__init__.py index 9d7f5887b..cdc9a07dc 100644 --- a/services/product-composition-api/src/orgmetra_product_composition/__init__.py +++ b/services/product-composition-api/src/orgmetra_product_composition/__init__.py @@ -34,6 +34,14 @@ RouteMethodRecord, RouteRecord, ) +from .request_routing import ( + CompositionMethodNotAllowedError, + CompositionRequestError, + CompositionRouteNotFoundError, + CompositionRouteUnavailableError, + CompositionRoutingError, + current_route_id_for_request, +) from .serving_snapshot import ( RecoveredRouteSnapshot, current_route_ids_for_snapshot, @@ -53,8 +61,13 @@ "AuthorizedRecoveredActivation", "CompositionContractError", "CompositionGeneration", + "CompositionMethodNotAllowedError", "CompositionRegistryError", + "CompositionRequestError", "CompositionRoute", + "CompositionRouteNotFoundError", + "CompositionRouteUnavailableError", + "CompositionRoutingError", "DeploymentIdentity", "GenerationRecordSet", "OwnerApiRelease", @@ -68,6 +81,7 @@ "RouteRecord", "admit_generation", "configuration_sha256", + "current_route_id_for_request", "current_route_ids_for_snapshot", "recover_active_route_snapshot", ] diff --git a/services/product-composition-api/src/orgmetra_product_composition/request_routing.py b/services/product-composition-api/src/orgmetra_product_composition/request_routing.py new file mode 100644 index 000000000..7e833efce --- /dev/null +++ b/services/product-composition-api/src/orgmetra_product_composition/request_routing.py @@ -0,0 +1,143 @@ +"""Resolve one canonical request against current durable composition authority. + +The resolver deliberately separates declared route selection from route availability. A concrete +Path Item declared by the active generation wins before availability is considered, so an +unavailable optional concrete route cannot fall through to a broader available template route. +The function returns only the stable route identifier; owner transport and HTTP response mapping +remain later serving responsibilities. +""" + +from __future__ import annotations + +import re + +from .activation import DeploymentIdentity +from .activation_runtime_integrity import AuthorizedPostgresActivationRegistry +from .admission import CompositionGeneration, CompositionRoute +from .serving_snapshot import RecoveredRouteSnapshot, current_route_ids_for_snapshot + +_REQUEST_METHOD = re.compile(r"^[A-Z]{1,16}$") +_REQUEST_PATH = re.compile(r"^/v[0-9]+(?:/[A-Za-z0-9._:-]+)+$") +_TEMPLATE_SEGMENT = re.compile(r"^\{[a-z][a-z0-9_]{0,63}\}$") + + +class CompositionRoutingError(RuntimeError): + """Base error for fail-closed product-composition request selection.""" + + +class CompositionRequestError(CompositionRoutingError): + """Raised when transport input is not in the canonical request-routing profile.""" + + +class CompositionRouteNotFoundError(CompositionRoutingError): + """Raised when the active generation declares no Path Item for the request path.""" + + +class CompositionMethodNotAllowedError(CompositionRoutingError): + """Raised when the selected declared Path Item does not admit the request method.""" + + +class CompositionRouteUnavailableError(CompositionRoutingError): + """Raised when the selected declared route lacks current positive availability evidence.""" + + +def _canonical_request_method(method: object) -> str: + """Require one exact uppercase HTTP method token without inventing owner operations.""" + + if type(method) is not str or _REQUEST_METHOD.fullmatch(method) is None: + raise CompositionRequestError("request method must be an exact uppercase HTTP token") + return method + + +def _canonical_request_path(request_path: object) -> str: + """Require the decoded canonical product path profile before any durable-state read.""" + + if type(request_path) is not str or not request_path or len(request_path) > 2048: + raise CompositionRequestError("request path must be an exact bounded str") + if _REQUEST_PATH.fullmatch(request_path) is None: + raise CompositionRequestError("request path is outside the canonical product path profile") + if any(segment in {".", ".."} for segment in request_path.split("/")): + raise CompositionRequestError("request path must not contain dot segments") + return request_path + + +def _route_matches_request_path(route: CompositionRoute, request_path: str) -> bool: + """Match one admitted template against one already-canonical decoded request path.""" + + route_segments = route.path_template.strip("/").split("/") + request_segments = request_path.strip("/").split("/") + if len(route_segments) != len(request_segments): + return False + return all( + route_segment == request_segment or _TEMPLATE_SEGMENT.fullmatch(route_segment) is not None + for route_segment, request_segment in zip(route_segments, request_segments, strict=True) + ) + + +def _selected_path_routes( + generation: CompositionGeneration, + request_path: str, +) -> tuple[CompositionRoute, ...]: + """Select the declared Path Item before availability or operation selection is considered.""" + + exact_routes = tuple( + route for route in generation.routes if route.path_template == request_path + ) + if exact_routes: + return exact_routes + + template_routes = tuple( + route + for route in generation.routes + if _route_matches_request_path(route, request_path) + ) + if template_routes: + return template_routes + raise CompositionRouteNotFoundError("active generation declares no route for request path") + + +def current_route_id_for_request( + registry: AuthorizedPostgresActivationRegistry, + deployment: DeploymentIdentity, + snapshot: RecoveredRouteSnapshot, + *, + method: str, + request_path: str, +) -> str: + """Return the current stable route ID for one canonical request or fail closed. + + Request syntax and declared path/method authority are resolved before PostgreSQL use. Only a + request that selects one admitted route crosses ``current_route_ids_for_snapshot`` for durable + activation/recovery currentness. Selection runs over the complete declared generation first, + applies concrete-before-template precedence, chooses only an explicitly declared method, and + checks positive route availability last. This ordering prevents both unnecessary durable-state + reads for unroutable requests and an unavailable optional concrete Path Item from widening into + a template route. + """ + + canonical_method = _canonical_request_method(method) + canonical_path = _canonical_request_path(request_path) + generation = snapshot.generation + path_routes = _selected_path_routes(generation, canonical_path) + method_routes = tuple( + route for route in path_routes if canonical_method in route.methods + ) + if not method_routes: + raise CompositionMethodNotAllowedError( + "selected declared Path Item does not admit request method" + ) + if len(method_routes) != 1: + raise CompositionRoutingError( + "active generation exposes ambiguous method authority after admission" + ) + + selected_route = method_routes[0] + selected_route_id = selected_route.route_id + available_route_ids = frozenset( + current_route_ids_for_snapshot(registry, deployment, snapshot) + ) + if selected_route_id not in available_route_ids: + raise CompositionRouteUnavailableError( + "selected declared route lacks current positive availability evidence" + ) + return selected_route_id diff --git a/services/product-composition-api/tests/test_activation_owned_production_docstrings.py b/services/product-composition-api/tests/test_activation_owned_production_docstrings.py index bf7045fb0..24d850e24 100644 --- a/services/product-composition-api/tests/test_activation_owned_production_docstrings.py +++ b/services/product-composition-api/tests/test_activation_owned_production_docstrings.py @@ -8,6 +8,7 @@ "activation.py", "activation_authorization.py", "activation_runtime_integrity.py", + "request_routing.py", "route_availability.py", "serving_snapshot.py", ) diff --git a/services/product-composition-api/tests/test_request_routing_currentness.py b/services/product-composition-api/tests/test_request_routing_currentness.py new file mode 100644 index 000000000..b2fb5f456 --- /dev/null +++ b/services/product-composition-api/tests/test_request_routing_currentness.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +from contextlib import contextmanager + +import pytest + +from orgmetra_product_composition import ( + ActivationAdmissionEvidence, + ActivationEvent, + AuthorizedPostgresActivationRegistry, + AuthorizedRecoveredActivation, + CompositionGeneration, + CompositionMethodNotAllowedError, + CompositionRequestError, + CompositionRoute, + CompositionRouteNotFoundError, + CompositionRouteUnavailableError, + DeploymentIdentity, + OwnerApiRelease, + OwnerOperationObservation, + ReleasedAuthorityEvidence, + configuration_sha256, + current_route_id_for_request, +) +from orgmetra_product_composition.serving_snapshot import _issue_route_snapshot + + +class _Cursor: + def __init__(self, row: tuple[object, ...]) -> None: + self.row = row + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, sql: str, parameters: tuple[object, ...]) -> None: + assert "product_composition_activation_event" in sql + assert "product_composition_recovery_attestation" in sql + assert len(parameters) == 4 + + def fetchone(self): + return self.row + + +class _Connection: + def __init__(self, cursor: _Cursor) -> None: + self._cursor = cursor + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def cursor(self): + return self._cursor + + +def _authority(authority_id: str, digit: str) -> ReleasedAuthorityEvidence: + repository = "keyverse" if authority_id == "keyverse" else "Orgmetra" + return ReleasedAuthorityEvidence( + authority_id=authority_id, + release_version="v1.0.0", + artifact_sha256=digit * 64, + release_locator=( + f"https://github.com/ContextualWisdomLab/{repository}/releases/tag/v1.0.0" + ), + ) + + +def _fixture(*, concrete_available: bool): + owner = OwnerApiRelease( + service_id="people_api", + release_version="v1.2.3", + openapi_sha256="1" * 64, + artifact_sha256="2" * 64, + release_locator="https://github.com/ContextualWisdomLab/Orgmetra/releases/tag/v1.2.3", + ) + template = CompositionRoute( + route_id="people_record", + path_template="/v1/people/{person_record_id}", + methods=("GET",), + owner_release=owner, + logical_upstream="service://people-api", + required=True, + ) + concrete = CompositionRoute( + route_id="people_current", + path_template="/v1/people/current", + methods=("GET",), + owner_release=owner, + logical_upstream="service://people-api", + required=False, + ) + generation = CompositionGeneration( + schema_version="orgmetra_gateway_composition.v1", + generation_id="generation_request_routing", + routes=(template, concrete), + config_sha256=configuration_sha256((template, concrete)), + ) + deployment = DeploymentIdentity("orgmetra_gateway", "production") + + observations = [ + OwnerOperationObservation( + route_id=template.route_id, + path_template=template.path_template, + method="GET", + service_id=owner.service_id, + release_version=owner.release_version, + openapi_sha256=owner.openapi_sha256, + artifact_sha256=owner.artifact_sha256, + observation_sha256="8" * 64, + observed_at_unix_ms=1_000, + valid_until_unix_ms=2_000, + ) + ] + if concrete_available: + observations.append( + OwnerOperationObservation( + route_id=concrete.route_id, + path_template=concrete.path_template, + method="GET", + service_id=owner.service_id, + release_version=owner.release_version, + openapi_sha256=owner.openapi_sha256, + artifact_sha256="2" * 64, + observation_sha256="9" * 64, + observed_at_unix_ms=1_000, + valid_until_unix_ms=2_000, + ) + ) + + evidence = ActivationAdmissionEvidence( + deployment_id=deployment.deployment_id, + environment_id=deployment.environment_id, + generation_id=generation.generation_id, + config_sha256=generation.config_sha256, + authorization_action="recover", + authorized_state_sequence=1, + keyverse_authority=_authority("keyverse", "5"), + orgmetra_authority=_authority("orgmetra", "6"), + orgmetra_policy_version_code="composition_activation_v1", + authorization_decision_sha256="7" * 64, + owner_operations=tuple(observations), + valid_until_unix_ms=2_000, + ) + event = ActivationEvent( + deployment=deployment, + activation_sequence=1, + generation_id=generation.generation_id, + previous_generation_id=None, + event_kind="activate", + evidence_bundle_sha256="a" * 64, + ) + snapshot = _issue_route_snapshot( + AuthorizedRecoveredActivation( + event=event, + generation=generation, + evidence=evidence, + recovery_sequence=1, + ) + ) + row = ( + event.activation_sequence, + event.generation_id, + event.previous_generation_id, + event.event_kind, + event.evidence_bundle_sha256, + snapshot.recovery_sequence, + evidence.bundle_sha256(), + snapshot.recovery_sequence, + 1_250, + 1_500, + False, + ) + cursor = _Cursor(row) + + @contextmanager + def connection_factory(): + yield _Connection(cursor) + + def evidence_provider(*args): + raise AssertionError("request routing must not reacquire external evidence") + + def clock_unix_ms() -> int: + raise AssertionError("request routing must use serving PostgreSQL currentness") + + registry = AuthorizedPostgresActivationRegistry( + connection_factory=connection_factory, + evidence_provider=evidence_provider, + clock_unix_ms=clock_unix_ms, + ) + return registry, deployment, snapshot + + +def test_unavailable_concrete_route_does_not_fall_through_to_available_template() -> None: + registry, deployment, snapshot = _fixture(concrete_available=False) + + with pytest.raises(CompositionRouteUnavailableError): + current_route_id_for_request( + registry, + deployment, + snapshot, + method="GET", + request_path="/v1/people/current", + ) + + +def test_available_concrete_route_wins_over_template() -> None: + registry, deployment, snapshot = _fixture(concrete_available=True) + + assert current_route_id_for_request( + registry, + deployment, + snapshot, + method="GET", + request_path="/v1/people/current", + ) == "people_current" + + +def test_template_route_matches_one_canonical_path_segment() -> None: + registry, deployment, snapshot = _fixture(concrete_available=False) + + assert current_route_id_for_request( + registry, + deployment, + snapshot, + method="GET", + request_path="/v1/people/person_123", + ) == "people_record" + + +def test_method_mismatch_does_not_fall_through_or_invent_owner_method() -> None: + registry, deployment, snapshot = _fixture(concrete_available=True) + + with pytest.raises(CompositionMethodNotAllowedError): + current_route_id_for_request( + registry, + deployment, + snapshot, + method="POST", + request_path="/v1/people/current", + ) + + +def test_unknown_path_is_distinct_from_unavailable_declared_route() -> None: + registry, deployment, snapshot = _fixture(concrete_available=False) + + with pytest.raises(CompositionRouteNotFoundError): + current_route_id_for_request( + registry, + deployment, + snapshot, + method="GET", + request_path="/v1/jobs/job_123", + ) + + +@pytest.mark.parametrize( + ("method", "request_path"), + [ + ("get", "/v1/people/person_123"), + ("GET", "/v1/people/../person_123"), + ("GET", "/v1/people/person%2F123"), + ("GET", "/v1/people/person_123?expand=job"), + ], +) +def test_request_routing_rejects_noncanonical_transport_inputs(method: str, request_path: str) -> None: + registry, deployment, snapshot = _fixture(concrete_available=False) + + with pytest.raises(CompositionRequestError): + current_route_id_for_request( + registry, + deployment, + snapshot, + method=method, + request_path=request_path, + ) diff --git a/services/product-composition-api/tests/test_request_routing_preselection_boundary.py b/services/product-composition-api/tests/test_request_routing_preselection_boundary.py new file mode 100644 index 000000000..85963d53b --- /dev/null +++ b/services/product-composition-api/tests/test_request_routing_preselection_boundary.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import pytest + +from orgmetra_product_composition import ( + ActivationAdmissionEvidence, + ActivationEvent, + AuthorizedPostgresActivationRegistry, + AuthorizedRecoveredActivation, + CompositionGeneration, + CompositionMethodNotAllowedError, + CompositionRoute, + CompositionRouteNotFoundError, + DeploymentIdentity, + OwnerApiRelease, + OwnerOperationObservation, + ReleasedAuthorityEvidence, + configuration_sha256, + current_route_id_for_request, +) +from orgmetra_product_composition.serving_snapshot import _issue_route_snapshot + + +def _authority(authority_id: str, digit: str) -> ReleasedAuthorityEvidence: + repository = "keyverse" if authority_id == "keyverse" else "Orgmetra" + return ReleasedAuthorityEvidence( + authority_id=authority_id, + release_version="v1.0.0", + artifact_sha256=digit * 64, + release_locator=( + f"https://github.com/ContextualWisdomLab/{repository}/releases/tag/v1.0.0" + ), + ) + + +def _fixture(): + owner = OwnerApiRelease( + service_id="people_api", + release_version="v1.2.3", + openapi_sha256="1" * 64, + artifact_sha256="2" * 64, + release_locator="https://github.com/ContextualWisdomLab/Orgmetra/releases/tag/v1.2.3", + ) + route = CompositionRoute( + route_id="people_record", + path_template="/v1/people/{person_record_id}", + methods=("GET",), + owner_release=owner, + logical_upstream="service://people-api", + required=True, + ) + generation = CompositionGeneration( + schema_version="orgmetra_gateway_composition.v1", + generation_id="generation_request_preselection", + routes=(route,), + config_sha256=configuration_sha256((route,)), + ) + deployment = DeploymentIdentity("orgmetra_gateway", "production") + evidence = ActivationAdmissionEvidence( + deployment_id=deployment.deployment_id, + environment_id=deployment.environment_id, + generation_id=generation.generation_id, + config_sha256=generation.config_sha256, + authorization_action="recover", + authorized_state_sequence=1, + keyverse_authority=_authority("keyverse", "5"), + orgmetra_authority=_authority("orgmetra", "6"), + orgmetra_policy_version_code="composition_activation_v1", + authorization_decision_sha256="7" * 64, + owner_operations=( + OwnerOperationObservation( + route_id=route.route_id, + path_template=route.path_template, + method="GET", + service_id=owner.service_id, + release_version=owner.release_version, + openapi_sha256=owner.openapi_sha256, + artifact_sha256=owner.artifact_sha256, + observation_sha256="8" * 64, + observed_at_unix_ms=1_000, + valid_until_unix_ms=2_000, + ), + ), + valid_until_unix_ms=2_000, + ) + event = ActivationEvent( + deployment=deployment, + activation_sequence=1, + generation_id=generation.generation_id, + previous_generation_id=None, + event_kind="activate", + evidence_bundle_sha256="a" * 64, + ) + snapshot = _issue_route_snapshot( + AuthorizedRecoveredActivation( + event=event, + generation=generation, + evidence=evidence, + recovery_sequence=1, + ) + ) + + def connection_factory(): + raise AssertionError( + "declared path/method rejection must not cross the PostgreSQL currentness boundary" + ) + + def evidence_provider(*args): + raise AssertionError("request routing must not reacquire external evidence") + + def clock_unix_ms() -> int: + raise AssertionError("request routing must not use a process-local clock") + + registry = AuthorizedPostgresActivationRegistry( + connection_factory=connection_factory, + evidence_provider=evidence_provider, + clock_unix_ms=clock_unix_ms, + ) + return registry, deployment, snapshot + + +def test_unknown_declared_path_is_rejected_before_postgres_currentness() -> None: + registry, deployment, snapshot = _fixture() + + with pytest.raises(CompositionRouteNotFoundError): + current_route_id_for_request( + registry, + deployment, + snapshot, + method="GET", + request_path="/v1/jobs/job_123", + ) + + +def test_undeclared_method_is_rejected_before_postgres_currentness() -> None: + registry, deployment, snapshot = _fixture() + + with pytest.raises(CompositionMethodNotAllowedError): + current_route_id_for_request( + registry, + deployment, + snapshot, + method="POST", + request_path="/v1/people/person_123", + )