Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,8 +41,8 @@ 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
export TREETOP_REST_VERSION=v0.1.0
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
Expand Down
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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 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.

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.
41 changes: 29 additions & 12 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,36 @@ 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).

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

- 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

Expand Down
52 changes: 52 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 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 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
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.
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
```

Expand Down Expand Up @@ -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)
Expand Down
21 changes: 9 additions & 12 deletions benchmarks/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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},
}
13 changes: 6 additions & 7 deletions benchmarks/test_bench_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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(
Expand Down
6 changes: 2 additions & 4 deletions benchmarks/test_bench_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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():
Expand Down
3 changes: 1 addition & 2 deletions benchmarks/test_performance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
20 changes: 12 additions & 8 deletions docker-compose.integration.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
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.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:
Expand All @@ -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/api/v1/health"]
test: ["CMD", "curl", "-f", "http://localhost:9999/readyz"]
interval: 2s
Loading