Metadata-Version: 2.4
Name: caselaw-access
Version: 0.3.1
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: flask
Requires-Dist: flask>=2.2; extra == "flask"
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.100; extra == "fastapi"
Provides-Extra: django
Requires-Dist: django>=4.2; extra == "django"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: flask>=2.2; extra == "dev"
Requires-Dist: fastapi>=0.100; extra == "dev"
Requires-Dist: django>=4.2; extra == "dev"
Requires-Dist: httpx>=0.27; 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.

## Framework adapters

The client itself is framework-agnostic — `check()` takes a token and returns a
decision. These wrap it so guarding a route is one import and one decorator.
Each imports its own framework, so install the extra you need:

```bash
pip install "caselaw-access[flask]"     # or [fastapi], or [django]
```

**Flask** — `@protect` goes *below* the route decorator, so Flask registers the
guarded function:

```python
from caselaw_access.flask import protect

@app.get("/search")
@protect("search")
def search():
    ...
```

**FastAPI / Starlette** — as a route dependency, or as a parameter when the
endpoint wants the decision:

```python
from caselaw_access.fastapi import Access, requires

@app.get("/search", dependencies=[requires("search")])
async def search():
    ...

@app.get("/quota")
async def quota(access: Access("quota")):
    return {"remaining": access.remaining_minute}
```

**Django** — on a view, or through `method_decorator` for class-based views and
DRF's `APIView`:

```python
from caselaw_access.django import protect

@protect("search")
def search(request):
    ...
```

**`http.server`** — see `caselaw_access.http_server`, which additionally takes
the writer your API already uses for error bodies.

Whichever you use, publish the scopes once at startup, after the modules
holding your routes have been imported:

```python
from caselaw_access import runtime

runtime.publish_manifest()
```

The registry is shared, so an app that mixes adapters — a Flask blueprint
beside a plain `http.server` handler during a migration — publishes one
manifest covering all of it.

On a denial the response is already written when the handler is skipped:
status, JSON body, and the `Retry-After` and `X-RateLimit-*` headers. Those
headers are the part hand-written glue reliably drops, because nothing fails
when they are missing — the caller simply never learns when to retry.

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