Metadata-Version: 2.4
Name: caselaw-access
Version: 0.2.0
Summary: Python client for the Case Law Explorer shared access service: authentication, endpoint scoping, and rate limiting as a drop-in guard.
License: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: httpx>=0.28.0
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"

# caselaw-access

Client SDK for the Case Law Explorer access service. One call decides
authentication, endpoint scope, and rate limit, so a product does not
implement any of them itself.

## Install

```bash
pip install caselaw-access
```

For local development against unreleased changes, build a wheel and install it
directly:

```bash
pip wheel . --no-deps -w dist
pip install dist/caselaw_access-*.whl
```

Prefer that over vendoring the wheel into a consumer repo: pip caches by
version, so a rebuilt wheel with an unchanged version number is silently
ignored.

## Configuration

Everything comes from the environment:

| Variable | Meaning | Default |
| --- | --- | --- |
| `ACCESS_SERVICE_URL` | Base URL of the access service. Empty disables all checks. | — |
| `ACCESS_PROJECT` | Project slug registered in the access console. | `citations-api` |
| `ACCESS_SERVICE_TIMEOUT_SECONDS` | Per-check timeout. | `5` |
| `ACCESS_FAIL_OPEN` | Waive **quota** for already-validated credentials when the service is unreachable. Never waives authentication. | `false` |
| `REQUIRE_AUTH` / `ACCESS_ENFORCE` | Master switch for enforcement. Unset on a connected deployment is an error, not an opt-out. | unset |
| `ACCESS_IDENTITY_CACHE_SECONDS` | How long a validated credential stays eligible for fail-open. | `300` |

Checks are only sent when the service URL is set **and** enforcement is on.
That combination is what lets a service run locally, and its tests run in CI,
without the control plane.

## Direct use

```python
from caselaw_access import AccessClient, RateLimited, PermissionDenied

client = AccessClient.from_env()

try:
    decision = client.check(token, endpoint="echr", method="POST")
except RateLimited as exc:
    ...  # exc.status == 429, exc.headers() has Retry-After
except PermissionDenied as exc:
    ...  # exc.status == 403
```

An allowed `Decision` carries the resolved `principal`, the matched `plan`,
the granted `scopes`, and the remaining quota. `decision.headers()` returns
the `X-RateLimit-Remaining-*` headers to attach to the response.

## `http.server` handlers

```python
from caselaw_access.http_server import AccessGuard

guard = AccessGuard(send_error=my_json_writer)

class handler(BaseHTTPRequestHandler):
    @guard.protect("echr", methods=["POST"])
    def do_POST(self):
        ...
```

The decorated method runs only when the call is allowed; a denial is written
before the body executes. The decision is attached to the handler as
`access_decision` and `access_principal`, and the headers to emit as
`access_headers`.

The verb reported to the service is the request's real method
(`handler.command`). `methods` only shapes the CORS header on an error
response.

## Failure semantics

| Situation | Result |
| --- | --- |
| No or malformed `Authorization` header | `401`, service never called |
| Service answers 401 | `401` with the service's code |
| Service answers 403, or `allowed: false` with a scope error | `403` |
| Service answers 429, or `allowed: false` with `rate_limit_exceeded` | `429` with `Retry-After` |
| Service unreachable, times out, or answers 4xx/5xx it cannot decide on | `503`, or allowed when `ACCESS_FAIL_OPEN=true` **and** this credential was recently validated for this endpoint |

Connection failures are retried because the request provably never arrived.
Read timeouts are **not** retried: the service may already have counted the
call against the caller's quota, and a retry would charge them twice.

Behaviour is pinned by the shared conformance suite in
[`../contract`](../contract), which this package runs in `tests/test_conformance.py`.

## Releasing

Released by GitHub Actions via PyPI trusted publishing — no API token anywhere.
Bump `version` in `pyproject.toml`, commit, push, then:

```bash
git tag python-v0.2.0 && git push origin python-v0.2.0
```

The workflow runs this suite, including the shared conformance cases, before
uploading, and refuses to publish if the tag and `pyproject.toml` disagree. See
[`../README.md`](../README.md) for the full procedure.

## What fail-open does, and does not, do

`/v1/check` answers two questions at once: who is this caller, and do they
have quota left. Waiving both during an outage means any non-empty bearer
string is admitted — an authentication bypass dressed up as an availability
feature.

So `ACCESS_FAIL_OPEN=true` waives **quota only**, and only for a credential
this process has already watched the service accept **for that same endpoint**
within `ACCESS_IDENTITY_CACHE_SECONDS`. Anything else is denied exactly as if
the policy were fail-closed.

In practice: an outage does not interrupt traffic that was already flowing, and
does not let anything new in. The cost is that a credential revoked during an
outage keeps working until its cache entry expires — set
`ACCESS_IDENTITY_CACHE_SECONDS=0` to disable the mechanism entirely, which
makes `ACCESS_FAIL_OPEN` a no-op.

The pair is keyed on credential *and* endpoint on purpose. Keying on the
credential alone would let an outage promote a caller onto an endpoint they
were never entitled to, turning a quota waiver into an authorization bypass.

## Enforcement must be stated

Leaving `REQUIRE_AUTH` / `ACCESS_ENFORCE` unset while a project *and* a secret
are configured raises `access_enforcement_not_configured` rather than running
inert. A project secret is issued once, by hand, for a specific deployment, so
carrying one is unambiguous intent — an absent flag is a forgotten variable,
and guessing wrong means enforcing nothing while looking fully configured.

Set it explicitly to `false` to run inert on purpose.
