Metadata-Version: 2.4
Name: lars-kluijtmans-admin-sdk
Version: 0.1.0
Summary: Python admin SDK for the Platform Management API
Project-URL: Homepage, https://github.com/LarsKluijtmans/auth
Project-URL: Source, https://github.com/LarsKluijtmans/auth/tree/main/admin-sdk-python
Author: Platform
License: MIT
Keywords: admin,auth,oauth,rbac,sdk
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: build>=1; extra == 'dev'
Requires-Dist: flake8>=7; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: twine>=5; extra == 'dev'
Description-Content-Type: text/markdown

# lars-kluijtmans-admin-sdk

A Python admin SDK for the **Platform** auth-domain APIs — modelled on the Firebase
Authentication Admin SDK. From a server-side program: read projects, clients and providers,
manage a project's custom OAuth scopes, and read/update users (active state, custom claims,
password-reset/verification emails).

The users/projects/clients/providers/scopes surface is served by the **auth-api** (`/auth/v1`, default
`http://127.0.0.1:8050`); `client.notifications` and `client.logs` / `client.usage` call the
notification-api and logs-api. All accept the same M2M token.

```bash
pip install lars-kluijtmans-admin-sdk
```

The import package stays `platform_admin` (`from platform_admin import AdminClient`).
Requires Python 3.10+.

## Authenticating

Two credential modes.

### M2M (recommended for servers)

A **service client** with the `client_credentials` grant. The SDK runs the grant against the
Login API, then caches and refreshes the token for you:

```python
from platform_admin import AdminClient

client = AdminClient.from_client_credentials(
    login_api_url="https://login.example.com",
    auth_url="https://auth.example.com",   # auth-api; default http://127.0.0.1:8050
    client_id="client_xxx",
    client_secret="…",
)
```

### Bring-your-own token

Attach an admin access token you already hold (e.g. from a logged-in session):

```python
client = AdminClient(auth_url="https://auth.example.com", token=ACCESS_TOKEN)
```

What a credential may do is governed by RBAC: a **user** token uses that user's role; a **service
client** uses the role assigned to it (see *Setting up a service client* below).

`AdminClient` is a context manager and closes its HTTP connection on exit:

```python
with AdminClient.from_client_credentials(...) as client:
    ...
```

## Using it

```python
# Projects
project  = client.projects.get(project_id)
projects = client.projects.list(company_id)

# Clients & providers
clients   = client.clients.list(project_id)
client.clients.set_service_role(project_id, client_id, role_id)  # authorize an m2m client (None clears)
provider  = client.providers.catalog()            # platform catalog
enabled   = client.providers.list(project_id)      # per-project, with enable state

# Scopes — a project's custom OAuth scopes (read: scopes:read, mutate: scopes:write)
scopes = client.scopes.list(project_id)
scope  = client.scopes.create(project_id, "invoices:read", description="Read invoices")
client.scopes.update(project_id, scope.id, description="Read all invoices")   # only description is mutable
client.scopes.delete(project_id, scope.id)

# Users — read
users = client.users.list(project_id, search="ann", status="active")  # one page
for user in client.users.iter(project_id):                            # all pages
    print(user.email, user.is_active)
user = client.users.get(project_id, user_id)

# Users — update (active state, claims, and profile fields)
client.users.set_active(project_id, user_id, False)
client.users.set_claims(project_id, user_id, {"tier": "gold"})
client.users.set_profile(project_id, user_id, {"display_name": "Ann", "language": "nl"})
# Firebase-style: apply whichever fields you pass (profile fields accept None to clear).
client.users.update(project_id, user_id, active=True, claims={"tier": "silver"},
                    display_name="Ann", language="nl", profile_picture="https://cdn/x.png")

# Users — remediation
client.users.send_password_reset(project_id, user_id)
client.users.resend_verification(project_id, user_id)
client.users.force_logout(project_id, user_id)
```

Every model exposes `.raw` (the full API payload) so new/unknown fields are always reachable.

> The admin can edit **display name, language, and profile picture** (`set_profile` / `update`),
> flip active state, set claims, and trigger the reset/verification emails. **Email and password
> changes remain self-service** on the Login API — they are not part of the admin surface.

## Notifications

`client.notifications` drives the **notification-api** — a separate service at its own base URL
(default `http://127.0.0.1:8020`, override with `notifications_url=`). It reuses the same M2M token;
your service client's role needs `notifications:send` / `notifications:read` /
`notifications:configure`.

```python
client = AdminClient.from_client_credentials(
    login_api_url="https://login.example.com",
    auth_url="https://auth.example.com",              # default http://127.0.0.1:8050
    notifications_url="https://notify.example.com",   # default http://127.0.0.1:8020
    client_id="client_xxx",
    client_secret="…",
)

# Send (channel = "email" | "sms" | "inapp" | "push"; for inapp/push `to` is the user's id)
result = client.notifications.send(
    "email", "user@example.com", template_key="welcome", context={"name": "Ann"}, project_id=project_id
)
print(result.message_id, result.status, result.provider)

# Delivery history — one page (limit/offset) or iterate every event (recipients are hashed)
page = client.notifications.list_messages(channel="email", status="sent")   # page.items / page.total
for event in client.notifications.iter_messages():
    print(event.channel, event.status, event.recipient_hash)

# Settings (provider secrets are write-only; a blank/absent secret keeps the stored one)
settings = client.notifications.get_settings(project_id)            # None if unset
client.notifications.update_settings(project_id=project_id, sender_name="Acme",
                                    email={"provider": "smtp", "password": "…"})

# Custom templates
client.notifications.templates.list(project_id=project_id)
tpl = client.notifications.templates.create("welcome", "Hi {{name}}", subject="Welcome")
client.notifications.templates.update(tpl.id, "Hello {{name}}", subject="Hi")
client.notifications.templates.delete(tpl.id)
```

## Logs & usage

`client.logs` and `client.usage` drive the **logs-api** — a separate service at its own base URL
(default `http://127.0.0.1:8030`, override with `logs_url=`). It reuses the same M2M token; your
service client's role needs `logs:write` / `logs:read` / `usage:read`.

```python
client = AdminClient.from_client_credentials(
    login_api_url="https://login.example.com",
    auth_url="https://auth.example.com",   # default http://127.0.0.1:8050
    logs_url="https://logs.example.com",   # default http://127.0.0.1:8030
    client_id="client_xxx",
    client_secret="…",
)

# Ingest a batch of 1–500 application log events (logs:write); returns the accepted count.
result = client.logs.write([
    {"level": "info", "category": "billing", "message": "invoice generated",
     "metadata": {"invoice_id": "inv_1"}, "project_id": project_id},
])
print(result.accepted)

# Query the merged log/audit stream (logs:read). `start`/`end` are ISO-8601 (sent as from/to);
# source = "logs" | "audit" | "all". One page (limit/offset) or iterate every entry.
page = client.logs.query(level="info", category="billing", source="all",
                        start="2026-01-01T00:00:00Z", end="2026-02-01T00:00:00Z")
for entry in client.logs.iter(source="audit"):
    print(entry.kind, entry.timestamp, entry.message, entry.action)

# Aggregated usage (usage:read): totals + a per-bucket timeseries. bucket = "day" | "hour".
usage = client.usage.summary(start="2026-01-01T00:00:00Z", end="2026-02-01T00:00:00Z", bucket="day")
print(usage.summary)                       # {login_success, login_failure, tokens_issued, active_sessions}
for point in usage.timeseries:
    print(point.bucket_start, point.metrics)
```

## Setting up a service client

1. **Create a client** for the project (Management console → project → Clients, or the API) and
   enable the **`client_credentials`** grant on it. Keep the `client_id` + secret.
2. **Assign it a role** — the permissions an m2m token from this client gets:
   ```http
   PUT /auth/v1/projects/{project_id}/clients/{client_id}/service-role
   { "role_id": "<a role id>" }
   ```
   (Needs `clients:write`.) A client with no service role can mint a token but is denied everything.
3. Use `AdminClient.from_client_credentials(...)` with that client's id + secret.

The service account is always scoped to its own company/project and is never a platform admin.

## Multiple services (base-URL registry)

`client.notifications` / `client.logs` / `client.usage` don't call the auth-api — they call the
notification-api and logs-api, which are **separate services at their own base URLs that accept the
same token**. The client keeps a small registry of service base URLs keyed by name
(`auth` / `notifications` / `logs`); each resource declares which service it targets and the
transport resolves the base URL from the registry. The defaults are set from `auth_url=` (primary) /
`notifications_url=` / `logs_url=` on the constructor, but you can register or override any service by
name:

```python
client.register_service("notifications", "https://notify.example.com")
client.base_url_for("logs")    # -> the logs-api base URL this client routes logs/usage to
client.notifications_base_url  # convenience property; same as base_url_for("notifications")
```

This is the SDK's module-extension seam: a new module's API surface is **one registered service plus
one resource class** (a `Resource` subclass that sets `SERVICE = "<name>"`) — no new constructor
argument or per-service plumbing.

## Errors

Failures raise typed exceptions — `AuthError` (401), `Forbidden` (403), `NotFound` (404),
`Conflict` (409), `ValidationError` (422), `NetworkError` — all subclasses of `AdminError`
(carrying `.status` and `.detail`):

```python
from platform_admin import NotFound, Forbidden

try:
    client.users.get(project_id, user_id)
except NotFound:
    ...
except Forbidden as exc:
    print(exc.status, exc.detail)
```

See [`examples/`](examples/) for runnable scripts (M2M and bring-your-own-token).
