---
audience: operators
summary: "Rotate webhook secrets, audit quota usage, manage roles \u2014 the operator\
  \ side of ScaiScribe."
title: Admin workflows
path: tutorials/admin
status: published
---


# Tutorial: Admin workflows

You'll: grant a tenant_admin role, audit quota usage at the partner and tenant level, and rotate a webhook signing secret. These are the operator workflows you'll do most often.

All examples use the Python SDK's admin sub-client. The same operations are available in the browser at `/admin/`; this tutorial covers the programmatic / scripted version.

## Setup

You need a token with admin-level scope. For the SDK that means authenticating as a user who holds at least `tenant_admin` (or higher) for the operations below:

```python
import os
from scaiscribe import Client, ScaiKeyAuth

client = Client(
    "https://scaiscribe.scailabs.ai",
    auth=ScaiKeyAuth(
        client_id=os.environ["SCAIKEY_CLIENT_ID"],
        client_secret=os.environ["SCAIKEY_CLIENT_SECRET"],
    ),
    default_tenant_id=os.environ["SCAISCRIBE_TENANT_ID"],
)

admin = client.admin
```

## Workflow 1 — Grant a role

A new colleague joins the marketing tenant. Make them a `tenant_admin`:

```python
role = admin.grant_role(
    "usr_alice_marketing",
    role="tenant_admin",
    tenant_scaikey_id="tnt_marketing",
)

print(f"Granted role_id={role.role_id} at {role.granted_at}")
print(f"By: {role.granted_by_scaikey_id}")
```

Confirm the grant:

```python
roles = admin.list_user_roles("usr_alice_marketing")
for r in roles:
    print(f"  {r.role} on tenant={r.tenant_scaikey_id} (active: {r.revoked_at is None})")
```

Revoke later if needed:

```python
admin.revoke_role("usr_alice_marketing", role.role_id)
```

Revocation stamps `revoked_at` on the row — it stays in the database for audit. You can still list it with `include_revoked=True`.

### Role precedence cheat sheet

| Need to grant… | …requires at least |
|---|---|
| `member` on tenant X | `tenant_admin` on X (or higher in scope) |
| `tenant_admin` on tenant X | `partner_admin` on X's partner (or `superadmin`) |
| `partner_admin` on partner Y | `superadmin` |
| `superadmin` | `superadmin` |

The server enforces this — attempts to grant above your level get a 403.

## Workflow 2 — Partner quota rollup

End of month — check how much your partner's tenants used:

```python
rollup = admin.partner_quota_rollup(
    "prt_acme",
    axis="renders_per_day",
)

print(f"Partner: {rollup.partner_scaikey_id}")
print(f"Across {rollup.tenant_count} tenant(s)")
print(f"Total today: {rollup.total_used} / {rollup.total_limit}\n")

print("Per tenant:")
for t in sorted(rollup.per_tenant, key=lambda x: -x.used):
    pct = (t.used / t.limit * 100) if t.limit > 0 else 0
    print(f"  {t.tenant_id}: {t.used}/{t.limit} ({pct:.0f}%)")
```

Output:

```
Partner: prt_acme
Across 12 tenant(s)
Total today: 4823 / 12000

Per tenant:
  tnt_alpha: 1450/1000 (145%)    ← over quota; got 429s today
  tnt_beta:   850/1000 (85%)
  tnt_gamma:  742/1000 (74%)
  ...
```

The `tnt_alpha` over-quota signal is real: tenants over 100% are returning 429 to their callers. Bump their quota (code change in `_DEFAULTS` for now; per-tenant overrides arrive in v1.x) or have a conversation.

Other axes work the same way:

```python
admin.partner_quota_rollup("prt_acme", axis="documents_count")
admin.partner_quota_rollup("prt_acme", axis="assets_bytes")
admin.partner_quota_rollup("prt_acme", axis="templates_count")
```

## Workflow 3 — Per-user breakdown within a tenant

Someone's tenant got a 429 yesterday. Who's responsible?

```python
rollup = admin.tenant_user_quota_rollup(
    "tnt_alpha",
    axis="renders_per_day",
)

print(f"Coverage: {rollup.coverage}")
print(f"Total: {rollup.total_used}\n")

for u in sorted(rollup.per_user, key=lambda x: -x.used):
    print(f"  {u.user_scaikey_id}: {u.used}")
```

Output:

```
Coverage: full
Total: 1450

  usr_batch_runner: 1240   ← the culprit
  usr_alice:         140
  usr_bob:            70
```

A batch job ran the tenant into the ceiling. Decide: rate-limit the batch, raise the tenant's quota, or move the batch to its own tenant.

### Coverage matrix reminder

The `coverage` field reports whether per-user attribution is available for the axis:

| Axis | Coverage |
|---|---|
| `renders_per_day` | `full` (from the `quota_periods_user` table) |
| `documents_count` | `full` (from `Spec.created_by` of v1 specs) |
| `assets_bytes` | `none` — Asset/Font tables don't track `created_by` yet |
| `templates_count` | `none` — Template table doesn't track `created_by` yet |

When `coverage` is `none`, `per_user` is empty and `note` explains. v1.x will close those gaps.

## Workflow 4 — Rotate a webhook signing secret

ScaiKey webhook calls into `/v1/internal/scaikey/webhook` carry an HMAC signature computed with a shared secret. Rotate when you suspect compromise or as part of a routine quarterly drill:

```python
# Superadmin-only
rotation = admin.rotate_webhook_secret()

print(f"New secret: {rotation.new_secret}")
print(f"Rotated at: {rotation.rotated_at}")
print(rotation.note)
```

Sample output:

```
New secret: aBc123_XyZ-456...64-char-url-safe-base64...
Rotated at: 2026-06-14T08:42:17+00:00
Note: Install this value on the ScaiKey application's webhook
      configuration, then set SCAISCRIBE_SCAIKEY_WEBHOOK_SECRET to
      the same value and restart scaiscribe-api. Until both sides
      match, webhook events will return 401 signature mismatch.
```

The new secret is shown **once**. Do this immediately:

1. Open the ScaiKey admin panel; navigate to the ScaiScribe application's webhook settings.
2. Paste the new secret in the secret field. Save.
3. SSH to the ScaiScribe backend host. Edit `.env`:
   ```bash
   SCAISCRIBE_SCAIKEY_WEBHOOK_SECRET=<the-new-secret>
   ```
4. Restart the API process: `systemctl restart scaiscribe-api` (or equivalent).
5. Confirm: trigger a webhook (e.g. a ScaiKey user update) and check the access log for `200`.

Between step 1 and step 4, webhook events return 401 (signature mismatch). Plan the rotation for a maintenance window or accept a brief gap in user-sync events — the daily reconciliation at 03:00 UTC catches anything missed.

## Workflow 5 — Inspect a failed job

A render came back 500. What happened?

```python
# Get the job
job = admin.get_job("job_01HXYZ...")

print(f"Status: {job.status}")
print(f"Operation: {job.operation}")
print(f"Submitted: {job.submitted_at}")
print(f"Completed: {job.completed_at}")
print(f"\nError: {job.error}\n")

if job.result_json:
    print("Result payload:")
    import json
    print(json.dumps(job.result_json, indent=2))
```

Sample output for a failed PDF render:

```
Status: failed
Operation: render_pdf
Submitted: 2026-06-14T08:30:15+00:00
Completed: 2026-06-14T08:30:48+00:00

Error: gotenberg returned 502: upstream timed out

Result payload:
{
  "code": "RENDER_FAILED",
  "message": "gotenberg returned 502: upstream timed out",
  "job_id": "job_01HXYZ...",
  "doc_id": "doc_01HXYZ...",
  "version": 2
}
```

The error envelope mirrors what the caller saw. Combined with the operation and document/version refs, you can reproduce the failure locally.

## Going further

- The admin SPA at `/admin/` exposes all of these workflows in a browser UI. Use it for ad-hoc exploration.
- The runbook at `docs/manual/operations/runbook.md` (operator manual) lists health-check endpoints, common-task recipes, troubleshooting, and capacity signals.
- For batch operations (e.g. grant the same role to 50 users), script with the SDK — the admin sub-client is idempotent where it can be and surfaces structured errors where it can't.

## Where to next

- [Concepts → Auth + quotas](/docs/scaiscribe/concepts/auth-and-quotas) — the model behind the rollup endpoints.
- [Reference → SDKs](/docs/scaiscribe/reference/sdks) — admin sub-client method-by-method coverage matrix.
- [Reference → API](/docs/scaiscribe/reference/api) — `/v1/admin/*` shapes.
