From e1e6c07ee6daa6407e8e9f2adcf2ea21af1a33c9 Mon Sep 17 00:00:00 2001 From: Terje Kvernes Date: Sun, 6 Sep 2026 13:42:19 +0200 Subject: [PATCH 1/3] Adopt strict authorization and declared-target contract for Python --- .github/workflows/ci.yml | 21 +- .gitignore | 2 + AGENTS.md | 19 + Changelog.md | 36 +- MIGRATION.md | 53 +++ README.md | 27 +- benchmarks/helpers.py | 21 +- benchmarks/test_bench_client.py | 13 +- benchmarks/test_bench_models.py | 6 +- benchmarks/test_performance.py | 3 +- docker-compose.integration.yml | 6 +- pyproject.toml | 3 +- src/treetop_client/client.py | 142 +----- src/treetop_client/models.py | 329 ++++++------- testdata/labels.json | 48 +- tests/test_client.py | 798 ++++++++++++++------------------ tests/test_integration.py | 61 ++- tests/test_models.py | 104 +++-- uv.lock | 2 +- 19 files changed, 809 insertions(+), 885 deletions(-) create mode 100644 AGENTS.md create mode 100644 MIGRATION.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c52de3..951fc92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,6 @@ jobs: include: - integration: false - integration: true - server-version: v0.0.12 - - integration: true - server-version: v0.0.16 steps: - name: Check out code @@ -35,6 +32,18 @@ jobs: - name: Install dependencies run: uv sync --locked --extra dev + - name: Check out exact REST candidate + if: matrix.integration + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: treetop-policy-engine/treetop-rest + ref: fce2e1fa8f44244c201dd87731ba63e5f203a8e0 + path: .candidate-rest + persist-credentials: false + - name: Build candidate server + if: matrix.integration + run: docker build -t treetop-rest-candidate:0.1.0 .candidate-rest + - name: Run type checks if: matrix.integration == false run: | @@ -44,9 +53,9 @@ jobs: - name: Run pytest run: | if [ "${{ matrix.integration }}" = "true" ]; then - export TREETOP_REST_VERSION=${{ matrix.server-version }} - export TREETOP_REST_IMAGE=ghcr.io/treetop-policy-engine/treetop-rest:$TREETOP_REST_VERSION - docker compose -f docker-compose.integration.yml pull + export TREETOP_REST_VERSION=v0.1.0 + export TREETOP_REST_IMAGE=treetop-rest-candidate:0.1.0 + docker compose -f docker-compose.integration.yml pull integration-test-cedar-server uv run pytest -m integration else uv run pytest diff --git a/.gitignore b/.gitignore index b25a625..ab4edcc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ __pycache__/ .uv-cache/ .venv/ dist/ + +.candidate-rest/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c24a7ed --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# Repository guidelines + +Prioritize correctness and one strict current project contract over compatibility +in early releases. Remove obsolete aliases and defaults with concrete breaking +migration notes. Keep synchronous and asynchronous methods uniform. + +Validate authorization response counts, indices, statuses, and complete versions. +Never treat malformed responses, empty batches, or failed items as authorization. +Keep the version cache bounded and immutable, reject invalid typed keys, and keep +subclass construction uncached. Preserve client transport and token protections. + +Run `pytest -m "not integration"`, `pyright`, `basedpyright`, and +`pytest benchmarks`. Run the full integration suite against the exact coordinated +REST candidate for wire changes. An unavailable or unready integration service +must fail, not silently skip. Run `uv build` and inspect wheel/sdist contents for +package changes. Review performance in CodSpeed without weakening its checks. + +Document user-visible changes in `Changelog.md` and `MIGRATION.md`. Use signed +commits and prepare reviewable PRs. Do not merge or release without user approval. diff --git a/Changelog.md b/Changelog.md index e7d8bc8..edaf388 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,19 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added +## [0.1.0] - 2026-09-06 + +### Breaking changes + +- Require one response per submitted request with the same ID. Reject inconsistent Allow/Deny policy IDs or arrays, missing permit Cedar IDs, and missing metadata content instead of filling legacy defaults. + +- Schema revisions use `SchemaVersion` with required `hash` and `loaded_at`, separately from policy/label generations. REST and Core version strings are package versions without a `v` prefix. + +- Target the coordinated REST 0.1.0 contract. Require complete policy versions, + schema metadata, request limits, and context capabilities; remove old defaults. +- Remove `check`, `check_detailed`, `acheck`, `acheck_detailed`, `health`, and + `ahealth`. Use the batch authorization API and `livez`/`alivez`. +- Reject the `desicion` typo, tagged legacy decisions, and scalar detailed-policy + responses. Require canonical decision strings and policy arrays. +- Validate batch counts, ordered indices, result status, and complete version + coherence. Empty batches and batches with failed items never satisfy `all_allowed`. +- Migrate label fixtures to declared resource-type/attribute targets and bundle + format 2. Rebuild and re-sign archives; see [MIGRATION.md](MIGRATION.md). + +### Performance -- Exercise the full integration suite against REST v0.0.16 and v0.0.12, including - the label-configuration identifier from the current server. -- Retain nullable `label_set` and unsigned 64-bit `generation` in `PolicyVersion` - and authorization/version response parsing. Older servers default to `None` - and `0`; model equality now includes both state dimensions. Invalid generations, - including booleans, are rejected. -- Reuse immutable parsed policy versions across batch items with a bounded 256-entry - cache keyed by all version fields and runtime types. Avoid repeated default-field - parsing for older responses; boolean generations cannot alias cached integers. - Custom subclasses remain uncached so their constructors and independent state - retain their existing behavior, including keyword-only and legacy constructors. +- Reuse immutable policy versions in a bounded 256-entry cache keyed by all four + required fields and their runtime types. Invalid values cannot enter the cache; + boolean generations cannot alias integers. Subclasses remain uncached and must + accept all four current fields. ## [0.0.12] - 2026-08-14 diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..7cd28f2 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,53 @@ +# Breaking 0.1.0 migration + +Upgrade all services and clients to the coordinated contract. Early releases +prioritize correctness over compatibility; old response formats are rejected. + +## Authorization and operations + +Replace `check(request)` with `authorize(request)` and inspect the returned batch +item's status before using its result. Use `authorize_detailed`, `aauthorize`, and +`aauthorize_detailed` for the corresponding operations. A single request returns +the same batch shape as multiple requests. The old single-result wrappers are +removed. Use `livez`/`alivez`, `readyz`/`areadyz`, and `openapi`/`aopenapi`; +`health`/`ahealth` and the server's legacy health/OpenAPI routes are removed. + +Responses require canonical decision strings, detailed policy arrays, complete +status metadata, and all four version fields: `hash`, `loaded_at`, nullable +`label_set`, and unsigned 64-bit `generation`. Batch items must have consecutive +indices, consistent status and counts, and exactly the enclosing version. Parsing +errors are errors; never turn them into allow decisions. `all_allowed()` is false +for empty batches or any failed item. Custom `PolicyVersion` subclasses must +accept all four fields and are not interned. + +## Declared label targets + +Configurations now use this rule shape: + +```json +{ + "target": {"resource_type": "App::Host", "attribute": "labels"}, + "field": "name", + "patterns": [{"name": "prod", "regex": "^prod"}] +} +``` + +Replace `kind`/`output` with the explicit target. Each exact Cedar resource type +and attribute tuple has one owner. Distinct types can reuse attribute names. +Sanitization follows that same scope: constrain resource types in policies before +trusting derived labels. Set bundle/module manifests to format 2, rebuild archives, +and re-sign them. Format 1 and old label syntax are rejected. + +## Coordinated verification + +CI builds an immutable REST candidate and runs the full integration suite. After +approval, release Core, Bundle, and REST before Python 0.1.0. No merge, tag, or +publication is authorized by preparing this candidate. + +Schema revisions use `SchemaVersion` with required `hash` and `loaded_at`, +separately from policy/label generations. REST and Core version strings are +package versions without a `v` prefix. + +Require one response per submitted request with the same ID. Reject inconsistent +Allow/Deny policy IDs or arrays, missing permit Cedar IDs, and missing metadata +content instead of filling legacy defaults. diff --git a/README.md b/README.md index 4937cee..6166787 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,11 @@ Python ≥ 3.12, zero runtime deps beyond HTTPX. - **Unified Batch Authorization Endpoint**: Process multiple authorization requests in a single API call - **Detail Levels**: Control response verbosity (brief vs. detailed with policy information) -- **Backward Compatible**: Existing code using `check()` and `check_detailed()` continues to work seamlessly - **Full Async Support**: Async/await support for all API methods - **Type Safe**: Fully type-hinted dataclasses for requests and responses - **Version Tracking**: Access policy version information (hash and loaded_at timestamp) -- **Treetop REST v0.0.16**: Complete policy versions, operational probes, generated OpenAPI, metrics, - status, policy, and schema endpoints. Integration tests also retain v0.0.12 compatibility. +- **Treetop REST 0.1.0**: One strict contract with complete state versions and operational metadata. + This is a breaking release; see [MIGRATION.md](MIGRATION.md). - **Request Context**: Pass request-scoped Cedar context attributes during authorization ## Basic Usage (Single Request) @@ -44,8 +43,10 @@ req = Request( resource=Resource.new("Host", id="myhost", attrs=attrs) ) -# Use the check method (wraps batch API internally) -resp = client.check(req) +# A single request uses the same batch API +response = client.authorize(req) +resp = response.results[0].result +assert resp is not None # Use is_allowed() / is_denied() methods assert resp.is_allowed() @@ -125,7 +126,9 @@ req = Request( ) # Get detailed response with policy information -resp = client.check_detailed(req) +response = client.authorize_detailed(req) +resp = response.results[0].result +assert resp is not None assert resp.is_allowed() assert resp.decision == Decision.ALLOW @@ -139,8 +142,8 @@ if policies: print(f"Cedar IDs: {[p.cedar_id for p in policies if p.cedar_id]}") # Access version information -hash = resp.version_hash() # SHA-256 hash or None -loaded_at = resp.version_loaded_at() # datetime or None +hash = resp.version_hash() # SHA-256 hash +loaded_at = resp.version_loaded_at() # datetime ``` ## Batch Detailed Responses @@ -173,7 +176,9 @@ All methods have async versions: ```python # Single request (async) -resp = await client.acheck(req) +response = await client.aauthorize(req) +resp = response.results[0].result +assert resp is not None # Batch requests (async) response = await client.aauthorize(requests) @@ -210,7 +215,7 @@ req = Request( ) # Pass correlation ID for tracing -resp = client.check(req, correlation_id="my-correlation-id") +response = client.authorize(req, correlation_id="my-correlation-id") response = client.authorize([req1, req2], correlation_id="batch-trace-id") ``` @@ -246,7 +251,7 @@ Strings, booleans, integers, and lists are encoded as Cedar `String`, `Bool`, ## Server Metadata and Uploads ```python -assert client.health() +assert client.livez() version = client.version() print(version.version, version.core.version, version.policies.hash) diff --git a/benchmarks/helpers.py b/benchmarks/helpers.py index d3c06c1..efc21e8 100644 --- a/benchmarks/helpers.py +++ b/benchmarks/helpers.py @@ -65,7 +65,7 @@ def make_requests(count: int, *, with_context: bool = False) -> list[Request]: def version_payload() -> JsonObject: - return {"hash": "policyhash", "loaded_at": _TIMESTAMP} + return {"hash": "policyhash", "loaded_at": _TIMESTAMP, "label_set": None, "generation": 0} def policy_payload(index: int = 0) -> JsonObject: @@ -115,21 +115,17 @@ def brief_batch_payload(count: int) -> JsonObject: def detailed_batch_payload(count: int) -> JsonObject: results: JsonArray = [] for i in range(count): - if i % 3: - decision: JsonObject = { - "Allow": { - "policy": cast(JsonArray, [policy_payload(i)]), - "version": version_payload(), - } - } - else: - decision = {"Deny": {"version": version_payload()}} + decision: JsonObject = { + "decision": "Allow" if i % 3 else "Deny", + "policy": cast(JsonArray, [policy_payload(i)]) if i % 3 else [], + "version": version_payload(), + } results.append( { "index": i, "id": f"req-{i}", "status": "success", - "result": {"decision": decision}, + "result": decision, } ) return { @@ -186,6 +182,7 @@ def status_payload() -> JsonObject: "allow_parallel": True, }, "request_limits": { + "max_batch_size": 1024, "max_context_bytes": 65536, "max_context_depth": 8, "max_context_keys": 64, @@ -203,5 +200,5 @@ def version_response_payload() -> JsonObject: "version": "v0.0.7", "core": {"version": "0.3.0", "cedar": "0.11.0"}, "policies": version_payload(), - "schema": {"hash": "schemahash", "loaded_at": _TIMESTAMP}, + "schema": {"hash": "schemahash", "loaded_at": _TIMESTAMP, "label_set": None, "generation": 0}, } diff --git a/benchmarks/test_bench_client.py b/benchmarks/test_bench_client.py index 1288a01..74d6be7 100644 --- a/benchmarks/test_bench_client.py +++ b/benchmarks/test_bench_client.py @@ -24,7 +24,6 @@ from pytest_httpx import HTTPXMock from treetop_client.client import TreeTopClient -from treetop_client.models import Decision BASE_URL = "http://treetop.test" @@ -59,14 +58,14 @@ def test_first_sync_request_lifecycle( httpx_mock.add_response( method="GET", - url=f"{BASE_URL}/api/v1/health", + url=f"{BASE_URL}/livez", json={}, ) def create_request_and_close() -> bool: instance = TreeTopClient(base_url=BASE_URL) try: - return instance.health() + return instance.livez() finally: instance.close() @@ -107,16 +106,16 @@ def test_authorize_detailed( assert len(response) == count -def test_check(benchmark: BenchmarkFixture, httpx_mock: HTTPXMock, client: TreeTopClient): - """Single-request compatibility wrapper around the batch endpoint.""" +def test_authorize_single(benchmark: BenchmarkFixture, httpx_mock: HTTPXMock, client: TreeTopClient): + """Single request input through the current batch endpoint.""" httpx_mock.add_response( method="POST", url=f"{BASE_URL}/api/v1/authorize", json=brief_batch_payload(1), ) request = make_requests(1)[0] - result = benchmark(client.check, request) - assert result.decision == Decision.DENY + result = benchmark(client.authorize, request) + assert result.results[0].is_denied() def test_async_authorize( diff --git a/benchmarks/test_bench_models.py b/benchmarks/test_bench_models.py index 5255a82..23b3bb4 100644 --- a/benchmarks/test_bench_models.py +++ b/benchmarks/test_bench_models.py @@ -123,8 +123,7 @@ def test_metadata_from_api(benchmark: BenchmarkFixture): def test_policy_version_with_label_metadata(benchmark: BenchmarkFixture): payload: JsonObject = { "hash": "policy-hash", "loaded_at": "2026-09-05T00:00:00Z", - "label_set": "labels-hash", "generation": 7, - } + "label_set": "labels-hash", "generation": 7} version = benchmark(PolicyVersion.from_api, payload) assert version.generation == 7 assert version.label_set == "labels-hash" @@ -133,8 +132,7 @@ def test_policy_version_with_label_metadata(benchmark: BenchmarkFixture): def test_policy_version_with_changing_generation(benchmark: BenchmarkFixture): payload: JsonObject = { "hash": "policy-hash", "loaded_at": "2026-09-05T00:00:00Z", - "label_set": "labels-hash", "generation": 0, - } + "label_set": "labels-hash", "generation": 0} generation = 0 def parse_next_version(): diff --git a/benchmarks/test_performance.py b/benchmarks/test_performance.py index 8042f14..316e906 100644 --- a/benchmarks/test_performance.py +++ b/benchmarks/test_performance.py @@ -43,8 +43,7 @@ def _request(index: int) -> Request: REQUESTS = [_request(index) for index in range(128)] VERSION: JsonObject = { "hash": "c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219", - "loaded_at": "2025-12-19T00:14:38.577289000Z", -} + "loaded_at": "2025-12-19T00:14:38.577289000Z", "label_set": None, "generation": 0} BRIEF_RESPONSE: JsonObject = { "results": [ { diff --git a/docker-compose.integration.yml b/docker-compose.integration.yml index bc0ca01..7c64281 100644 --- a/docker-compose.integration.yml +++ b/docker-compose.integration.yml @@ -8,9 +8,9 @@ services: command: ["/data", "--port", "18999"] integration-test-treetop-server: - image: ${TREETOP_REST_IMAGE:-ghcr.io/treetop-policy-engine/treetop-rest:v0.0.16} + image: ${TREETOP_REST_IMAGE:-ghcr.io/treetop-policy-engine/treetop-rest:v0.1.0} container_name: integration-test-treetop-server - pull_policy: "always" + pull_policy: "missing" ports: - "10101:9999" environment: @@ -24,5 +24,5 @@ services: depends_on: - integration-test-cedar-server healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9999/api/v1/health"] + test: ["CMD", "curl", "-f", "http://localhost:9999/readyz"] interval: 2s diff --git a/pyproject.toml b/pyproject.toml index 888ec0c..08dafae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "treetop-client" -version = "0.0.12" +version = "0.1.0" description = "Python client library for the Treetop policy server" authors = [{ name = "Terje Kvernes", email = "terje@kvernes.no" }] license = { text = "MIT" } @@ -27,6 +27,7 @@ packages = ["src/treetop_client"] [tool.hatch.build.targets.sdist] include = [ "/Changelog.md", + "/MIGRATION.md", "/LICENSE", "/README.md", "/pyproject.toml", diff --git a/src/treetop_client/client.py b/src/treetop_client/client.py index 9ea4c46..06f38b6 100644 --- a/src/treetop_client/client.py +++ b/src/treetop_client/client.py @@ -9,11 +9,8 @@ import httpx from treetop_client.models import ( - AuthorizedResponseBrief, - AuthorizedResponseDetailed, AuthorizeResponseBrief, AuthorizeResponseDetailed, - Decision, Endpoint, JsonArray, JsonObject, @@ -35,15 +32,12 @@ def _requests_to_api( requests: Request | JsonObject | Sequence[Request | JsonObject], -) -> JsonArray: +) -> list[JsonObject]: if isinstance(requests, Request): return [requests.to_api()] if isinstance(requests, dict): return [requests] - return cast( - JsonArray, - [request.to_api() if isinstance(request, Request) else request for request in requests], - ) + return [request.to_api() if isinstance(request, Request) else request for request in requests] def _policy_query_params( @@ -273,15 +267,6 @@ async def ametrics(self) -> str: resp = await self._async_get(Endpoint.METRICS.value) return resp.raise_for_status().text - def health(self) -> bool: - """Return True when the server health endpoint responds with a 2xx status.""" - return self._sync_get(Endpoint.HEALTH.value).raise_for_status().is_success - - async def ahealth(self) -> bool: - """Return True when the server health endpoint responds with a 2xx status.""" - resp = await self._async_get(Endpoint.HEALTH.value) - return resp.raise_for_status().is_success - def version(self) -> VersionResponse: """Fetch server, core, policy, and schema version metadata.""" resp = self._sync_get(Endpoint.VERSION.value) @@ -482,12 +467,14 @@ def authorize( request_list = _requests_to_api(requests) resp = self._sync_post( Endpoint.AUTHORIZE.value, - json_body={"requests": request_list}, + json_body={"requests": cast(JsonArray, request_list)}, correlation_id=correlation_id, ) - return AuthorizeResponseBrief.from_api( + response = AuthorizeResponseBrief.from_api( cast(JsonObject, resp.raise_for_status().json()) ) + response.validate_requests(request_list) + return response def authorize_detailed( self, @@ -507,13 +494,15 @@ def authorize_detailed( request_list = _requests_to_api(requests) resp = self._sync_post( Endpoint.AUTHORIZE.value, - json_body={"requests": request_list}, + json_body={"requests": cast(JsonArray, request_list)}, correlation_id=correlation_id, params={"detail": "full"}, ) - return AuthorizeResponseDetailed.from_api( + response = AuthorizeResponseDetailed.from_api( cast(JsonObject, resp.raise_for_status().json()) ) + response.validate_requests(request_list) + return response async def aauthorize( self, @@ -533,12 +522,14 @@ async def aauthorize( request_list = _requests_to_api(requests) resp = await self._async_post( Endpoint.AUTHORIZE.value, - json_body={"requests": request_list}, + json_body={"requests": cast(JsonArray, request_list)}, correlation_id=correlation_id, ) - return AuthorizeResponseBrief.from_api( + response = AuthorizeResponseBrief.from_api( cast(JsonObject, resp.raise_for_status().json()) ) + response.validate_requests(request_list) + return response async def aauthorize_detailed( self, @@ -558,112 +549,15 @@ async def aauthorize_detailed( request_list = _requests_to_api(requests) resp = await self._async_post( Endpoint.AUTHORIZE.value, - json_body={"requests": request_list}, + json_body={"requests": cast(JsonArray, request_list)}, correlation_id=correlation_id, params={"detail": "full"}, ) - return AuthorizeResponseDetailed.from_api( + response = AuthorizeResponseDetailed.from_api( cast(JsonObject, resp.raise_for_status().json()) ) - - # Compatibility methods for single-request API (wraps batch API) - def check( - self, request: Request | JsonObject, correlation_id: str | None = None - ) -> AuthorizedResponseBrief: - """Check the given request. Synchronous version (compatibility wrapper). - - This method provides backward compatibility with the old single-request API. - It wraps the new batch authorize endpoint. - - Args: - request: The request to check, either as a Request object or a dictionary. - correlation_id: Optional correlation ID for tracing the request. - Returns: - An AuthorizedResponseBrief containing the result of the check. - Raises: - httpx.HTTPStatusError: If the request fails with a non-2xx status code - """ - response = self.authorize(request, correlation_id=correlation_id) - if not response.results: - raise ValueError("No results returned from authorize endpoint") - result = response.results[0] - if result.status == "failed": - raise RuntimeError(f"Authorization failed: {result.error}") - return result.result or AuthorizedResponseBrief(Decision.DENY) - - def check_detailed( - self, request: Request | JsonObject, correlation_id: str | None = None - ) -> AuthorizedResponseDetailed: - """Check the given request with detailed output. Synchronous version (compatibility wrapper). - - This method provides backward compatibility with the old single-request API. - It wraps the new batch authorize_detailed endpoint. - - Args: - request: The request to check, either as a Request object or a dictionary. - correlation_id: Optional correlation ID for tracing the request. - Returns: - An AuthorizedResponseDetailed containing the detailed result of the check. - Raises: - httpx.HTTPStatusError: If the request fails with a non-2xx status code - """ - response = self.authorize_detailed(request, correlation_id=correlation_id) - if not response.results: - raise ValueError("No results returned from authorize endpoint") - result = response.results[0] - if result.status == "failed": - raise RuntimeError(f"Authorization failed: {result.error}") - return result.result or AuthorizedResponseDetailed(Decision.DENY, [], None) - - async def acheck( - self, request: Request | JsonObject, correlation_id: str | None = None - ) -> AuthorizedResponseBrief: - """Check the given request. Asynchronous version (compatibility wrapper). - - This method provides backward compatibility with the old single-request API. - It wraps the new batch aauthorize endpoint. - - Args: - request: The request to check, either as a Request object or a dictionary. - correlation_id: Optional correlation ID for tracing the request. - Returns: - An AuthorizedResponseBrief containing the result of the check. - Raises: - httpx.HTTPStatusError: If the request fails with a non-2xx status code - """ - response = await self.aauthorize(request, correlation_id=correlation_id) - if not response.results: - raise ValueError("No results returned from authorize endpoint") - result = response.results[0] - if result.status == "failed": - raise RuntimeError(f"Authorization failed: {result.error}") - return result.result or AuthorizedResponseBrief(Decision.DENY) - - async def acheck_detailed( - self, request: Request | JsonObject, correlation_id: str | None = None - ) -> AuthorizedResponseDetailed: - """Check the given request with detailed output. Asynchronous version (compatibility wrapper). - - This method provides backward compatibility with the old single-request API. - It wraps the new batch aauthorize_detailed endpoint. - - Args: - request: The request to check, either as a Request object or a dictionary. - correlation_id: Optional correlation ID for tracing the request. - Returns: - An AuthorizedResponseDetailed containing the detailed result of the check. - Raises: - httpx.HTTPStatusError: If the request fails with a non-2xx status code - """ - response = await self.aauthorize_detailed( - request, correlation_id=correlation_id - ) - if not response.results: - raise ValueError("No results returned from authorize endpoint") - result = response.results[0] - if result.status == "failed": - raise RuntimeError(f"Authorization failed: {result.error}") - return result.result or AuthorizedResponseDetailed(Decision.DENY, [], None) + response.validate_requests(request_list) + return response def close(self): """Close the synchronous client connection.""" diff --git a/src/treetop_client/models.py b/src/treetop_client/models.py index ae01dc5..bc62b01 100644 --- a/src/treetop_client/models.py +++ b/src/treetop_client/models.py @@ -1,10 +1,11 @@ from __future__ import annotations import enum +from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime from functools import lru_cache -from typing import ClassVar, Generic, Literal, NoReturn, TypeAlias, TypedDict, TypeVar, override +from typing import Generic, Literal, NoReturn, TypeAlias, TypedDict, TypeVar, cast, override JsonPrimitive: TypeAlias = str | int | float | bool | None JsonObject: TypeAlias = dict[str, "JsonValue"] @@ -24,7 +25,7 @@ def _expect_str(value: JsonValue | None, *, field_name: str) -> str: def _expect_int(value: JsonValue | None, *, field_name: str) -> int: - if isinstance(value, int): + if type(value) is int: return value raise ValueError(f"{field_name} must be an int, got {type(value).__name__}") @@ -66,7 +67,6 @@ class Endpoint(enum.Enum): READYZ = "/readyz" OPENAPI = "/openapi.json" METRICS = "/metrics" - HEALTH = "/api/v1/health" VERSION = "/api/v1/version" STATUS = "/api/v1/status" POLICIES = "/api/v1/policies" @@ -284,7 +284,7 @@ def _context_value_to_api(value: ContextValue | JsonValue) -> JsonObject: return {"type": "String", "value": value} if isinstance(value, bool): return {"type": "Bool", "value": value} - if isinstance(value, int): + if type(value) is int: return { "type": "Long", "value": _validate_i64(value, field_name="Long value"), @@ -413,27 +413,19 @@ def as_api(obj: Request | JsonObject) -> JsonObject: @dataclass(slots=True, frozen=True) class AuthorizedResponseBrief: decision: Decision - policy_id: str = "" - version: PolicyVersion | None = None + policy_id: str + version: PolicyVersion - _KEYS: ClassVar[tuple[str, ...]] = ("decision",) + def __post_init__(self) -> None: + if (self.decision is Decision.ALLOW) != bool(self.policy_id): + raise ValueError("Allow requires a policy ID; Deny requires an empty ID") @classmethod def from_api(cls, data: JsonObject) -> AuthorizedResponseBrief: - dec = data.get("decision") - if dec is None: - dec = data.get("desicion") - if dec is None: - raise KeyError("decision") - decision = _expect_str(dec, field_name="decision") - policy_id = data.get("policy_id") - version = data.get("version") return cls( - decision=_decision_from_api(decision), - policy_id=_expect_str(policy_id, field_name="policy_id") - if policy_id is not None - else "", - version=_optional_policy_version(version), + decision=_decision_from_api(_expect_str(data.get("decision"), field_name="decision")), + policy_id=_expect_str(data.get("policy_id"), field_name="policy_id"), + version=PolicyVersion.from_api(_expect_dict(data.get("version"), field_name="version")), ) def is_allowed(self) -> bool: @@ -442,21 +434,21 @@ def is_allowed(self) -> bool: def is_denied(self) -> bool: return self.decision == Decision.DENY - def version_hash(self) -> str | None: - """Return the policy version hash if available, otherwise None.""" - return self.version.hash if self.version else None + def version_hash(self) -> str: + """Return the required policy version hash.""" + return self.version.hash - def version_loaded_at(self) -> datetime | None: - """Return the policy version loaded_at timestamp if available, otherwise None.""" - return self.version.loaded_at if self.version else None + def version_loaded_at(self) -> datetime: + """Return the required policy load timestamp.""" + return self.version.loaded_at @dataclass(slots=True, frozen=True) class PermitPolicy: literal: str json: JsonObject + cedar_id: str annotation_id: str | None = None - cedar_id: str | None = None @classmethod def from_api(cls, data: JsonObject) -> PermitPolicy: @@ -465,7 +457,9 @@ def from_api(cls, data: JsonObject) -> PermitPolicy: annotation_id = _expect_optional_str( data.get("annotation_id"), field_name="annotation_id" ) - cedar_id = _expect_optional_str(data.get("cedar_id"), field_name="cedar_id") + cedar_id = _expect_str(data["cedar_id"], field_name="cedar_id") + if not literal or not cedar_id: + raise ValueError("permit policy requires a nonempty literal and Cedar ID") return cls( literal=literal, json=json_blob, @@ -480,8 +474,8 @@ class PolicyVersion: hash: str loaded_at: datetime - label_set: str | None = None - generation: int = 0 + label_set: str | None + generation: int def __post_init__(self) -> None: generation = self.generation @@ -490,64 +484,44 @@ def __post_init__(self) -> None: @classmethod def from_api(cls, data: JsonObject) -> PolicyVersion: - hash_value = _expect_str(data.get("hash"), field_name="version hash") - loaded_at_value = _expect_str(data.get("loaded_at"), field_name="version loaded_at") - # Subclasses may add mutable state or constructor behavior. - if cls is not PolicyVersion: - if "label_set" not in data and "generation" not in data: - return cls(hash=hash_value, loaded_at=_datetime_from_api(loaded_at_value)) - return cls( - hash=hash_value, - loaded_at=_datetime_from_api(loaded_at_value), - label_set=_expect_optional_str(data.get("label_set"), field_name="version label_set"), - generation=_expect_int(data.get("generation", 0), field_name="version generation"), + if cls is PolicyVersion: + return _policy_version_from_values( + data["hash"], data["loaded_at"], data["label_set"], data["generation"] ) - if "label_set" not in data and "generation" not in data: - return _policy_version_from_values(cls, hash_value, loaded_at_value) - return _policy_version_from_values( - cls, - hash_value, - loaded_at_value, - _expect_optional_str(data.get("label_set"), field_name="version label_set"), - _expect_int(data.get("generation", 0), field_name="version generation"), + # Subclasses may add mutable state; construct them without interning. + return cls( + hash=_expect_str(data["hash"], field_name="version hash"), + loaded_at=_datetime_from_api(_expect_str(data["loaded_at"], field_name="version loaded_at")), + label_set=_expect_optional_str(data["label_set"], field_name="version label_set"), + generation=_expect_int(data["generation"], field_name="version generation"), ) -@lru_cache(maxsize=256, typed=True) -def _policy_version_from_values( - cls: type[PolicyVersion], - hash_value: str, - loaded_at: str, - label_set: str | None = None, - generation: int = 0, +def _parse_policy_version( + hash_value: JsonValue, + loaded_at: JsonValue, + label_set: JsonValue, + generation: JsonValue, ) -> PolicyVersion: - """Share immutable versions across repeated batch items, keyed by every field. + """Validate each distinct immutable wire version once in a bounded typed cache. - Typed keys keep booleans distinct from cached integer generations, so the - constructor always rejects them. Invalid constructions are never cached. + Typed keys distinguish bool from int. Every required field participates in + the key; invalid or unhashable inputs never produce a cached version. """ - return cls(hash_value, _datetime_from_api(loaded_at), label_set, generation) - - -def _optional_policy_version(blob: JsonValue | None) -> PolicyVersion | None: - if blob is None: - return None - return PolicyVersion.from_api(_expect_dict(blob, field_name="version")) + return PolicyVersion( + _expect_str(hash_value, field_name="version hash"), + _datetime_from_api(_expect_str(loaded_at, field_name="version loaded_at")), + _expect_optional_str(label_set, field_name="version label_set"), + _expect_int(generation, field_name="version generation"), + ) -def _permit_policies_from_api( - blob: JsonValue, *, context: str, decision_data: JsonObject -) -> list[PermitPolicy]: - if isinstance(blob, list): - if not blob: - raise ValueError(f"{context} has empty policy list: {decision_data!r}") - return [ - PermitPolicy.from_api(_expect_dict(entry, field_name="policy")) - for entry in blob - ] - if isinstance(blob, dict): - return [PermitPolicy.from_api(blob)] - raise ValueError(f"{context} has malformed policy: {blob!r}") +# The wire boundary accepts arbitrary JSON: lru_cache rejects unhashable values, +# and the parser rejects invalid scalar values before any result can be cached. +_policy_version_from_values = cast( + Callable[[JsonValue, JsonValue, JsonValue, JsonValue], PolicyVersion], + lru_cache(maxsize=256, typed=True)(_parse_policy_version), +) class PolicyMatchReason(str, enum.Enum): @@ -624,12 +598,29 @@ def from_api(cls, data: JsonObject) -> CoreVersion: ) +@dataclass(slots=True, frozen=True) +class SchemaVersion: + """Loaded schema revision; distinct from policy and label generations.""" + + hash: str + loaded_at: datetime + + @classmethod + def from_api(cls, data: JsonObject) -> SchemaVersion: + return cls( + hash=_expect_str(data["hash"], field_name="schema hash"), + loaded_at=_datetime_from_api( + _expect_str(data["loaded_at"], field_name="schema loaded_at") + ), + ) + + @dataclass(slots=True, frozen=True) class VersionResponse: version: str core: CoreVersion policies: PolicyVersion - schema: PolicyVersion | None = None + schema: SchemaVersion | None = None @classmethod def from_api(cls, data: JsonObject) -> VersionResponse: @@ -640,7 +631,7 @@ def from_api(cls, data: JsonObject) -> VersionResponse: policies=PolicyVersion.from_api( _expect_dict(data.get("policies"), field_name="policies") ), - schema=PolicyVersion.from_api( + schema=SchemaVersion.from_api( _expect_dict(schema_blob, field_name="schema") ) if schema_blob is not None @@ -656,7 +647,7 @@ class Metadata: source: JsonObject | None refresh_frequency: int | None entries: int - content: str | None = None + content: str @classmethod def from_api(cls, data: JsonObject) -> Metadata: @@ -677,43 +668,26 @@ def from_api(cls, data: JsonObject) -> Metadata: if refresh_frequency is not None else None, entries=_expect_int(data.get("entries"), field_name="entries"), - content=_expect_optional_str(data.get("content"), field_name="content"), + content=_expect_str(data["content"], field_name="content"), ) @dataclass(slots=True, frozen=True) class PolicyConfiguration: - allow_upload: bool | None - schema_validation_mode: str | None - policies: Metadata | None - labels: Metadata | None - schema: Metadata | None + allow_upload: bool + schema_validation_mode: str + policies: Metadata + labels: Metadata + schema: Metadata @classmethod def from_api(cls, data: JsonObject) -> PolicyConfiguration: - allow_upload = data.get("allow_upload") - schema_validation_mode = data.get("schema_validation_mode") - policies = data.get("policies") - labels = data.get("labels") - schema = data.get("schema") return cls( - allow_upload=_expect_bool(allow_upload, field_name="allow_upload") - if allow_upload is not None - else None, - schema_validation_mode=_expect_str( - schema_validation_mode, field_name="schema_validation_mode" - ) - if schema_validation_mode is not None - else None, - policies=Metadata.from_api(_expect_dict(policies, field_name="policies")) - if policies is not None - else None, - labels=Metadata.from_api(_expect_dict(labels, field_name="labels")) - if labels is not None - else None, - schema=Metadata.from_api(_expect_dict(schema, field_name="schema")) - if schema is not None - else None, + allow_upload=_expect_bool(data.get("allow_upload"), field_name="allow_upload"), + schema_validation_mode=_expect_str(data.get("schema_validation_mode"), field_name="schema_validation_mode"), + policies=Metadata.from_api(_expect_dict(data.get("policies"), field_name="policies")), + labels=Metadata.from_api(_expect_dict(data.get("labels"), field_name="labels")), + schema=Metadata.from_api(_expect_dict(data.get("schema"), field_name="schema")), ) @@ -747,7 +721,7 @@ class RequestLimits: max_context_bytes: int max_context_depth: int max_context_keys: int - max_batch_size: int | None = None + max_batch_size: int @classmethod def from_api(cls, data: JsonObject) -> RequestLimits: @@ -763,9 +737,7 @@ def from_api(cls, data: JsonObject) -> RequestLimits: ), max_batch_size=_expect_int( data.get("max_batch_size"), field_name="max_batch_size" - ) - if data.get("max_batch_size") is not None - else None, + ), ) @@ -823,66 +795,19 @@ class AuthorizedResponseDetailed: # Either decision == Decision.DENY (empty policies list) or Decision.ALLOW with policies decision: Decision policies: list[PermitPolicy] - version: PolicyVersion | None = None + version: PolicyVersion @classmethod def from_api(cls, data: JsonObject) -> AuthorizedResponseDetailed: - dec = data.get("decision") - if dec is None: - dec = data.get("desicion") # Temporary typo support - if dec is None: - raise KeyError("decision") - - if isinstance(dec, str): - # 1) Is it a simple Deny (old format)? - if dec == "Deny": - version = _optional_policy_version(data.get("version")) - return cls(decision=Decision.DENY, policies=[], version=version) - - # 1b) Is it a simple Allow with top-level policy/version? - if dec == "Allow": - if "policy" not in data: - raise ValueError(f"Allow decision missing policy: {data!r}") - policies = _permit_policies_from_api( - data["policy"], context="Allow decision", decision_data=data - ) - version = _optional_policy_version(data.get("version")) - return cls( - decision=Decision.ALLOW, - policies=policies, - version=version, - ) - raise ValueError(f"Unrecognized decision value: {dec!r}") - - # 2) Is it a Deny with version (new format)? - if isinstance(dec, dict) and "Deny" in dec: - deny_dict = _expect_dict(dec["Deny"], field_name="deny decision") - version = _optional_policy_version(deny_dict.get("version")) - return cls( - decision=Decision.DENY, - policies=[], - version=version, - ) - - # 3) If it's a dict with an "Allow" key, pull the policies and optional version - if isinstance(dec, dict) and "Allow" in dec: - allow_dict = _expect_dict(dec["Allow"], field_name="allow decision") - - if "policy" not in allow_dict: - raise ValueError(f"Allow decision missing policy: {dec!r}") - - policies = _permit_policies_from_api( - allow_dict["policy"], context="Allow decision", decision_data=data - ) - version = _optional_policy_version(allow_dict.get("version")) - return cls( - decision=Decision.ALLOW, - policies=policies, - version=version, - ) - - # 4) Otherwise it's malformed - raise ValueError(f"Unrecognized decision shape: {dec!r}") + decision = _decision_from_api(_expect_str(data.get("decision"), field_name="decision")) + policies = _expect_list(data.get("policy"), field_name="policy") + if (decision is Decision.ALLOW) != bool(policies): + raise ValueError("Allow requires permit policies; Deny requires an empty array") + return cls( + decision=decision, + policies=[PermitPolicy.from_api(_expect_dict(policy, field_name="policy")) for policy in policies], + version=PolicyVersion.from_api(_expect_dict(data.get("version"), field_name="version")), + ) def is_allowed(self) -> bool: return self.decision == Decision.ALLOW @@ -902,13 +827,13 @@ def __getitem__(self, index: int) -> PermitPolicy: """Return a matching policy by index.""" return self.policies[index] - def version_hash(self) -> str | None: - """Return the policy version hash if available, otherwise None.""" - return self.version.hash if self.version else None + def version_hash(self) -> str: + """Return the required policy version hash.""" + return self.version.hash - def version_loaded_at(self) -> datetime | None: - """Return the policy version loaded_at timestamp if available, otherwise None.""" - return self.version.loaded_at if self.version else None + def version_loaded_at(self) -> datetime: + """Return the required policy load timestamp.""" + return self.version.loaded_at # Generic type variables for authorization results and responses @@ -934,6 +859,11 @@ def _authorize_result_fields( error = ( _expect_str(error_blob, field_name="error") if error_blob is not None else None ) + if status == "success": + if "result" not in data or error is not None: + raise ValueError("successful result requires a decision and no error") + elif status != "failed" or "result" in data or error is None: + raise ValueError("failed result requires an error and no decision") return index, result_id, status, error @@ -1034,6 +964,23 @@ class AuthorizeResponseBase(Generic[T]): successful: int failed: int + def __post_init__(self) -> None: + successful = 0 + for index, item in enumerate(self.results): + if item.index != index: + raise ValueError("batch result indices must match their positions") + if item.status == "success": + result = item.result + if result is None or item.error is not None: + raise ValueError("successful result requires a decision and no error") + if result.version is not self.version and result.version != self.version: + raise ValueError("batch and item policy versions must match") + successful += 1 + elif item.status != "failed" or item.result is not None or item.error is None: + raise ValueError("failed result requires an error and no decision") + if self.successful != successful or self.failed != len(self.results) - successful: + raise ValueError("batch result counts must match the returned results") + def __iter__(self): """Iterate over results.""" return iter(self.results) @@ -1046,6 +993,14 @@ def __getitem__(self, index: int) -> T: """Get result by index.""" return self.results[index] + def validate_requests(self, requests: list[JsonObject]) -> None: + """Require exactly one corresponding result for every submitted request.""" + if len(self.results) != len(requests): + raise ValueError("response result count differs from the submitted batch") + for item, request in zip(self.results, requests, strict=True): + if item.id != request.get("id"): + raise ValueError("response result ID differs from the submitted request") + def get_by_id(self, request_id: str) -> T | None: """Get result by client-provided request ID.""" for result in self.results: @@ -1074,12 +1029,12 @@ def allowed_count(self) -> int: ) def all_allowed(self) -> bool: - """Check if all successful results are allowed.""" - return all( - result.result is not None + """Require a nonempty batch in which every result succeeded and allowed.""" + return bool(self.results) and all( + result.status == "success" + and result.result is not None and result.result.decision is Decision.ALLOW for result in self.results - if result.status == "success" ) @@ -1098,10 +1053,10 @@ def from_api(cls, data: JsonObject) -> AuthorizeResponseBrief: for entry in results_blob ] else: - results = [] + raise ValueError("results must be an array") version = PolicyVersion.from_api(_expect_dict(data.get("version"), field_name="version")) - successful = _expect_int(data.get("successful", 0), field_name="successful") - failed = _expect_int(data.get("failed", 0), field_name="failed") + successful = _expect_int(data.get("successful"), field_name="successful") + failed = _expect_int(data.get("failed"), field_name="failed") return cls( results=results, version=version, successful=successful, failed=failed @@ -1123,10 +1078,10 @@ def from_api(cls, data: JsonObject) -> AuthorizeResponseDetailed: for entry in results_blob ] else: - results = [] + raise ValueError("results must be an array") version = PolicyVersion.from_api(_expect_dict(data.get("version"), field_name="version")) - successful = _expect_int(data.get("successful", 0), field_name="successful") - failed = _expect_int(data.get("failed", 0), field_name="failed") + successful = _expect_int(data.get("successful"), field_name="successful") + failed = _expect_int(data.get("failed"), field_name="failed") return cls( results=results, version=version, successful=successful, failed=failed diff --git a/testdata/labels.json b/testdata/labels.json index 9494941..101de06 100644 --- a/testdata/labels.json +++ b/testdata/labels.json @@ -1,25 +1,27 @@ [ - { - "kind": "Host", - "field": "name", - "output": "nameLabels", - "patterns": [ - { - "name": "in_domain", - "regex": "example\\.com$" - }, - { - "name": "valid_webserver_name", - "regex": "^web-\\d+" - }, - { - "name": "admin_subdomain", - "regex": "^admin\\." - }, - { - "name": "staging_environment", - "regex": "^staging\\." - } - ] + { + "field": "name", + "patterns": [ + { + "name": "in_domain", + "regex": "example\\.com$" + }, + { + "name": "valid_webserver_name", + "regex": "^web-\\d+" + }, + { + "name": "admin_subdomain", + "regex": "^admin\\." + }, + { + "name": "staging_environment", + "regex": "^staging\\." + } + ], + "target": { + "resource_type": "Host", + "attribute": "nameLabels" } -] \ No newline at end of file + } +] diff --git a/tests/test_client.py b/tests/test_client.py index 6577060..c7ecdbf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,5 @@ import asyncio +from dataclasses import replace from datetime import datetime import httpx @@ -9,6 +10,8 @@ from treetop_client.models import ( Action, Decision, + JsonArray, + JsonObject, PolicyMatchReason, QualifiedId, Request, @@ -56,7 +59,7 @@ def metadata_payload(content: str = "...") -> dict[str, object]: def add_health_version_status_responses(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( method="GET", - url="http://localhost:9999/api/v1/health", + url="http://localhost:9999/livez", json={}, status_code=200, ) @@ -68,12 +71,10 @@ def add_health_version_status_responses(httpx_mock: HTTPXMock) -> None: "core": {"version": "0.3.0", "cedar": "0.11.0"}, "policies": { "hash": "policyhash", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, + "loaded_at": "2025-12-19T00:14:38.577289000Z", "label_set": None, "generation": 0}, "schema": { "hash": "schemahash", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, + "loaded_at": "2025-12-19T00:14:38.577289000Z", "label_set": None, "generation": 0}, }, status_code=200, ) @@ -146,14 +147,16 @@ def add_upload_responses(httpx_mock: HTTPXMock) -> None: "Content-Type": "text/plain", "X-Upload-Token": "token", }, - json={"policies": metadata_payload("permit (...);")}, + json={"allow_upload":True, "schema_validation_mode":"permissive", +"policies":metadata_payload("permit (...);"), "labels":metadata_payload(""), "schema":metadata_payload('{"": {}}')}, status_code=200, ) httpx_mock.add_response( method="POST", url="http://localhost:9999/api/v1/schema", match_headers={"X-Upload-Token": "token"}, - json={"schema": metadata_payload('{"": {}}')}, + json={"allow_upload":True, "schema_validation_mode":"permissive", +"policies":metadata_payload("permit (...);"), "labels":metadata_payload(""), "schema":metadata_payload('{"": {}}')}, status_code=200, ) @@ -161,7 +164,7 @@ def add_upload_responses(httpx_mock: HTTPXMock) -> None: def test_http_clients_are_initialized_on_demand(httpx_mock: HTTPXMock): httpx_mock.add_response( method="GET", - url="http://localhost:9999/api/v1/health", + url="http://localhost:9999/livez", json={}, status_code=200, ) @@ -170,7 +173,7 @@ def test_http_clients_are_initialized_on_demand(httpx_mock: HTTPXMock): assert client.__dict__["_sync_client"] is None assert client.__dict__["_async_client"] is None - assert client.health() + assert client.livez() assert client.__dict__["_sync_client"] is not None assert client.__dict__["_async_client"] is None client.close() @@ -181,7 +184,7 @@ def test_closed_client_is_not_initialized_later(): client.close() with pytest.raises(RuntimeError, match="client has been closed"): - _ = client.health() + _ = client.livez() assert client.__dict__["_sync_client"] is None @@ -190,7 +193,7 @@ def test_health_version_and_status(httpx_mock: HTTPXMock): add_health_version_status_responses(httpx_mock) client = TreeTopClient() - assert client.health() is True + assert client.livez() is True version = client.version() assert version.version == "v0.0.7" assert version.core.cedar == "0.11.0" @@ -360,7 +363,7 @@ def test_async_health_version_and_status(httpx_mock: HTTPXMock): async def exercise() -> None: client = TreeTopClient() try: - assert await client.ahealth() is True + assert await client.alivez() is True version = await client.aversion() assert version.version == "v0.0.7" @@ -435,20 +438,17 @@ def test_authorize_single_request_brief(httpx_mock: HTTPXMock): "index": 0, "id": "check-1", "status": "success", - "result": { - "decision": "Allow", - "policy_id": "default", - "version": { - "hash": "result-hash", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, - }, + "result": {'decision': 'Allow', + 'policy_id': 'default', + 'version': {'hash': 'c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}, } ], "version": { "hash": "c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, + "loaded_at": "2025-12-19T00:14:38.577289000Z", "label_set": None, "generation": 0}, "successful": 1, "failed": 0, }, @@ -465,7 +465,7 @@ def test_authorize_single_request_brief(httpx_mock: HTTPXMock): assert result.get_decision() == Decision.ALLOW assert result.result is not None assert result.result.policy_id == "default" - assert result.result.version_hash() == "result-hash" + assert result.result.version_hash() == response.version.hash def test_authorize_multiple_requests_brief(httpx_mock: HTTPXMock): @@ -473,28 +473,25 @@ def test_authorize_multiple_requests_brief(httpx_mock: HTTPXMock): httpx_mock.add_response( method="POST", url="http://localhost:9999/api/v1/authorize", - json={ - "results": [ - { - "index": 0, - "id": "check-1", - "status": "success", - "result": {"decision": "Allow"}, - }, - { - "index": 1, - "id": "check-2", - "status": "failed", - "error": "Evaluation failed: invalid resource", - }, - ], - "version": { - "hash": "c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, - "successful": 1, - "failed": 1, - }, + json={'results': [{'index': 0, + 'id': 'check-1', + 'status': 'success', + 'result': {'decision': 'Allow', + 'policy_id': "policy0", + 'version': {'hash': 'c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}, + {'index': 1, + 'id': 'check-2', + 'status': 'failed', + 'error': 'Evaluation failed: invalid resource'}], + 'version': {'hash': 'c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}, + 'successful': 1, + 'failed': 1}, status_code=200, ) client = TreeTopClient() @@ -523,57 +520,26 @@ def test_authorize_detailed(httpx_mock: HTTPXMock): httpx_mock.add_response( method="POST", url="http://localhost:9999/api/v1/authorize?detail=full", - json={ - "results": [ - { - "index": 0, - "id": "check-1", - "status": "success", - "result": { - "decision": { - "Allow": { - "policy": [ - { - "literal": 'permit (\n principal == User::"alice",\n action in [Action::"view"],\n resource == Photo::"42"\n);', - "json": { - "action": { - "entities": [ - {"id": "view", "type": "Action"} - ], - "op": "in", - }, - "conditions": [], - "effect": "permit", - "principal": { - "entity": { - "id": "alice", - "type": "User", - }, - "op": "==", - }, - "resource": { - "entity": {"id": "42", "type": "Photo"}, - "op": "==", - }, - }, - } - ], - "version": { - "hash": "c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219", - "loaded_at": "2025-12-19T15:25:55.384783000Z", - }, - }, - }, - }, - } - ], - "version": { - "hash": "c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, - "successful": 1, - "failed": 0, - }, + json={'results': [{'index': 0, + 'id': 'check-1', + 'status': 'success', + 'result': {'decision': 'Allow', + 'policy': [{'literal': 'permit (\n principal == User::"alice",\n action in [Action::"view"],\n resource == Photo::"42"\n);', + 'json': {'action': {'entities': [{'id': 'view', 'type': 'Action'}], 'op': 'in'}, + 'conditions': [], + 'effect': 'permit', + 'principal': {'entity': {'id': 'alice', 'type': 'User'}, 'op': '=='}, + 'resource': {'entity': {'id': '42', 'type': 'Photo'}, 'op': '=='}}, "cedar_id": "policy0"}], + 'version': {'hash': 'c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}], + 'version': {'hash': 'c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}, + 'successful': 1, + 'failed': 0}, status_code=200, ) client = TreeTopClient() @@ -597,22 +563,21 @@ def test_authorize_deny(httpx_mock: HTTPXMock): httpx_mock.add_response( method="POST", url="http://localhost:9999/api/v1/authorize", - json={ - "results": [ - { - "index": 0, - "id": "check-1", - "status": "success", - "result": {"decision": "Deny"}, - } - ], - "version": { - "hash": "c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, - "successful": 1, - "failed": 0, - }, + json={'results': [{'index': 0, + 'id': 'check-1', + 'status': 'success', + 'result': {'decision': 'Deny', + 'policy_id': '', + 'version': {'hash': 'c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}], + 'version': {'hash': 'c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}, + 'successful': 1, + 'failed': 0}, status_code=200, ) client = TreeTopClient() @@ -642,22 +607,21 @@ def test_async_authorize(httpx_mock: HTTPXMock): httpx_mock.add_response( method="POST", url="http://localhost:9999/api/v1/authorize", - json={ - "results": [ - { - "index": 0, - "id": "check-1", - "status": "success", - "result": {"decision": "Allow"}, - } - ], - "version": { - "hash": "c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, - "successful": 1, - "failed": 0, - }, + json={'results': [{'index': 0, + 'id': 'check-1', + 'status': 'success', + 'result': {'decision': 'Allow', + 'policy_id': "policy0", + 'version': {'hash': 'c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}], + 'version': {'hash': 'c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}, + 'successful': 1, + 'failed': 0}, status_code=200, ) client = TreeTopClient() @@ -672,96 +636,68 @@ def test_async_authorize(httpx_mock: HTTPXMock): assert result.is_allowed() -# Backward compatibility tests (old check/check_detailed API) -def test_check_backward_compatibility(httpx_mock: HTTPXMock): - """Test backward compatibility with old check() method.""" +# Single-request inputs use the batch API +def test_authorize_single_request(httpx_mock: HTTPXMock): + """Test a single request through the batch API.""" httpx_mock.add_response( method="POST", url="http://localhost:9999/api/v1/authorize", - json={ - "results": [ - { - "index": 0, - "id": None, - "status": "success", - "result": {"decision": "Allow"}, - } - ], - "version": { - "hash": "c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, - "successful": 1, - "failed": 0, - }, + json={'results': [{'index': 0, + 'id': None, + 'status': 'success', + 'result': {'decision': 'Allow', + 'policy_id': "policy0", + 'version': {'hash': 'c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}], + 'version': {'hash': 'c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}, + 'successful': 1, + 'failed': 0}, status_code=200, ) client = TreeTopClient() - resp = client.check(make_req(id_suffix=None)) + resp_batch = client.authorize(make_req(id_suffix=None)) + resp = resp_batch.results[0].result + assert resp is not None assert resp.is_allowed() assert resp.decision == Decision.ALLOW -def test_check_detailed_backward_compatibility(httpx_mock: HTTPXMock): - """Test backward compatibility with old check_detailed() method.""" +def test_authorize_single_detailed_request(httpx_mock: HTTPXMock): + """Test a single request through the detailed batch API.""" httpx_mock.add_response( method="POST", url="http://localhost:9999/api/v1/authorize?detail=full", - json={ - "results": [ - { - "index": 0, - "id": None, - "status": "success", - "result": { - "decision": { - "Allow": { - "policy": [ - { - "literal": 'permit (\n principal == User::"alice",\n action in [Action::"view"],\n resource == Photo::"42"\n);', - "json": { - "effect": "permit", - "principal": { - "entity": { - "id": "alice", - "type": "User", - }, - "op": "==", - }, - "action": { - "entities": [ - {"id": "view", "type": "Action"} - ], - "op": "in", - }, - "resource": { - "entity": {"id": "42", "type": "Photo"}, - "op": "==", - }, - "conditions": [], - }, - } - ], - "version": { - "hash": "c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219", - "loaded_at": "2025-12-16T15:25:55.384783000Z", - }, - }, - }, - }, - } - ], - "version": { - "hash": "c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219", - "loaded_at": "2025-12-16T15:25:55.384783000Z", - }, - "successful": 1, - "failed": 0, - }, + json={'results': [{'index': 0, + 'id': None, + 'status': 'success', + 'result': {'decision': 'Allow', + 'policy': [{'literal': 'permit (\n principal == User::"alice",\n action in [Action::"view"],\n resource == Photo::"42"\n);', + 'json': {'effect': 'permit', + 'principal': {'entity': {'id': 'alice', 'type': 'User'}, 'op': '=='}, + 'action': {'entities': [{'id': 'view', 'type': 'Action'}], 'op': 'in'}, + 'resource': {'entity': {'id': '42', 'type': 'Photo'}, 'op': '=='}, + 'conditions': []}, "cedar_id": "policy0"}], + 'version': {'hash': 'c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219', + 'loaded_at': '2025-12-16T15:25:55.384783000Z', + 'label_set': None, + 'generation': 0}}}], + 'version': {'hash': 'c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219', + 'loaded_at': '2025-12-16T15:25:55.384783000Z', + 'label_set': None, + 'generation': 0}, + 'successful': 1, + 'failed': 0}, status_code=200, ) client = TreeTopClient() - resp = client.check_detailed(make_req(id_suffix=None)) + resp_batch = client.authorize_detailed(make_req(id_suffix=None)) + resp = resp_batch.results[0].result + assert resp is not None assert resp.is_allowed() assert resp.decision == Decision.ALLOW assert len(resp.policies) > 0 @@ -777,31 +713,32 @@ def test_check_detailed_backward_compatibility(httpx_mock: HTTPXMock): assert isinstance(loaded_at, datetime) -def test_check_deny_backward_compatibility(httpx_mock: HTTPXMock): - """Test backward compatibility with old check() method returning Deny.""" +def test_authorize_single_deny(httpx_mock: HTTPXMock): + """Test a single request returning Deny through the batch API.""" httpx_mock.add_response( method="POST", url="http://localhost:9999/api/v1/authorize", - json={ - "results": [ - { - "index": 0, - "id": None, - "status": "success", - "result": {"decision": "Deny"}, - } - ], - "version": { - "hash": "c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, - "successful": 1, - "failed": 0, - }, + json={'results': [{'index': 0, + 'id': None, + 'status': 'success', + 'result': {'decision': 'Deny', + 'policy_id': '', + 'version': {'hash': 'c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}], + 'version': {'hash': 'c82d116854d77bf689c3d15e167764876dffe869c970bc08ab7c5dacd7726219', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}, + 'successful': 1, + 'failed': 0}, status_code=200, ) client = TreeTopClient() - resp = client.check(make_req(id_suffix=None)) + resp_batch = client.authorize(make_req(id_suffix=None)) + resp = resp_batch.results[0].result + assert resp is not None assert resp.is_denied() assert resp.decision == Decision.DENY @@ -812,38 +749,43 @@ def test_batch_authorize_lookup_by_index(httpx_mock: HTTPXMock): httpx_mock.add_response( method="POST", url="http://localhost:9999/api/v1/authorize", - json={ - "results": [ - { - "index": 0, - "id": "req-alice-view", - "status": "success", - "result": {"decision": "Allow"}, - }, - { - "index": 1, - "id": "req-bob-delete", - "status": "success", - "result": {"decision": "Deny"}, - }, - { - "index": 2, - "id": "req-charlie-edit", - "status": "success", - "result": {"decision": "Allow"}, - }, - ], - "version": { - "hash": "abc123", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, - "successful": 3, - "failed": 0, - }, + json={'results': [{'index': 0, + 'id': 'req-alice-view', + 'status': 'success', + 'result': {'decision': 'Allow', + 'policy_id': "policy0", + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}, + {'index': 1, + 'id': 'req-bob-delete', + 'status': 'success', + 'result': {'decision': 'Deny', + 'policy_id': '', + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}, + {'index': 2, + 'id': 'req-charlie-edit', + 'status': 'success', + 'result': {'decision': 'Allow', + 'policy_id': "policy0", + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}], + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}, + 'successful': 3, + 'failed': 0}, status_code=200, ) client = TreeTopClient() - requests = [make_req("1"), make_req("2"), make_req("3")] + requests = [replace(make_req(), id=request_id) for request_id in ('req-alice-view', 'req-bob-delete', 'req-charlie-edit')] response = client.authorize(requests) # Test lookup by index @@ -863,38 +805,43 @@ def test_batch_authorize_lookup_by_id(httpx_mock: HTTPXMock): httpx_mock.add_response( method="POST", url="http://localhost:9999/api/v1/authorize", - json={ - "results": [ - { - "index": 0, - "id": "req-alice-view", - "status": "success", - "result": {"decision": "Allow"}, - }, - { - "index": 1, - "id": "req-bob-delete", - "status": "success", - "result": {"decision": "Deny"}, - }, - { - "index": 2, - "id": "req-charlie-edit", - "status": "success", - "result": {"decision": "Allow"}, - }, - ], - "version": { - "hash": "abc123", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, - "successful": 3, - "failed": 0, - }, + json={'results': [{'index': 0, + 'id': 'req-alice-view', + 'status': 'success', + 'result': {'decision': 'Allow', + 'policy_id': "policy0", + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}, + {'index': 1, + 'id': 'req-bob-delete', + 'status': 'success', + 'result': {'decision': 'Deny', + 'policy_id': '', + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}, + {'index': 2, + 'id': 'req-charlie-edit', + 'status': 'success', + 'result': {'decision': 'Allow', + 'policy_id': "policy0", + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}], + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}, + 'successful': 3, + 'failed': 0}, status_code=200, ) client = TreeTopClient() - requests = [make_req("1"), make_req("2"), make_req("3")] + requests = [replace(make_req(), id=request_id) for request_id in ('req-alice-view', 'req-bob-delete', 'req-charlie-edit')] response = client.authorize(requests) # Test lookup by ID @@ -923,58 +870,34 @@ def test_batch_authorize_detailed_lookup_by_index(httpx_mock: HTTPXMock): httpx_mock.add_response( method="POST", url="http://localhost:9999/api/v1/authorize?detail=full", - json={ - "results": [ - { - "index": 0, - "id": "req-1", - "status": "success", - "result": { - "decision": { - "Allow": { - "policy": [ - { - "literal": "permit (...);", - "json": { - "effect": "permit", - }, - } - ], - "version": { - "hash": "hash1", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, - } - } - }, - }, - { - "index": 1, - "id": "req-2", - "status": "success", - "result": { - "decision": { - "Deny": { - "version": { - "hash": "hash2", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - } - } - } - }, - }, - ], - "version": { - "hash": "abc123", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, - "successful": 2, - "failed": 0, - }, + json={'results': [{'index': 0, + 'id': 'req-1', + 'status': 'success', + 'result': {'decision': 'Allow', + 'policy': [{'literal': 'permit (...);', 'json': {'effect': 'permit'}, "cedar_id": "policy0"}], + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}, + {'index': 1, + 'id': 'req-2', + 'status': 'success', + 'result': {'decision': 'Deny', + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}, + 'policy': []}}], + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}, + 'successful': 2, + 'failed': 0}, status_code=200, ) client = TreeTopClient() - requests = [make_req("1"), make_req("2")] + requests = [replace(make_req(), id=request_id) for request_id in ('req-1', 'req-2')] response = client.authorize_detailed(requests) # Test lookup by index @@ -982,11 +905,11 @@ def test_batch_authorize_detailed_lookup_by_index(httpx_mock: HTTPXMock): assert response[0].is_allowed() policies = response[0].policies assert len(policies) > 0 - assert response[0].version_hash() == "hash1" + assert response[0].version_hash() == response.version.hash assert response[1].is_denied() assert len(response[1].policies) == 0 - assert response[1].version_hash() == "hash2" + assert response[1].version_hash() == response.version.hash def test_batch_authorize_detailed_lookup_by_id(httpx_mock: HTTPXMock): @@ -994,54 +917,30 @@ def test_batch_authorize_detailed_lookup_by_id(httpx_mock: HTTPXMock): httpx_mock.add_response( method="POST", url="http://localhost:9999/api/v1/authorize?detail=full", - json={ - "results": [ - { - "index": 0, - "id": "photo-allow", - "status": "success", - "result": { - "decision": { - "Allow": { - "policy": [ - { - "literal": "permit (...);", - "json": { - "effect": "permit", - }, - } - ], - "version": { - "hash": "hash1", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, - } - } - }, - }, - { - "index": 1, - "id": "video-deny", - "status": "success", - "result": { - "decision": { - "Deny": { - "version": { - "hash": "hash2", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - } - } - } - }, - }, - ], - "version": { - "hash": "abc123", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, - "successful": 2, - "failed": 0, - }, + json={'results': [{'index': 0, + 'id': 'photo-allow', + 'status': 'success', + 'result': {'decision': 'Allow', + 'policy': [{'literal': 'permit (...);', 'json': {'effect': 'permit'}, "cedar_id": "policy0"}], + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}, + {'index': 1, + 'id': 'video-deny', + 'status': 'success', + 'result': {'decision': 'Deny', + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}, + 'policy': []}}], + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}, + 'successful': 2, + 'failed': 0}, status_code=200, ) client = TreeTopClient() @@ -1075,13 +974,13 @@ def test_batch_authorize_detailed_lookup_by_id(httpx_mock: HTTPXMock): assert photo_result.is_allowed() policies = photo_result.policies assert len(policies) > 0 - assert photo_result.version_hash() == "hash1" + assert photo_result.version_hash() == response.version.hash video_result = response.get_by_id("video-deny") assert video_result is not None assert video_result.is_denied() assert len(video_result.policies) == 0 - assert video_result.version_hash() == "hash2" + assert video_result.version_hash() == response.version.hash def test_batch_authorize_mixed_success_and_failure(httpx_mock: HTTPXMock): @@ -1089,38 +988,35 @@ def test_batch_authorize_mixed_success_and_failure(httpx_mock: HTTPXMock): httpx_mock.add_response( method="POST", url="http://localhost:9999/api/v1/authorize", - json={ - "results": [ - { - "index": 0, - "id": "req-ok", - "status": "success", - "result": {"decision": "Allow"}, - }, - { - "index": 1, - "id": "req-error", - "status": "failed", - "error": "Invalid resource kind", - }, - { - "index": 2, - "id": "req-ok2", - "status": "success", - "result": {"decision": "Deny"}, - }, - ], - "version": { - "hash": "abc123", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, - "successful": 2, - "failed": 1, - }, + json={'results': [{'index': 0, + 'id': 'req-ok', + 'status': 'success', + 'result': {'decision': 'Allow', + 'policy_id': "policy0", + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}, + {'index': 1, 'id': 'req-error', 'status': 'failed', 'error': 'Invalid resource kind'}, + {'index': 2, + 'id': 'req-ok2', + 'status': 'success', + 'result': {'decision': 'Deny', + 'policy_id': '', + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}], + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}, + 'successful': 2, + 'failed': 1}, status_code=200, ) client = TreeTopClient() - requests = [make_req("1"), make_req("2"), make_req("3")] + requests = [replace(make_req(), id=request_id) for request_id in ('req-ok', 'req-error', 'req-ok2')] response = client.authorize(requests) # Verify counts @@ -1158,38 +1054,43 @@ def test_batch_authorize_iteration(httpx_mock: HTTPXMock): httpx_mock.add_response( method="POST", url="http://localhost:9999/api/v1/authorize", - json={ - "results": [ - { - "index": 0, - "id": "req-1", - "status": "success", - "result": {"decision": "Allow"}, - }, - { - "index": 1, - "id": "req-2", - "status": "success", - "result": {"decision": "Deny"}, - }, - { - "index": 2, - "id": "req-3", - "status": "success", - "result": {"decision": "Allow"}, - }, - ], - "version": { - "hash": "abc123", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, - "successful": 3, - "failed": 0, - }, + json={'results': [{'index': 0, + 'id': 'req-1', + 'status': 'success', + 'result': {'decision': 'Allow', + 'policy_id': "policy0", + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}, + {'index': 1, + 'id': 'req-2', + 'status': 'success', + 'result': {'decision': 'Deny', + 'policy_id': '', + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}, + {'index': 2, + 'id': 'req-3', + 'status': 'success', + 'result': {'decision': 'Allow', + 'policy_id': "policy0", + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}}}], + 'version': {'hash': 'abc123', + 'loaded_at': '2025-12-19T00:14:38.577289000Z', + 'label_set': None, + 'generation': 0}, + 'successful': 3, + 'failed': 0}, status_code=200, ) client = TreeTopClient() - requests = [make_req("1"), make_req("2"), make_req("3")] + requests = [replace(make_req(), id=request_id) for request_id in ('req-1', 'req-2', 'req-3')] response = client.authorize(requests) # Test iteration @@ -1199,3 +1100,24 @@ def test_batch_authorize_iteration(httpx_mock: HTTPXMock): # Test iteration with filter allowed_count = sum(1 for result in response if result.is_allowed()) assert allowed_count == 2 + + +@pytest.mark.parametrize("method", ["authorize", "authorize_detailed", "aauthorize", "aauthorize_detailed"]) +@pytest.mark.parametrize("corruption", ["truncated", "wrong_id"]) +def test_authorization_rejects_response_mismatch(httpx_mock: HTTPXMock, method: str, corruption: str): + version: JsonObject = {"hash":"h", "loaded_at":"2026-09-06T00:00:00Z", "label_set":None, "generation":0} + results: JsonArray = [] if corruption == "truncated" else [{"index":0, "id":"wrong-id", "status":"success", + "result":{"decision":"Deny", "policy_id":"", "policy":[], "version":version}}] + httpx_mock.add_response(json={"results":results, "successful":len(results), "failed":0, "version":version}) + client = TreeTopClient() + with pytest.raises(ValueError, match="submitted"): + if method == "authorize": + _ = client.authorize(make_req()) + elif method == "authorize_detailed": + _ = client.authorize_detailed(make_req()) + elif method == "aauthorize": + _ = asyncio.run(client.aauthorize(make_req())) + else: + _ = asyncio.run(client.aauthorize_detailed(make_req())) + asyncio.run(client.aclose()) + client.close() diff --git a/tests/test_integration.py b/tests/test_integration.py index 20afe5d..237dfc3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -23,7 +23,7 @@ PORT = 10101 NAMESPACE = ["DNS"] -SERVER_VERSION = os.environ.get("TREETOP_REST_VERSION", "v0.0.16") +SERVER_VERSION = os.environ.get("TREETOP_REST_VERSION", "v0.1.0") def make_host_resource( @@ -167,7 +167,7 @@ def docker_compose_up_down(tmp_path_factory: pytest.TempPathFactory): except Exception: time.sleep(1) else: - pytest.skip("policy-server did not start in time") + pytest.fail("policy-server did not start in time") yield # tear down _ = subprocess.call( @@ -175,7 +175,7 @@ def docker_compose_up_down(tmp_path_factory: pytest.TempPathFactory): ) -def test_v0_0_11_server_surfaces(client: TreeTopClient): +def test_current_server_surfaces(client: TreeTopClient): assert client.livez() assert client.readyz() assert client.openapi()["openapi"] == "3.1.0" @@ -245,7 +245,9 @@ def test_live_check_allows_user( client: TreeTopClient, ): req = make_request(principal, action, "host.example.com", groups) - resp = client.check(req) + resp_batch = client.authorize(req) + resp = resp_batch.results[0].result + assert resp is not None if expected: assert resp.is_allowed() assert resp.decision == Decision.ALLOW @@ -279,23 +281,24 @@ def test_live_check_allows_super_bare( action=Action.new("any"), resource=Resource.new(resource_kind, id, attrs), ) - resp = client.check(req) + resp_batch = client.authorize(req) + resp = resp_batch.results[0].result + assert resp is not None assert resp.is_allowed() assert resp.decision == Decision.ALLOW -def test_live_v0011_metadata_endpoints(client: TreeTopClient): - assert client.health() is True +def test_current_metadata_endpoints(client: TreeTopClient): + assert client.livez() is True version = client.version() - assert version.version == SERVER_VERSION + assert version.version == SERVER_VERSION.removeprefix("v") assert version.core.version assert version.core.cedar assert version.policies.hash assert version.policies.loaded_at is not None - if SERVER_VERSION == "v0.0.16": - assert version.policies.label_set is not None - assert len(version.policies.label_set) == 64 + assert version.policies.label_set is not None + assert len(version.policies.label_set) == 64 status = client.status() assert status.policy_configuration.allow_upload is False @@ -346,7 +349,9 @@ def test_live_v0011_request_context_bool_and_long(client: TreeTopClient): }, ) - response = client.check(request) + response_batch = client.authorize(request) + response = response_batch.results[0].result + assert response is not None assert response.decision == Decision.ALLOW @@ -354,7 +359,9 @@ def test_live_check_allow_detailed( client: TreeTopClient, ): req = make_request("alice", "view_host", "host.example.com", ["admins"]) - resp = client.check_detailed(req) + resp_batch = client.authorize_detailed(req) + resp = resp_batch.results[0].result + assert resp is not None assert resp.is_allowed() assert resp.decision == Decision.ALLOW assert len(resp.policies) > 0 @@ -382,7 +389,7 @@ def test_live_check_allow_detailed( assert len(annotation_ids) > 0 assert annotation_ids[0] == "DNS.admins_policy" - cedar_ids = [p.cedar_id for p in policies if p.cedar_id is not None] + cedar_ids = [p.cedar_id for p in policies] assert len(cedar_ids) > 0 # Cedar ID should be present (e.g., "policy0", "policy1", etc.) assert cedar_ids[0].startswith("policy") @@ -452,7 +459,9 @@ def test_live_policies_match_dns_cedar( client: TreeTopClient, ): expected = load_dns_policy_literals() - resp = client.check_detailed(req) + resp_batch = client.authorize_detailed(req) + resp = resp_batch.results[0].result + assert resp is not None assert resp.is_allowed() policies = list(resp) @@ -476,7 +485,9 @@ def test_live_forbid_policy_enforced( # Charlie is an admin, but forbid policy should override the allow. req = make_request("charlie", "delete_host", "host.example.com", ["admins"]) - resp = client.check_detailed(req) + resp_batch = client.authorize_detailed(req) + resp = resp_batch.results[0].result + assert resp is not None assert resp.is_denied() assert len(resp) == 0 @@ -493,7 +504,9 @@ def test_live_multiple_policy_ids_present( "10.0.0.1", ["admins"], ) - resp = client.check_detailed(req) + resp_batch = client.authorize_detailed(req) + resp = resp_batch.results[0].result + assert resp is not None assert resp.is_allowed() policies = list(resp) @@ -503,7 +516,7 @@ def test_live_multiple_policy_ids_present( assert None not in annotation_ids assert {pid for pid in annotation_ids if pid is not None} == expected_ids - cedar_ids = [p.cedar_id for p in policies if p.cedar_id is not None] + cedar_ids = [p.cedar_id for p in policies] assert len(cedar_ids) == len(policies) assert len(set(cedar_ids)) == len(cedar_ids) @@ -522,7 +535,9 @@ def test_live_users_ip_network_range( "192.168.1.42", ["users"], ) - allow_resp_192 = client.check_detailed(allow_req_192) + allow_resp_192_batch = client.authorize_detailed(allow_req_192) + allow_resp_192 = allow_resp_192_batch.results[0].result + assert allow_resp_192 is not None assert allow_resp_192.is_allowed() assert len(allow_resp_192) == 1 assert allow_resp_192[0].annotation_id == "DNS.users_ip_network_policy" @@ -533,7 +548,9 @@ def test_live_users_ip_network_range( "10.1.2.3", ["users"], ) - allow_resp_10 = client.check_detailed(allow_req_10) + allow_resp_10_batch = client.authorize_detailed(allow_req_10) + allow_resp_10 = allow_resp_10_batch.results[0].result + assert allow_resp_10 is not None assert allow_resp_10.is_allowed() assert len(allow_resp_10) == 1 assert allow_resp_10[0].annotation_id == "DNS.users_ip_network_policy" @@ -544,7 +561,9 @@ def test_live_users_ip_network_range( "172.16.0.1", ["users"], ) - deny_resp = client.check_detailed(deny_req) + deny_resp_batch = client.authorize_detailed(deny_req) + deny_resp = deny_resp_batch.results[0].result + assert deny_resp is not None assert deny_resp.is_denied() assert len(deny_resp) == 0 diff --git a/tests/test_models.py b/tests/test_models.py index 3698248..dada70c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -216,11 +216,10 @@ def test_detailed_response_current_full_shape(): resp = AuthorizedResponseDetailed.from_api( { "decision": "Allow", - "policy": [{"literal": "permit (...);", "json": {"effect": "permit"}}], + "policy": [{"literal": "permit (...);", "json": {"effect": "permit"}, "cedar_id": "policy0"}], "version": { "hash": "abc123", - "loaded_at": "2025-12-19T00:14:38.577289000Z", - }, + "loaded_at": "2025-12-19T00:14:38.577289000Z", "label_set": None, "generation": 0}, } ) @@ -234,8 +233,7 @@ def test_detailed_response_current_full_shape(): def test_policy_version_retains_complete_state(label_set: str | None, generation: int): wire: JsonObject = { "hash": "abc", "loaded_at": "2026-09-05T00:00:00Z", - "label_set": label_set, "generation": generation, - } + "label_set": label_set, "generation": generation} version = PolicyVersion.from_api(wire) assert version.label_set == label_set assert version.generation == generation @@ -243,29 +241,29 @@ def test_policy_version_retains_complete_state(label_set: str | None, generation changed: JsonObject = dict(wire, generation=(generation + 1) % (1 << 64)) assert version != PolicyVersion.from_api(changed) for response_type in [AuthorizedResponseBrief, AuthorizedResponseDetailed]: - response = response_type.from_api({"decision": "Deny", "policy": [], "version": wire}) + response = response_type.from_api({"decision": "Deny", "policy": [], "policy_id": "", "version": wire}) assert response.version == version -def test_policy_version_defaults_for_older_servers(): - version = PolicyVersion.from_api({"hash": "abc", "loaded_at": "2026-09-05T00:00:00Z"}) - assert version.label_set is None - assert version.generation == 0 +@pytest.mark.parametrize("field", ["hash", "loaded_at", "label_set", "generation"]) +def test_policy_version_requires_every_current_field(field: str): + wire: JsonObject = {"hash":"h", "loaded_at":"2026-09-05T00:00:00Z", "label_set":None, "generation":0} + del wire[field] + with pytest.raises((KeyError, ValueError), match=field): + _ = PolicyVersion.from_api(wire) @pytest.mark.parametrize("generation", [-1, 1 << 64, True, False, 1.5, "1", None]) def test_policy_version_rejects_invalid_generation(generation: JsonValue): with pytest.raises((TypeError, ValueError), match="generation"): _ = PolicyVersion.from_api({ - "hash": "abc", "loaded_at": "2026-09-05T00:00:00Z", "generation": generation, - }) + "hash": "abc", "loaded_at": "2026-09-05T00:00:00Z", "generation": generation, "label_set": None}) def test_cached_versions_do_not_conflate_generation_types_or_state(): wire: JsonObject = { "hash": "cached", "loaded_at": "2026-09-05T00:00:00Z", - "label_set": "labels-v1", "generation": 0, - } + "label_set": "labels-v1", "generation": 0} original = PolicyVersion.from_api(wire) assert original == PolicyVersion.from_api(dict(wire)) for field, value in [("label_set", "labels-v2"), ("generation", 1), @@ -279,8 +277,8 @@ def test_cached_versions_do_not_conflate_generation_types_or_state(): _ = PolicyVersion.from_api(invalid) -@pytest.mark.parametrize("modern_metadata", [False, True]) -def test_policy_version_subclasses_are_constructed_independently(modern_metadata: bool): +@pytest.mark.parametrize("identified_labels", [False, True]) +def test_policy_version_subclasses_are_constructed_independently(identified_labels: bool): constructed: list[str] = [] class CustomPolicyVersion(PolicyVersion): @@ -288,8 +286,8 @@ def __post_init__(self) -> None: super().__post_init__() constructed.append(self.hash) - wire: JsonObject = {"hash": "custom", "loaded_at": "2026-09-05T00:00:00Z"} - if modern_metadata: + wire: JsonObject = {"hash": "custom", "loaded_at": "2026-09-05T00:00:00Z", "label_set": None, "generation": 0} + if identified_labels: wire.update({"label_set": "labels-v1", "generation": 7}) first = CustomPolicyVersion.from_api(wire) second = CustomPolicyVersion.from_api(wire) @@ -299,22 +297,8 @@ def __post_init__(self) -> None: assert constructed == ["custom", "custom"] -def test_policy_version_preserves_legacy_keyword_only_constructor(): - class LegacyVersion(PolicyVersion): - def __init__(self, *, hash: str, loaded_at: datetime): - super().__init__(hash=hash, loaded_at=loaded_at) - - wire: JsonObject = {"hash": "legacy", "loaded_at": "2026-09-05T00:00:00Z"} - first = LegacyVersion.from_api(wire) - second = LegacyVersion.from_api(wire) - assert isinstance(first, LegacyVersion) - assert first.hash == "legacy" - assert first.generation == 0 - assert first == second - assert first is not second - -def test_policy_version_passes_modern_metadata_as_keywords(): +def test_policy_version_passes_identified_labels_as_keywords(): class KeywordVersion(PolicyVersion): def __init__(self, *, hash: str, loaded_at: datetime, label_set: str | None = None, generation: int = 0): @@ -327,3 +311,57 @@ def __init__(self, *, hash: str, loaded_at: datetime, assert isinstance(version, KeywordVersion) assert version.label_set == "labels" assert version.generation == 7 + + +@pytest.mark.parametrize("decision", [{"Allow":{"policy":[]}}, {"Deny":{}}, None]) +def test_old_decision_shapes_and_typo_alias_are_rejected(decision: JsonValue): + wire: JsonObject = {"decision":decision, "desicion":"Allow", "policy_id":"p", "policy":[], + "version":{"hash":"h","loaded_at":"2026-09-05T00:00:00Z","label_set":None,"generation":0}} + for response_type in [AuthorizedResponseBrief, AuthorizedResponseDetailed]: + with pytest.raises(ValueError, match="decision"): + _ = response_type.from_api(wire) + + +@pytest.mark.parametrize("field,value", [("successful",0), ("failed",1), ("index",1), ("generation",1)]) +def test_batch_rejects_inconsistent_current_metadata(field: str, value: int): + from treetop_client.models import AuthorizeResponseBrief + version: JsonObject = {"hash":"h","loaded_at":"2026-09-05T00:00:00Z","label_set":None,"generation":0} + result_version: JsonObject = dict(version) + entry: JsonObject = {"index":0,"status":"success","result":{"decision":"Allow","policy_id":"p","version":result_version}} + batch: JsonObject = {"results":[entry],"version":version,"successful":1,"failed":0} + if field == "index": + entry[field] = value + elif field == "generation": + result_version[field] = value + else: + batch[field] = value + with pytest.raises(ValueError, match="batch"): + _ = AuthorizeResponseBrief.from_api(batch) + + +def test_all_allowed_rejects_empty_and_failed_batches(): + from treetop_client.models import AuthorizeResponseBrief + version: JsonObject = {"hash":"h","loaded_at":"2026-09-05T00:00:00Z","label_set":None,"generation":0} + empty = AuthorizeResponseBrief.from_api({"results":[],"version":version,"successful":0,"failed":0}) + failed = AuthorizeResponseBrief.from_api({"results":[{"index":0,"status":"failed","error":"evaluation failed"}],"version":version,"successful":0,"failed":1}) + assert not empty.all_allowed() + assert not failed.all_allowed() + + +def test_schema_revision_is_distinct_from_authorization_generation(): + from treetop_client.models import SchemaVersion + + revision = SchemaVersion.from_api({"hash": "schema", "loaded_at": "2026-09-06T00:00:00Z"}) + assert revision.hash == "schema" + for missing in ["hash", "loaded_at"]: + data: JsonObject = {"hash": "schema", "loaded_at": "2026-09-06T00:00:00Z"} + del data[missing] + with pytest.raises(KeyError): + _ = SchemaVersion.from_api(data) + + +@pytest.mark.parametrize("decision,policy_id", [("Allow", ""), ("Deny", "permit")]) +def test_brief_decision_rejects_inconsistent_policy_id(decision: str, policy_id: str): + with pytest.raises(ValueError): + _ = AuthorizedResponseBrief.from_api({"decision": decision, "policy_id": policy_id, + "version": {"hash":"h", "loaded_at":"2026-09-06T00:00:00Z", "label_set":None, "generation":0}}) diff --git a/uv.lock b/uv.lock index b0dc30c..dc45948 100644 --- a/uv.lock +++ b/uv.lock @@ -261,7 +261,7 @@ wheels = [ [[package]] name = "treetop-client" -version = "0.0.12" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From 2d94132b14bbfda64372778c73a5beeca529e3c7 Mon Sep 17 00:00:00 2001 From: Terje Kvernes Date: Sun, 6 Sep 2026 14:16:11 +0200 Subject: [PATCH 2/3] Wait for healthy fixtures before starting integration REST --- Changelog.md | 5 ++++ docker-compose.integration.yml | 14 +++++---- tests/test_integration.py | 53 +++++++++++++++------------------- 3 files changed, 37 insertions(+), 35 deletions(-) diff --git a/Changelog.md b/Changelog.md index edaf388..4915939 100644 --- a/Changelog.md +++ b/Changelog.md @@ -26,6 +26,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Migrate label fixtures to declared resource-type/attribute targets and bundle format 2. Rebuild and re-sign archives; see [MIGRATION.md](MIGRATION.md). +### Verification + +- Require a healthy pinned HTTP fixture before REST starts; wait for both policy + and label loading, fail setup errors, and always clean up integration containers. + ### Performance - Reuse immutable policy versions in a bounded 256-entry cache keyed by all four diff --git a/docker-compose.integration.yml b/docker-compose.integration.yml index 7c64281..5c0c82f 100644 --- a/docker-compose.integration.yml +++ b/docker-compose.integration.yml @@ -1,11 +1,14 @@ services: integration-test-cedar-server: - image: docker.io/svenstaro/miniserve - ports: - - "18999:18999" + image: docker.io/library/python@sha256:78e98729f8fc4099e53cffb3fe59fd15b18dfa4ace8c914dee0cefa5320068eb # 3.12-alpine volumes: - ./testdata:/data:ro,Z - command: ["/data", "--port", "18999"] + command: ["python", "-m", "http.server", "18999", "--directory", "/data"] + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18999/dns.cedar'); urllib.request.urlopen('http://127.0.0.1:18999/labels.json')"] + interval: 1s + timeout: 2s + retries: 30 integration-test-treetop-server: image: ${TREETOP_REST_IMAGE:-ghcr.io/treetop-policy-engine/treetop-rest:v0.1.0} @@ -22,7 +25,8 @@ services: - TREETOP_POLICY_UPDATE_FREQUENCY=120 - TREETOP_LABELS_UPDATE_FREQUENCY=120 depends_on: - - integration-test-cedar-server + integration-test-cedar-server: + condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9999/readyz"] interval: 2s diff --git a/tests/test_integration.py b/tests/test_integration.py index 237dfc3..4631601 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -3,7 +3,6 @@ import subprocess import time from pathlib import Path -from typing import cast import httpx import pytest @@ -143,36 +142,30 @@ def docker_compose_up_down(tmp_path_factory: pytest.TempPathFactory): yield return - # bring up _ = tmp_path_factory - _ = subprocess.check_call( - ["docker", "compose", "-f", "docker-compose.integration.yml", "up", "-d"] - ) - # wait for the server to be ready - for _ in range(10): - try: - resp = httpx.get(f"http://localhost:{PORT}/api/v1/policies", timeout=1.0) - if resp.status_code == 200: - payload = cast(dict[str, object], resp.json()) - policies = payload.get("policies") - entries = 0 - if isinstance(policies, dict): - policies_dict = cast(dict[str, object], policies) - entries_val = policies_dict.get("entries", 0) - entries = entries_val if isinstance(entries_val, int) else 0 - if entries: - break - else: - time.sleep(1) - except Exception: - time.sleep(1) - else: - pytest.fail("policy-server did not start in time") - yield - # tear down - _ = subprocess.call( - ["docker", "compose", "-f", "docker-compose.integration.yml", "down"] - ) + compose = ["docker", "compose", "-f", "docker-compose.integration.yml"] + try: + _ = subprocess.check_call([*compose, "up", "-d"], timeout=60) + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + try: + resp = httpx.get(f"http://localhost:{PORT}/api/v1/status", timeout=1.0) + if resp.status_code == 200: + status = TreeTopClient(base_url=f"http://localhost:{PORT}") + try: + configuration = status.status().policy_configuration + if configuration.policies.entries > 0 and configuration.labels.entries > 0: + break + finally: + status.close() + except (httpx.HTTPError, ValueError, KeyError): + pass + time.sleep(0.2) + else: + pytest.fail("policy-server did not load both policy and label fixtures in time") + yield + finally: + _ = subprocess.call([*compose, "down"], timeout=30) def test_current_server_surfaces(client: TreeTopClient): From 71b7880e5959f2f59e8bfb445c67058c4fd15abb Mon Sep 17 00:00:00 2001 From: Terje Kvernes Date: Sun, 6 Sep 2026 19:23:54 +0200 Subject: [PATCH 3/3] build: verify against the released REST 0.1.0 image --- .github/workflows/ci.yml | 16 ++-------------- .gitignore | 2 -- AGENTS.md | 2 +- MIGRATION.md | 5 ++--- 4 files changed, 5 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 951fc92..3fb2387 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,18 +32,6 @@ jobs: - name: Install dependencies run: uv sync --locked --extra dev - - name: Check out exact REST candidate - if: matrix.integration - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: treetop-policy-engine/treetop-rest - ref: fce2e1fa8f44244c201dd87731ba63e5f203a8e0 - path: .candidate-rest - persist-credentials: false - - name: Build candidate server - if: matrix.integration - run: docker build -t treetop-rest-candidate:0.1.0 .candidate-rest - - name: Run type checks if: matrix.integration == false run: | @@ -54,8 +42,8 @@ jobs: run: | if [ "${{ matrix.integration }}" = "true" ]; then export TREETOP_REST_VERSION=v0.1.0 - export TREETOP_REST_IMAGE=treetop-rest-candidate:0.1.0 - docker compose -f docker-compose.integration.yml pull integration-test-cedar-server + export TREETOP_REST_IMAGE=ghcr.io/treetop-policy-engine/treetop-rest@sha256:d1fdd7536f31dde0b922f9516baa16a1f5ee7ecd4ab1c0892afd555128acc43e + docker compose -f docker-compose.integration.yml pull uv run pytest -m integration else uv run pytest diff --git a/.gitignore b/.gitignore index ab4edcc..b25a625 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,3 @@ __pycache__/ .uv-cache/ .venv/ dist/ - -.candidate-rest/ diff --git a/AGENTS.md b/AGENTS.md index c24a7ed..c59983f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ subclass construction uncached. Preserve client transport and token protections. Run `pytest -m "not integration"`, `pyright`, `basedpyright`, and `pytest benchmarks`. Run the full integration suite against the exact coordinated -REST candidate for wire changes. An unavailable or unready integration service +REST release for wire changes. An unavailable or unready integration service must fail, not silently skip. Run `uv build` and inspect wheel/sdist contents for package changes. Review performance in CodSpeed without weakening its checks. diff --git a/MIGRATION.md b/MIGRATION.md index 7cd28f2..932bd65 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -40,9 +40,8 @@ and re-sign them. Format 1 and old label syntax are rejected. ## Coordinated verification -CI builds an immutable REST candidate and runs the full integration suite. After -approval, release Core, Bundle, and REST before Python 0.1.0. No merge, tag, or -publication is authorized by preparing this candidate. +CI runs the full integration suite against the immutable REST 0.1.0 release image +pinned in its workflow. Release Core, Bundle, and REST before Python 0.1.0. Schema revisions use `SchemaVersion` with required `hash` and `loaded_at`, separately from policy/label generations. REST and Core version strings are