Metadata-Version: 2.4
Name: odcp-client
Version: 1.0.0
Summary: Official Python SDK for the Owndivision Control Plane (license verify, JWKS, whoami caching).
Author: Owndivision
License: Proprietary
Project-URL: Homepage, https://github.com/owndivision/owndivision-control-plane
Keywords: owndivision,control-plane,sdk
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: odcp-contracts<3,>=2.2
Requires-Dist: httpx<0.29,>=0.27
Requires-Dist: pyjwt<3,>=2.10.1
Requires-Dist: cryptography>=42
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: pytest-cov>=5; extra == "dev"
Requires-Dist: respx>=0.21; extra == "dev"
Requires-Dist: freezegun>=1.5; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"

# odcp-client

Official Python SDK for the Owndivision Control Plane. Provides offline-capable
license JWT verification with JWKS caching, whoami lookup with revision-aware
cache invalidation, and branding retrieval — all with both sync and async clients.

## Install

```bash
pip install odcp-client
```

Requires Python 3.12+. Runtime dependencies: `odcp-contracts>=2.2,<3`, `httpx`,
`pyjwt`, `cryptography`.

## Quickstart (sync)

```python
from odcp_client import OdcpClient, LicenseInvalid, WhoamiNotFound

client = OdcpClient(
    base_url="https://cp.your-domain.com",
    api_token="your-cp-api-token",
    license_token="eyJ...",  # the signed license JWT
    # Required when CP is configured with non-default iss/aud — must match
    # CP's CP_SIGNING_ISS / CP_SIGNING_AUD. Defaults are
    # "owndivision-control-plane" / "owndivision-dp".
    signing_issuer="owndivision-control-plane",
    signing_audience="owndivision-dp",
)

# Verify the license (offline after first JWKS fetch)
try:
    claims = client.verify_license()
    print(f"Seat cap: {claims.license.seat_cap}")
except LicenseInvalid as exc:
    print(f"License invalid: {exc.reason}")

# Whoami lookup with revision-aware caching
try:
    whoami = client.whoami(
        provider="auth0",
        subject="auth0|user_id",
        workspace_id="ws-uuid",
    )
    print(whoami.user.email)
except WhoamiNotFound:
    print("Unknown identity")

# Permission check (never raises)
can_read = client.has_permission(
    provider="auth0",
    subject="auth0|user_id",
    workspace_id="ws-uuid",
    permission="workspace.read",
)

client.close()  # or use as a context manager: `with OdcpClient(...) as client:`
```

## Quickstart (async)

```python
import asyncio
from odcp_client import AsyncOdcpClient

async def main() -> None:
    async with AsyncOdcpClient(
        base_url="https://cp.your-domain.com",
        api_token="your-cp-api-token",
        license_token="eyJ...",
    ) as client:
        claims = await client.verify_license()
        whoami = await client.whoami(
            provider="auth0", subject="auth0|user_id", workspace_id="ws-uuid",
        )
        can_write = await client.has_permission(
            provider="auth0",
            subject="auth0|user_id",
            workspace_id="ws-uuid",
            permission="workspace.write",
        )

asyncio.run(main())
```

## FastAPI dependency recipe

```python
from fastapi import Depends, HTTPException, Request
from odcp_client import AsyncOdcpClient, WhoamiNotFound
from odcp_contracts.whoami import WhoAmIResponse

# Construct once at app startup (shared across requests).
odcp = AsyncOdcpClient(
    base_url=settings.CP_BASE_URL,
    api_token=settings.CP_API_TOKEN,
    license_token=settings.LICENSE_TOKEN,
)


async def get_current_user(request: Request) -> WhoAmIResponse:
    sub = extract_auth0_sub(request)          # your Auth0 subject extractor
    ws_id = extract_workspace_from_license(request)  # your workspace resolver
    try:
        return await odcp.whoami(
            provider="auth0", subject=sub, workspace_id=ws_id
        )
    except WhoamiNotFound:
        raise HTTPException(status_code=401, detail="unknown_identity")


@app.get("/protected")
async def protected(user: WhoAmIResponse = Depends(get_current_user)) -> dict:
    return {"email": user.user.email}
```

## Cache backends

The default backend is an in-process LRU+TTL cache (`InMemoryTTLCache`). For
multi-process deployments, swap in a Redis backend:

```python
import pickle
import redis

class RedisCacheBackend:
    def __init__(self, redis_client: redis.Redis) -> None:
        self._r = redis_client

    def get(self, key: str):
        value = self._r.get(key)
        return None if value is None else pickle.loads(value)

    def set(self, key: str, value, ttl_seconds: int) -> None:
        self._r.setex(key, ttl_seconds, pickle.dumps(value))

    def delete(self, key: str) -> None:
        self._r.delete(key)


client = OdcpClient(
    base_url="https://cp.your-domain.com",
    cache_backend=RedisCacheBackend(redis.Redis()),
)
```

> **Important:** Custom backends MUST be thread-safe and SHOULD be
> picklable-friendly. Async clients call backend methods via
> `asyncio.to_thread` automatically when the backend is not an
> `InMemoryTTLCache` instance.

## License verification semantics

`verify_license()` works **offline after the first JWKS fetch**. The SDK fetches
`/api/v1/.well-known/jwks.json` once (default TTL: 3600 s), caches RSA public
keys by `kid`, and verifies subsequent tokens locally.

Key rotation is handled transparently: if a token references an unknown `kid`, the
SDK forces a JWKS refresh before raising. `LicenseInvalid.reason` codes:

| Reason | Meaning |
|--------|---------|
| `expired` | Token has passed its `exp` claim |
| `bad_signature` | Signature verification failed |
| `wrong_issuer` | `iss` claim does not match |
| `wrong_audience` | `aud` claim does not match |
| `unknown_kid` | No signing key found for the token's `kid` |
| `malformed` | Token is structurally invalid or claims fail validation |
| `jwks_unavailable` | CP JWKS endpoint could not be reached |

## Revision-aware whoami

`whoami()` caches responses keyed by `(provider, subject, workspace_id, rbac_revision)`.
When CP increments `rbac_revision` for a workspace (on role/permission change), the
pointer entry expires or mismatches, causing the next call to re-fetch automatically.

No explicit cache invalidation is needed on the DP side.

> **Note:** During the Plan 02 rollout window, CP may omit `rbac_revision`. The
> SDK degrades gracefully to TTL-only caching in that case.

## Versioning & compatibility

`odcp-client 1.x` requires `odcp-contracts>=2.2,<3`. The public API is frozen at
v1.0:

```python
from odcp_client import OdcpClient, AsyncOdcpClient, OdcpError, LicenseInvalid, WhoamiNotFound
```

Everything else (modules with a leading underscore, internal classes) may change
without a major version bump.

## Development

```bash
git clone https://github.com/owndivision/owndivision-control-plane
cd owndivision-control-plane
pip install -e odcp_client[dev] -e .
pytest tests/odcp_client --cov=odcp_client --cov-fail-under=90
```

See [docs/plans/06_odcp_client_package.md](../docs/plans/06_odcp_client_package.md)
and [docs/specs/06_odcp_client_package/](../docs/specs/06_odcp_client_package/) for
implementation details.
