Metadata-Version: 2.4
Name: intent-api-admin
Version: 0.2.0
Summary: Admin-machine surface and auto-admin for Intent API products.
Author: Chris Bora
License-Expression: LicenseRef-IACL
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: intent-api>=0.5.0
Requires-Dist: pydantic>=2.0.0
Requires-Dist: sqlalchemy>=2.0.0
Requires-Dist: pyjwt[crypto]>=2.8.0
Requires-Dist: httpx>=0.24.0
Requires-Dist: cryptography>=41.0.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
Requires-Dist: fastapi>=0.100.0; extra == "dev"
Requires-Dist: httpx>=0.24.0; extra == "dev"
Dynamic: license-file

# Intent API Admin

**Console-issued admin tokens and zero-boilerplate admin surfaces for any Intent API product.**

`intent-api-admin` plugs into [`intent-api`](https://pypi.org/project/intent-api/) to add a dedicated admin-machine surface, Ed25519/JWKS-verified admin tokens, an automatic CRUD admin generator from your SQLAlchemy models, role-aware role gates, audit correlation, and a schema document the console can render directly.

## Install

```bash
pip install intent-api-admin
```

Requires `intent-api>=0.5.0` and Python 3.10+.

## Three-step integration

```python
from intent_api import IntentRouter
from intent_api_admin import (
    configure_admin_auth, auto_admin, admin_machine_auth,
)

configure_admin_auth(
    issuer="https://console.example.com",
    audience="myapp",
    base=Base,
)

router = IntentRouter()
router.register("User", UserService())
auto_admin(router, expose=["User", "Team"])

app.include_router(router.build(get_user=get_clerk_user, get_db=get_db))
app.include_router(router.build_admin_machine(get_user=admin_machine_auth(), get_db=get_db))
```

That is the entire integration. Console operators now get full read/write CRUD on every model in `expose=[...]`, and your handlers are unchanged.

## Impersonation (Clerk actor tokens)

Impersonation consumes Clerk's native actor tokens, minted per-product via the product's own Clerk SDK. The console never holds product Clerk credentials and never mints Clerk tokens itself.

> The visual indicator is a UX hint, not a guarantee. Always confirm
> impersonation state via the console's session list before assuming the
> absence of an indicator means you're not impersonating. The security
> boundary is server-side.

### Two-step integration

**Step 1 — Mint.** Add a `User.create_clerk_actor_token` custom action that calls `clerk_sdk.actor_tokens.create(...)`. The full reference adapter is in [docs/CLERK_ACTOR_TOKEN_ADAPTER.md](docs/CLERK_ACTOR_TOKEN_ADAPTER.md).

**Step 2 — Enforce.** Call `enforce_actor_writes` at the top of any write handler, OR install `actor_writes_middleware` once in your runtime.

```python
# Per-handler
from intent_api_admin import enforce_actor_writes

@custom_action(admin_only=True)
async def ban(self, *, db, user, context, id, payload, session_claims=None):
    enforce_actor_writes(session_claims or {}, action="custom",
                         is_read_only_custom_action=False)
    ...

# Or middleware (one-time install)
from intent_api_admin import actor_writes_middleware
runtime = build_runtime(middlewares=[actor_writes_middleware])
```

### Audit context — one line in `get_user`

`bind_impersonation_context` populates `acting_admin_id` and `impersonation_session_id` as structlog contextvars so every log line in the handler inherits them. One line in your auth dependency:

```python
from intent_api_admin import bind_impersonation_context

async def get_current_user(...):
    auth_state = clerk_sdk.authenticate_request(...)
    bind_impersonation_context(auth_state.payload)   # one-liner
    context.clerk_claims = auth_state.payload
    ...
```

### CSP — `frame-ancestors 'none'`

The console always launches impersonation in a new tab via `window.open(url, "_blank", "noopener,noreferrer")`. Set CSP `frame-ancestors 'none'` on every product response so this architecture self-enforces — even if a future change tries to embed your product, browsers will refuse.

```python
@app.middleware("http")
async def add_csp_headers(request, call_next):
    response = await call_next(request)
    response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
    return response
```

See [docs/PRODUCT_ADOPTION.md](docs/PRODUCT_ADOPTION.md) for the full per-product checklist (~60 LOC) and [SECURITY.md](SECURITY.md) for the V1 threat model.

## Roles

Three roles, ordered by rank:

| Role     | Rank | Default for                                  |
|----------|-----:|----------------------------------------------|
| `owner`  |    3 | `delete` on auto-admin services              |
| `admin`  |    2 | `update`, default for `@custom_action(admin_only=True)` |
| `support`|    1 | `list`, `read`                               |

A higher-ranked role satisfies any lower-ranked requirement. So an `owner` token can perform any action that requires `support`, `admin`, or `owner`.

## Heuristic defaults

`auto_admin()` reads each SQLAlchemy model and picks sensible defaults you almost never need to override.

- `list_fields` — primary key + `name` / `email` / `title` / `slug` / `status` / `created_at` if present, then non-text columns up to 6 total.
- `search_fields` — string columns with `index=True` or `unique=True` and `length<=255`.
- `default_sort` — `created_at desc` if present; otherwise `<primary_key> desc`.
- Auto-redaction patterns — any column whose name starts with `password`, `secret`, `api_key`, `token`, or `_`, or ends with `_hash` / `_digest`. Redacted fields are stripped from responses *and* refused on update.

## Override patterns

```python
auto_admin(
    router,
    expose=["User", "Team", "Order"],
    redact_fields={"User": ["last_login_ip"]},          # add to auto-redaction
    deny_edit_fields={"User": ["email"]},                # read-only in admin
    deny_delete=["Order"],                                # disallow delete entirely
    overrides={                                           # surgical schema override
        "User": {"default_sort": "email asc",
                 "list_fields": ["id", "email", "name", "plan", "is_active"]},
    },
)
```

## Custom admin actions

Use the augmented `@custom_action` from `intent_api_admin` for admin-only commands. They are dispatched on the admin-machine surface only — invisible to standard, MCP, machine, and the legacy admin route.

```python
from intent_api_admin import custom_action

class UserService(IntentService):
    @custom_action(admin_only=True, min_role="admin")
    async def ban(self, *, db, user, context, id, payload):
        ...

    @custom_action(admin_only=True, min_role="support", read_only=True)
    async def view_audit(self, *, db, user, context, id, payload):
        ...
```

`read_only=True` is honored by `enforce_actor_writes` (see above) so the action remains callable under a read-only Clerk actor session.

## Common errors and fixes

| Code                          | When                                                                | Fix |
|------------------------------|---------------------------------------------------------------------|-----|
| `ADMIN_AUTH_NOT_CONFIGURED`  | `auto_admin()` called before `configure_admin_auth()`               | Call `configure_admin_auth(...)` first. |
| `CONFIGURATION_ORDER_ERROR`  | `configure_admin_auth()` called again after `auto_admin()` ran      | Reset only in tests; in prod call exactly once at startup. |
| `MODEL_NOT_FOUND`            | `expose=["X"]` but `X` isn't on `Base.registry`                     | Make sure the model is imported before `auto_admin()`. |
| `WRONG_SURFACE`              | Admin token on `/api/intent`                                        | Use `/api/admin-machine-intent` for admin tokens. |
| `IMPERSONATION_WRITE_DENIED` | A write ran under a read-only Clerk actor session                   | Mint the actor token with `allow_writes=true`, or mark the action `read_only=True`. |
| `INSUFFICIENT_ROLE`          | Token role rank is below the action's `min_role`                    | Issue a token with the required role. |
| `FIELD_REDACTED`             | Update payload references a redacted field                          | Drop the field from payload, or remove from `redact_fields`. |
| `FIELD_READ_ONLY`            | Update payload references a `deny_edit_fields` field                | Drop the field, or remove the deny entry. |
| `DELETE_DENIED`              | Delete on a `deny_delete` model                                     | Remove the model from `deny_delete=[...]`. |
| `UNSUPPORTED_FILTER_OP`      | List filter uses an op the auto-admin filter parser doesn't support | Use one of `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `contains`, `in`, `is_null`. |

See [TOKEN_CONTRACT.md](TOKEN_CONTRACT.md) for the full token spec.

## Reference test suite

`tests/wbs_reference/` is an exhaustive contract suite that mirrors the WBS success criteria S1–S52. Copy it into your repo to validate every adoption.
