# UUM — External Platform Integration Guide

> **Document:** `frontend/admin/public/INTEGRATIONS.md`  
> **Audience:** Backend developers integrating a third-party service with UUM SSO, plans, and RBAC.  
> **Version:** 3.3 — May 2026  
> **Status:** Verified against real API (`https://uum.sandboxlabs.uk`) on 2026-05-20. Updated 2026-05-29 with deletion detection documentation (polling-based tenant cleanup via provisioning API).

---

## 1. Executive Summary

UUM (Unified User Management) is the **Identity Provider (IdP)** of Sandbox Labs. Any external platform that needs to authenticate users, know their role, or manage tenant access can integrate with UUM instead of reimplementing login, registration, or RBAC.

| Problem | What UUM solves |
|----------|-----------------|
| Login / password reset | UUM authenticates. Your service never sees passwords. |
| Multi-tenancy (which company is this user from?) | The SSO token includes `tenant_id` and `roles`. |
| RBAC per service | The admin assigns roles in UUM; your service only reads the token. |
| Customer onboarding | The MSP creates sub-tenants and users; your service receives the data via token or API. |

**You do NOT need:**
- A `users` table with passwords.
- "Forgot password" logic.
- A from-scratch roles system.
- Manual user–company relationship management.

**You DO need:**
- An endpoint `GET /auth/sso` (or your preferred path) that receives the SSO token from UUM.
- A call to `POST /api/v1/provisioning/validate-token` to verify the token.
- (Optional) An endpoint `GET /plans` for automatic plan synchronization.
- (Optional but recommended) **JIT Provisioning** — create local users the first time they arrive.

Throughout this document we use **ACME** as the fictitious external service. Replace `acme` with your actual `service_slug` wherever you see it.

---

## 2. Agent Instructions

If you are an AI agent tasked with implementing this integration, read this section first.

### 2.1. Your TODO Checklist

```
[ ] 1. CONFIGURE your service in UUM admin panel or via POST /api/v1/admin/services
[ ] 2. STORE the API key generated by UUM as UUM_API_KEY (env var, never in repo)
[ ] 3. CREATE a shadow local_users table (see § 10)
[ ] 4. IMPLEMENT GET /auth/sso endpoint (receives ?sso_token=<jwt>)
[ ] 5. IMPLEMENT JIT provisioning in /auth/sso:
        - POST /api/v1/provisioning/validate-token
        - Find-or-create local user
        - Create local session (JWT or httpOnly cookie)
        - Redirect to dashboard
[ ] 6. [OPTIONAL] IMPLEMENT onboarding flow (see § 6.4):
        - After JIT, check if local user has all required internal fields
        - If missing → redirect to /onboarding (authenticated page)
        - POST /api/onboarding/complete saves to local DB
        - Subsequent logins skip onboarding → go directly to /dashboard
[ ] 7. IMPLEMENT GET /api/auth/me (reads local session, returns user)
[ ] 8. IMPLEMENT POST /api/auth/logout (clears local session)
[ ] 9. IMPLEMENT middleware: if session expired → redirect to UUM login URL
[ ] 10. [OPTIONAL] IMPLEMENT GET /plans catalog endpoint
[ ] 11. [OPTIONAL] IMPLEMENT periodic user sync cron job
[ ] 12. [OPTIONAL] IMPLEMENT deletion detection (see § 8.4):
         - Poll GET /provisioning/services/{slug}/users for pending_deletions
         - Delete local tenant data when seeing pending_deletion status
         - Confirm via DELETE /provisioning/services/{slug}/tenants/{tenant_id}
[ ] 13. ADD role-based guards using the `roles` field from the local session
[ ] 14. VERIFY integration end-to-end (see § 2.3)
```

### 2.2. Environment Variables You Must Set

```bash
# REQUIRED
UUM_BASE_URL="https://uum.sandboxlabs.uk/api/v1"
UUM_API_KEY="acme_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
UUM_SERVICE_SLUG="acme"

# YOUR OWN
LOCAL_JWT_SECRET="..."             # secret for signing your own session JWTs
LOCAL_SESSION_TTL_HOURS="24"       # how long your local sessions last
```

> **Important:** `UUM_API_KEY` is exposed **in plain text only once** when the UUM admin generates it via `POST /admin/services/{id}/api-key`. Store it securely. UUM stores only the hash.

### 2.3. Verification Tests

Run these checks before declaring the integration production-ready:

```
[ ] SSO happy path:
    1. A user with the "user" role on "acme" logs into UUM
    2. They click the ACME card (appears in "subscribed" list with roles)
    3. UUM redirects to https://acme.com/auth/sso?sso_token=<jwt>
    4. Your /auth/sso calls POST /provisioning/validate-token → 200
    5. Response includes: user_id, email, tenant_id, tenant_slug, tenant_name, roles, plan_slug
    6. A local user row is created (or updated if tenant/roles/plan changed)
    7. A local cookie/JWT is set
    8. User reaches the dashboard

[ ] SSO rejected — no role:
    1. A user WITHOUT a role on "acme" logs into UUM
    2. They do NOT see the ACME card in "subscribed" services (only in "discoverable")
    3. If they somehow hit /sso/redirect with slug "acme", UUM returns 403

[ ] SSO rejected — expired token:
    1. Wait 60+ seconds after generating an sso_token
    2. POST /provisioning/validate-token → 401 (not 422)

[ ] SSO rejected — wrong API key:
    1. POST /provisioning/validate-token with X-API-Key: wrong_key → 401

[ ] SSO rejected — replay attack:
    1. Obtain a valid sso_token
    2. POST /provisioning/validate-token → 200
    3. POST /provisioning/validate-token with the same token → 401 "SSO token has already been used"

[ ] Session expiry:
    1. Wait until local session TTL expires (or set a short TTL for testing)
    2. Make a request to /api/auth/me → 401
    3. Redirect to https://uum.sandboxlabs.uk/login?service=acme&redirect=...

[ ] Logout:
    1. POST /api/auth/logout → cookie cleared
    2. GET /api/auth/me → 401

[ ] [OPTIONAL] Plan catalog:
    1. GET /plans with X-API-Key header → 200, returns {"plans": [...]}
    2. Without X-API-Key → 401

[ ] [OPTIONAL] Periodic sync:
     1. GET /provisioning/services/acme/users → returns known users
     2. Single user: GET /provisioning/services/acme/users/<id> → returns user + is_active + roles
     3. Your cron job creates/updates local users accordingly

[ ] [OPTIONAL] Deletion detection:
     1. Ask a UUM admin to remove your service from a test tenant
     2. Poll GET /provisioning/services/acme/users
     3. Verify response includes pending_deletions with the test tenant
     4. Verify users of that tenant have deletion_status: "pending_deletion"
     5. Delete local data for that tenant
     6. Call DELETE /provisioning/services/acme/tenants/{test_tenant_id}
     7. Verify response 200 with status: "confirmed" and roles_deleted > 0
     8. Poll again → verify pending_deletions no longer contains the tenant

[ ] Token valid but user has no role on this service:
    1. User has a UUM account but NO role assigned on "acme"
    2. POST /provisioning/validate-token with a valid sso_token → 401 "SSO token not valid for this service"
    3. Your /auth/sso must NOT create a local user; show an access-denied page

[ ] Tenant migration (company_id changes between logins):
    1. User logs in → JIT creates local row with tenant_id=A
    2. UUM admin moves user to tenant B
    3. User logs in again → validate-token returns new tenant_id=B
    4. Your JIT must UPDATE the local `tenant_id` to B (not create a duplicate)

[ ] Plan changes between logins:
    1. User logs in → SSO returns plan_slug="basic"
    2. Subscription is upgraded to "professional" in UUM
    3. User logs in again → SSO returns plan_slug="professional"
    4. Your JIT must update the local `plan_slug` accordingly

[ ] /me/services response format:
    1. User logs into UUM → calls GET /me/services
    2. Response is NOT a flat array — it's {"subscribed": [...], "discoverable": [...]}
    3. "subscribed" entries include "roles": [{"role": "admin"}]
    4. "discoverable" entries include "allow_public_signup", "plans", "default_plan"
```

---

## 3. Core Concepts

### 3.1. Tenant — the isolation unit

A `tenant` is a company, customer, or department. UUM supports hierarchies:

```
ACME MSP (root tenant)
├── Client A (sub-tenant)
│   ├── Sales Dept (sub-sub-tenant)
│   └── Legal Dept (sub-sub-tenant)
└── Client B (sub-tenant)
```

When a user logs in, UUM assigns a **current tenant** — the company the user is "acting as" in that session. The SSO token's `tenant_id` is the current tenant, not necessarily the user's home tenant. Your service should use this `tenant_id` to isolate data.

Users can belong to multiple tenants. UUM supports session switching via `POST /me/switch-tenant` — the JWT's `current_tenant_id` claim updates to the selected tenant, and subsequent SSO tokens reflect the new current tenant.

### 3.2. Service — your app, registered in UUM

A `super_admin` registers your service via:

```
POST /api/v1/admin/services
Authorization: Bearer <super_admin_access_token>

{
  "slug":               "acme",
  "name":               "ACME Corp Suite",
  "description":        "Enterprise document management",
  "icon":               "lucide-file-text",
  "category":           "Productivity",
  "callback_url":       "https://acme.com/auth/sso",
  "available_roles":    ["admin", "user", "readonly"],
  "allow_public_signup": false,
  "default_plan_id":    null,
  "catalog_url":        "https://acme.com/api/uum/plans",
  "landing_page":       "/dashboard",
  "logo_url":           "https://acme.com/logo.svg",
  "is_internal":        false,
  "default_role":       "user",
  "branding":           {}
}
```

Key fields:

| Field | Meaning |
|-------|---------|
| `slug` | Permanent unique identifier. Appears in SSO tokens. |
| `callback_url` | Where UUM sends the user after SSO (single URL for all flows). |
| `available_roles` | Roles your service understands. UUM only allows assigning these. |
| `allow_public_signup` | Whether unauthenticated users can self-register (see § 9). |
| `default_plan_id` | Plan automatically assigned on public signup. |
| `catalog_url` | Where UUM fetches your plan catalog (see § 7). |
| `landing_page` | Path to redirect after SSO (e.g. `/dashboard`). Overrides callback_url path. |
| `logo_url` | URL of your service's logo (displayed in UUM landing page service card). |
| `is_internal` | If `true`, only visible to superadmins (not in public catalog). |
| `default_role` | Role auto-assigned when a user is added to this service without an explicit role. |
| `branding` | JSON object with service-specific branding (colors, favicon, etc.). |

### 3.3. User Service Role — the permission that ties everything together

A user does not have a "global role". They have a role **per service**:

```
User: alice@client-a.com
  → admin   on acme
  → user    on pbx
  → (none)  on uum (cannot enter the admin panel)
```

Your service only receives the roles relevant to it. If the user has no role on your service, UUM does not even generate the SSO token (returns 403).

### 3.4. The Permission Boundary — UUM vs Your Service

**UUM controls ACCESS. Your service controls INTERNAL PERMISSIONS.**

UUM answers one question: *"Can this user enter this service, and at what role level?"* UUM roles (`admin`, `user`, `readonly`) determine **who gets through the door**. What they do once inside is **entirely your service's responsibility**.

| Responsibility | UUM (IdP) | Your Service |
|---|---|---|
| Authentication (login, password, JWT) | ✅ | ❌ |
| Tenant/company association | ✅ `tenant_id` in SSO token | ❌ |
| Role per service (admin, user, readonly) | ✅ Assigns and delivers via SSO | ❌ |
| Internal permissions (can_export, can_delete_others, api_access, billing_view) | ❌ UUM has no knowledge of these | ✅ Define and enforce in your own DB |
| User metadata (timezone, phone, department, preferences) | ❌ | ✅ Ask via onboarding, store locally |
| Tenant-level config (API keys, webhook URLs, feature flags) | ❌ | ✅ Configure per tenant in your DB |

**How to implement internal permissions:**

```python
# In your service, map UUM roles → internal permissions
ROLE_PERMISSIONS = {
    "admin":    ["read", "write", "delete", "manage_users", "export_data", "billing"],
    "user":     ["read", "write"],
    "readonly": ["read"],
}

def get_permissions(uum_roles: list[str]) -> set[str]:
    """Derive internal permissions from UUM roles."""
    perms: set[str] = set()
    for role in uum_roles:
        perms.update(ROLE_PERMISSIONS.get(role, []))
    return perms
```

You may also store **additional permissions** beyond what UUM roles provide in your `local_users` table (see § 10). Use these for service-specific capabilities that don't map cleanly to the UUM role model.

**Critical rule:** Never use UUM roles to gate fine-grained internal actions like "can delete document #42" or "can access API endpoint /admin/reports/*". UUM roles are coarse-grained on purpose. Implement your own authorization middleware for internal guards.

### 3.5. What UUM Owns — Features Your Service Must NOT Reimplement

UUM is the **sole owner** of all user management operations. Your external service must not implement, expose, or maintain UI for the following — these are handled exclusively from within the UUM admin panel and the UUM user dashboard:

| Feature | Who owns it | What your service must NOT do |
|---|---|---|
| **Reset password** | ✅ UUM | ❌ Do NOT build a "forgot password" flow. Redirect users to `https://uum.sandboxlabs.uk/forgot-password`. |
| **Change password** | ✅ UUM | ❌ Do NOT expose a "change password" form. The user changes their password from the UUM profile page. |
| **Assign / revoke roles** | ✅ UUM | ❌ Do NOT build a "manage user roles" UI. Only the UUM admin can assign roles via the UUM admin panel. |
| **Remove a user from the service** | ✅ UUM | ❌ Do NOT build a "remove user" button. The UUM admin removes the user's role, and your periodic sync deactivates their local access. |
| **View who has access to your service** | ✅ UUM | ❌ Do NOT build a user list from scratch. Use `GET /provisioning/services/{slug}/users` (see § 8.1). |
| **Suspend / deactivate a user** | ✅ UUM | ❌ Do NOT implement a suspend button. The UUM admin deactivates the user in UUM; your service detects `is_active=false` and denies access. |
| **Invite a new user** | ✅ UUM | ❌ Do NOT build an invite flow. UUM handles invitations and email verification. |
| **User profile management** | ✅ UUM | ❌ Do NOT expose fields like `full_name`, `email`, or `phone` for editing. These come from UUM. Only store internal metadata (timezone, preferences) in your own DB. |

> **Why does this matter?** If your service exposes a "reset password" form or a "manage roles" UI, you will have duplicate, out-of-sync user management that creates confusion and security gaps. UUM is the single source of truth for identity — your service consumes it.

**The correct pattern:**
```
User clicks "Account Settings" in your service
  → redirect to UUM profile page: https://uum.sandboxlabs.uk/profile
  → user changes password / updates profile there
  → next time they SSO, your service receives the updated email/name via the SSO token
```

**For internal permissions (per-service, not UUM-managed):** These are fine-grained capabilities your service owns (e.g. "can export reports", "can manage billing"). You may build a UI for these, but they live only in your own DB (`internal_perms` column) and are controlled by your own admin role — not the UUM admin panel.

---

## 4. SSO Integration Flow

### 4.1. Sequence Diagram

```
User              UUM Landing         UUM API              YOUR SERVICE (ACME)
  │                    │                  │                        │
  │── login ──────────>│                  │                        │
  │                    │── POST /auth/login →│                     │
  │                    │<── JWT + cookie ────│                     │
  │<── redirect ───────│                  │                        │
  │── GET /me/services >│                 │                        │
  │                    │── GET /me/services →│                     │
  │<── {subscribed:[...], │                  │                        │
  │     discoverable:[...]}│                 │                        │
  │                    │                  │                        │
  │── click "ACME" ───>│                  │                        │
  │                    │── POST /sso/redirect {service_slug:"acme"}│
  │                    │<── {callback_url} ─│                      │
  │<── 302 → https://acme.com/auth/sso?sso_token=eyJ... ──────────>│
  │                    │                  │    POST /provisioning/validate-token
  │                    │                  │<── {user_id, email, tenant_id, roles}
  │                    │                  │    [create local session]
  │<── 302 → /dashboard ────────────────────────────────────────────│
```

### 4.2. Endpoints Your Service Must Expose

| Your endpoint | Method | Purpose |
|---------------|--------|---------|
| `/auth/sso` | GET | Receives `?sso_token=<jwt>`. Validates with UUM, JIT provisions user, creates local session, redirects. |
| `/api/auth/me` | GET | Returns the currently logged-in user from the local session. |
| `/api/auth/logout` | POST | Clears the local session. |
| `/plans` (optional) | GET | Catalog endpoint (see § 7). |

### 4.2.1. `/me/services` Response Format

When the UUM landing page loads after login, it calls `GET /me/services`. The response is **not** a flat array — it is a structured object with two categories:

```json
{
  "subscribed": [
    {
      "slug": "acme",
      "name": "ACME Corp Suite",
      "description": "Enterprise document management",
      "icon": "lucide-file-text",
      "category": "Productivity",
      "roles": [{"role": "admin"}]
    }
  ],
  "discoverable": [
    {
      "slug": "pbx",
      "name": "Cloud PBX",
      "description": "Business phone system",
      "icon": "lucide-phone",
      "category": "Communication",
      "allow_public_signup": true,
      "plans": ["basic"],
      "default_plan": "basic"
    }
  ]
}
```

| Field | Meaning |
|-------|---------|
| `subscribed` | Services where the user has at least one role (the ACME card appears here). |
| `discoverable` | Services visible to the user but without an assigned role. May include `allow_public_signup`, default plan info. |
| `roles` | Array of `{\"role\": \"...\"}` objects (not a flat string array). Indicates what the user can do on that service. |

This format is relevant for your integration because it determines whether a user sees your service card. If a user does not appear in `subscribed`, UUM will reject SSO for your service (403).

> **Note for SSR frameworks (Next.js, Nuxt, SvelteKit, Remix):** If your `/auth/sso` runs on the server (e.g. Next.js App Router route handler), `httpx` or `fetch` calls to **validate the SSO token** must go to the **internal** UUM backend URL (`http://uum-backend:8000`), not the public URL. The SSO redirect from UUM hits your public endpoint; your server-side handler then proxies the validation to the internal UUM API.

### 4.3. Step 1 — The user arrives at your `/auth/sso`

UUM redirects to:

```
GET https://acme.com/auth/sso?sso_token=eyJhbGciOiJIUzI1NiIs...
```

Your backend must:
1. Extract `sso_token` from the query parameter.
2. Call UUM to validate it.
3. Create or update the local user.
4. Create a local session (cookie or JWT).
5. Redirect to the dashboard.

### 4.4. Step 2 — Validate the token with UUM

See § 5.

### 4.5. Step 3 — Create a local session

The SSO token has a 60-second TTL. You must NOT use it for subsequent requests. Create your own session, for example:

```python
# Generate a local JWT (24h TTL)
import jwt
from datetime import datetime, timedelta, timezone

local_token = jwt.encode(
    {
        "sub": local_user_id,
        "uum_user_id": uum_user_id,
        "tenant_id": tenant_id,
        "roles": roles,
        "exp": datetime.now(timezone.utc) + timedelta(hours=24),
    },
    settings.LOCAL_JWT_SECRET,
    algorithm="HS256",
)

# Or set an httpOnly cookie
response = RedirectResponse(url="/dashboard", status_code=302)
response.set_cookie(
        key=f"{os.getenv('UUM_SERVICE_SLUG')}_session",  # e.g. "acme_session"
    value=local_token,
    httponly=True,
    secure=True,
    samesite="lax",
    max_age=86400,
)
return response
```

### 4.6. Session expiry — what your `/api/auth/me` returns

When the local session expires and the frontend calls `/api/auth/me`, your backend should return:

```json
// HTTP 401
{
  "detail": "Session expired"
}
```

**Frontend must:**
- Detect HTTP 401 → redirect the user to UUM login:
  ```
  https://uum.sandboxlabs.uk/login?service=acme&redirect=https://acme.com/dashboard
  ```

The `service` parameter tells UUM which service the user is coming from. If `allow_public_signup` is enabled for ACME, UUM shows a "Sign Up" link. The `redirect` parameter is where the user returns after login (before the SSO redirect).

### 4.7. Logout Behavior

Your service has **two** independent logout paths:

| Action | Effect |
|--------|--------|
| `POST /api/auth/logout` (local) | Clears the local session cookie / JWT. The user stays logged in at UUM. |
| User logs out from UUM (`uum.sandboxlabs.uk`) | Invalidates the UUM session and clears UUM's cookie. Does **not** affect the local session on your service. The user can still access your service until the local session TTL expires. |

> **Note:** Your service cannot call UUM's `POST /auth/logout` on behalf of the user — that endpoint requires the user's refresh token cookie from UUM's own session.

**For a complete logout flow:**
1. Call `POST /api/auth/logout` → clears the local session cookie / JWT.
2. Redirect to `https://uum.sandboxlabs.uk/login?service=acme&redirect=...` → the user lands on UUM's login page. If they want to fully log out of UUM as well, they use the UUM interface (its logout button calls `POST /auth/logout` and redirects to `/login`).

---

## 5. Token Validation

### 5.1. Endpoint

```
POST /api/v1/provisioning/validate-token
Base URL: https://uum.sandboxlabs.uk

Headers:
  Content-Type: application/json
  X-API-Key:     <your_service_api_key>

Body:
{
  "sso_token": "eyJhbGciOiJIUzI1NiIs..."
}
```

### 5.2. Successful Response (200)

```json
{
  "valid":        true,
  "user_id":      "550e8400-e29b-41d4-a716-446655440000",
  "email":        "alice@client-a.com",
  "tenant_id":    "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "tenant_slug":  "acme",
  "tenant_name":  "Acme Corp",
  "roles":        ["admin"],
  "plan_slug":    "professional"
}
```

| Field | Meaning |
|-------|---------|
| `user_id` | The UUM UUID of the user. Use this to match your local row (NOT email). |
| `email` | The user's email. |
| `tenant_id` | The **current tenant** — the company the user was "acting as" when they clicked your service card. Use this to isolate data. |
| `tenant_slug` | The tenant's short identifier (e.g. `acme`). Use this as a stable key for company-specific configuration or display. |
| `tenant_name` | The tenant's display name (e.g. `Acme Corp`). Use this for UI labels, emails, etc. |
| `roles` | The user's roles on your service (e.g. `["admin"]`, `["user"]`). |
| `plan_slug` | The active plan of the tenant's subscription for your service. Obtained from the most recent active/trialing subscription. `null` if no active subscription. |

### 5.3. Error Responses

| HTTP | Cause | What to do |
|------|-------|------------|
| 401 | `X-API-Key` is invalid or missing | Check the API key matches the one generated in UUM. |
| 401 | Token's `service_slug` does not match the service authenticating | Tokens are scoped to a service — someone is trying to reuse a token from another service. |
| 401 | Token expired (>60s) or malformed | The SSO token has a 60-second TTL. Redirect to UUM: `https://uum.sandboxlabs.uk/login?service=acme&redirect=...` |
| 401 | Replay attack — token already used | SSO tokens are single-use. UUM stores the `jti` claim in Redis (TTL 60s). A second validation returns `"SSO token has already been used"`. |
| 401 | User has no role on this service | The user is authenticated in UUM but has not been assigned a role on this service. Show an access-denied page. |

---

## 6. JIT Provisioning

UUM does not send webhooks (P0). Your service discovers users when they arrive via SSO. This is called **Just-In-Time Provisioning**.

### 6.1. JIT Flow

```
1. UUM redirects to https://acme.com/auth/sso?sso_token=<jwt>
2. Your backend validates the token → obtains user_id, email, tenant_id, roles
3. Your backend queries the local DB: does a user with this uum_user_id exist?
   ├── NO  → Create local user with token data
   └── YES → Update email/roles/tenant if they changed
4. Create local session
5. Redirect to dashboard
```

### 6.2. Implementation (Python)

```python
import httpx
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import RedirectResponse

router = APIRouter()

UUM_BASE     = "https://uum.sandboxlabs.uk/api/v1"
UUM_API_KEY  = os.getenv("UUM_API_KEY")          # NEVER hardcoded


@router.get("/auth/sso")
async def sso_callback(request: Request):
    sso_token = request.query_params.get("sso_token")
    if not sso_token:
        raise HTTPException(status_code=400, detail="Missing sso_token")

    # 1. Validate with UUM
    async with httpx.AsyncClient(timeout=10.0) as client:
        resp = await client.post(
            f"{UUM_BASE}/provisioning/validate-token",
            headers={"X-API-Key": UUM_API_KEY},
            json={"sso_token": sso_token},
        )
    if resp.status_code != 200:
        raise HTTPException(status_code=401, detail="Invalid SSO token")

    data        = resp.json()
    uum_user_id = data["user_id"]
    email       = data["email"]
    tenant_id   = data["tenant_id"]
    roles       = data["roles"]          # e.g. ["admin"]
    plan_slug   = data.get("plan_slug")  # may be null

    # 2. JIT: find or create local user
    user = await db.get_user_by_uum_id(uum_user_id)
    if not user:
        user = await db.create_user(
            uum_user_id=uum_user_id,
            email=email,
            tenant_id=tenant_id,
            roles=roles,
            plan_slug=plan_slug,
            is_active=True,
        )
        logger.info(f"[JIT] New user provisioned: {email} in tenant {tenant_id}")
    else:
        if user.email != email or user.tenant_id != tenant_id or set(user.roles) != set(roles):
            await db.update_user(user.id, email=email, tenant_id=tenant_id, roles=roles)
            logger.info(f"[JIT] User updated: {email}")

    # 3. Create local session
    local_token = create_local_session(user.id, tenant_id, roles)

    # 4. Redirect to dashboard
    response = RedirectResponse(url="/dashboard", status_code=302)
    response.set_cookie(
    key=f"{settings.UUM_SERVICE_SLUG}_session",   # e.g. "acme_session", "lexxto_session"
        value=local_token,
        httponly=True,
        secure=True,
        samesite="lax",
        max_age=86400,
    )
    return response
```

### 6.3. JIT vs Periodic Sync

| Strategy | When to use | Pros | Cons |
|----------|------------|------|------|
| **JIT only** (on-demand) | MVPs, few users, low turnover | Simple, always up-to-date | The user does not appear in your DB until they log in. You cannot report on "total users" without them having logged in. |
| **JIT + periodic sync** | Mature product, need user lists for reporting, offline permissions, or push notifications | You have a near-complete copy of users and roles in your DB. | Requires a cron job. More complexity. |

**Recommendation:** Start with JIT. It covers 90% of cases. Add periodic sync only when you have a clear business reason (e.g. "I need to show the client how many admin users they have, including those who have never logged in").

### 6.4. Agent Note — Onboarding Flow for Missing Internal Data

> **This section is addressed to AI agents implementing the integration.**

Some services require additional user or tenant data beyond what UUM provides in the SSO token (e.g. timezone, locale, department, phone number, preferred language, or a tenant-level configuration key). This data is **internal to your service** — UUM has no knowledge of it.

Do NOT block the SSO flow to collect this data. Instead, implement a **deferred onboarding flow**:

```
User new to ACME (first SSO login)
        │
        ▼
  /api/auth/sso → validates token with UUM → JIT provisions local user
        │
        ▼
  Does local user have all required internal fields?
        │
   ┌────┴────┐
   │         │
  YES       NO
   │         │
   │         ▼
   │    Redirect to /onboarding
   │         │
   │         ▼
   │    User fills form with missing fields
   │    (timezone, locale, phone, etc.)
   │         │
   │         ▼
   │    POST /api/onboarding/complete
   │    → saves to local DB
   │         │
   └─────────┘
        │
        ▼
  Redirect to /dashboard (or original destination)
```

#### When and how to trigger the onboarding check

1. **After JIT provisioning**, before creating the local session, check if the local user record has all required fields populated (e.g. `timezone IS NULL` or `onboarding_completed = false`).
2. If fields are missing, **create the local session anyway** (the user is authenticated), but redirect to `/onboarding` instead of `/dashboard`.
3. The `/onboarding` page is **authenticated** — your session middleware must accept it as a valid path even though onboarding is incomplete.
4. After the form is submitted, update the local user record and redirect to `/dashboard`.

#### Example: on-boarding check in `/auth/sso`

```python
@router.get("/auth/sso")
async def sso_callback(request: Request):
    # ... token validation, JIT provisioning, local session creation ...

    # --- Onboarding check ---
    if not user.onboarding_completed or missing_required_fields(user):
        response = RedirectResponse(url="/onboarding", status_code=302)
    else:
        response = RedirectResponse(url="/dashboard", status_code=302)
    # ... set session cookie, return response ...
```

#### What UUM provides vs what you must ask for

| Data source | Comes from UUM SSO? | You must ask the user? |
|-------------|---------------------|------------------------|
| `user_id`, `email`, `tenant_id`, `roles`, `plan_slug` | ✅ Yes, in the validate-token response | ❌ No |
| Timezone, locale, phone, department, custom preferences | ❌ No, UUM does not store these | ✅ Yes, on your `/onboarding` page |
| Tenant-level config (e.g. API base URL, webhook URL) | ❌ No | ✅ Yes, from the tenant admin on first login |

#### Important rules

1. **Do NOT block SSO.** The user always gets a local session. The question is only where they land: `/onboarding` or `/dashboard`.
2. **The `/onboarding` page must work with your local session middleware.** Do not require re-authentication to fill in a form.
3. **Store this data only in your local DB.** UUM is not a dumping ground for service-specific metadata.
4. **For tenant-level data**, consider asking the **first admin user** from each tenant to configure it, then mark `tenant.onboarding_completed = true` so subsequent users from the same tenant skip directly to `/dashboard`.
5. **Test the flow:** Create a user in UUM, SSO into your service for the first time, verify you land on `/onboarding`, fill in the form, verify you reach `/dashboard`, log out and back in, verify you go directly to `/dashboard` the second time.

---

## 7. Plan Catalog Synchronization

UUM can automatically sync your service's plans if you expose a catalog endpoint.

### 7.1. Why

- You do not need to manually call UUM when you change your plans.
- UUM fetches plans only when needed (first SSO from a user).
- The admin can force sync from the UUM panel.

### 7.2. Catalog Endpoint You Must Expose

```
GET /plans   (or the URL configured as catalog_url)
Headers:
  X-API-Key: <your_service_api_key>

Response 200:
{
  "plans": [
    {
      "slug":             "basic",
      "name":             "Basic",
      "description":      "For small teams",
      "features":         ["10 docs/month", "Email support"],
      "price_amount":     0,
      "price_currency":   "USD",
      "billing_interval": "monthly",
      "trial_period_days": 0,
      "is_active":         true,
      "dodo_product_id":   null,
      "metadata":          {}
    },
    {
      "slug":             "professional",
      "name":             "Professional",
      "description":      "For growing teams",
      "features":         ["Unlimited docs", "Priority support", "API access"],
      "price_amount":     4900,
      "price_currency":   "USD",
      "billing_interval": "monthly",
      "trial_period_days": 14,
      "is_active":         true,
      "dodo_product_id":   "prod_abc123",
      "metadata":          {}
    }
  ]
}
```

### 7.3. Requirements

| Requirement | Detail |
|-------------|--------|
| URL | Publicly accessible (HTTPS required in production). Configured as `catalog_url` in the service. |
| Method | `GET` |
| Authentication | UUM sends `X-API-Key` with your API key. Reject if it does not match. |
| Response format | `{"plans": [...]}` |
| Timeout | UUM waits up to 30 seconds. |
| Errors | If the endpoint fails, UUM continues the SSO without blocking. |

### 7.4. When UUM Syncs Plans

| Scenario | Trigger | Behavior |
|----------|---------|----------|
| SSO auto-sync | Service has `catalog_url` + API key + 0 active plans | UUM fetches in background during the user's first SSO. |
| Admin manual | Admin clicks "Request Plans" in the UUM panel | UUM fetches and syncs immediately with `replace_all=true`. |

### 7.5. Manual Sync (Alternative)

If you prefer your service to push plans instead of UUM pulling them:

```
POST /api/v1/provisioning/services/acme/plans/sync
Headers:
  X-API-Key: <your_service_api_key>

Body:
{
  "plans": [
    {
      "slug":             "basic",
      "name":             "Basic",
      "price_amount":     0,
      "price_currency":   "USD",
      "billing_interval": "monthly",
      "is_active":        true
    }
  ],
  "replace_all": true   // deactivates plans not in payload
}
```

Response: `200` with the list of active plans after the sync.

---

## 8. Periodic User Sync

If you decide to implement periodic sync, UUM exposes provisioning endpoints for your backend to download the current state.

### 8.1. List Users of Your Service

> **⚠️ Critical scope warning:** This endpoint returns **only users who have at least one role assigned on YOUR service**. It does **NOT** return all users of any tenant. A tenant may have 50 users, but if only 3 of them have a role on `acme`, you will see only those 3. This is by design — UUM scopes all provisioning endpoints to the calling service.

```
GET /api/v1/provisioning/services/acme/users
Headers:
  X-API-Key: <your_service_api_key>

Query params:
  ?tenant_id=<uuid>   → Optional. Filter by tenant.
  &page=1             → Default 1.
  &page_size=50       → Default 50, max 200.
```

**Response:**

```json
{
  "items": [
    {
      "user_id":   "550e8400-e29b-41d4-a716-446655440000",
      "email":     "alice@client-a.com",
      "tenant_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "roles":     ["admin"],
      "deletion_status": "active",
      "deletion_scheduled_at": null
    },
    {
      "user_id":   "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
      "email":     "bob@client-a.com",
      "tenant_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "roles":     ["user"],
      "deletion_status": "active",
      "deletion_scheduled_at": null
    }
  ],
  "pending_deletions": [],
  "total": 12,
  "page": 1,
  "page_size": 50
}
```

### 8.2. Get a Single User

```
GET /api/v1/provisioning/services/acme/users/<user_id>
Headers:
  X-API-Key: <your_service_api_key>
```

**Response:**

```json
{
  "user_id":   "550e8400-e29b-41d4-a716-446655440000",
  "email":     "alice@client-a.com",
  "tenant_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "is_active": true,
  "roles":     ["admin"]
}
```

Returns `404` if the user does not exist or has no roles on your service.

### 8.3. Recommended Sync Frequency

Every 15 minutes (or on demand). In pseudo-code:

```python
async def sync_users_from_uum():
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            f"{UUM_BASE}/provisioning/services/acme/users?page_size=100",
            headers={"X-API-Key": UUM_API_KEY},
        )
    for uum_user in resp.json()["items"]:
        local = await db.get_user_by_uum_id(uum_user["user_id"])
        if not local:
            await db.create_user(**uum_user)
        else:
            await db.update_user(local.id, **uum_user)
    # Optional: deactivate local users no longer present in UUM
```

### 8.4. Deletion Detection — Detecting When a Tenant Removes Your Service

When a tenant cancels their subscription or an admin removes your service from a tenant in UUM, your service **must** clean up the tenant's local data. UUM does not send webhooks — instead, your periodic sync detects deletions via the same polling endpoint.

**How it works:**

1. A tenant's service is removed in UUM → UUM starts a **30-day grace period**.
2. Your service's next poll of `GET /provisioning/services/{slug}/users` receives:
   - **Per-user:** `deletion_status` changes from `"active"` to `"pending_deletion"`, and `deletion_scheduled_at` is populated with the timestamp when the grace period began.
   - **Top-level:** `pending_deletions` list contains entries for each tenant+service pair pending deletion.
3. Your service deletes/archives ALL local data for that tenant (users, documents, settings, etc.).
4. Your service **confirms** the deletion by calling `DELETE /provisioning/services/{slug}/tenants/{tenant_id}`.
5. UUM hard-deletes the `TenantService` link and `UserServiceRole` records, and marks the deletion as confirmed.

**If your service never confirms:** UUM auto-hard-deletes after the 30-day grace period expires, regardless of whether your service cleaned up.

#### Response when a deletion is pending

```json
{
  "items": [
    {
      "user_id":   "550e8400-e29b-41d4-a716-446655440000",
      "email":     "alice@client-a.com",
      "tenant_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "roles":     ["admin"],
      "deletion_status": "pending_deletion",
      "deletion_scheduled_at": "2026-05-29T15:00:00Z"
    }
  ],
  "pending_deletions": [
    {
      "tenant_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "tenant_slug": "acme-corp",
      "service_slug": "acme",
      "deletion_scheduled_at": "2026-05-29T15:00:00Z",
      "status": "pending_deletion"
    }
  ],
  "total": 1,
  "page": 1,
  "page_size": 50
}
```

| Field | Meaning |
|-------|---------|
| `items[].deletion_status` | `"active"` (normal) or `"pending_deletion"` (grace period in progress). |
| `items[].deletion_scheduled_at` | ISO 8601 timestamp when the grace period started. `null` if not pending. |
| `pending_deletions` | List of tenant+service pairs pending deletion. Your service should iterate this list and clean up local data for each entry. |
| `pending_deletions[].tenant_id` | The tenant whose data should be deleted. |
| `pending_deletions[].tenant_slug` | Human-readable identifier for logging/display. |
| `pending_deletions[].deletion_scheduled_at` | When the 30-day clock started. |
| `pending_deletions[].status` | Always `"pending_deletion"`. |

#### Implementation pattern

```python
async def check_pending_deletions():
    """Called from your periodic sync cron job."""
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            f"{UUM_BASE}/provisioning/services/acme/users",
            headers={"X-API-Key": UUM_API_KEY},
        )
    data = resp.json()

    for deletion in data.get("pending_deletions", []):
        tenant_id = deletion["tenant_id"]
        tenant_slug = deletion["tenant_slug"]

        # 1. Delete ALL local data for this tenant
        await db.delete_tenant_data(tenant_id)
        logger.info(f"[DELETION] Removed all data for tenant {tenant_slug}")

        # 2. Confirm deletion to UUM
        await client.delete(
            f"{UUM_BASE}/provisioning/services/acme/tenants/{tenant_id}",
            headers={"X-API-Key": UUM_API_KEY},
        )
        logger.info(f"[DELETION] Confirmed cleanup for tenant {tenant_slug}")
```

#### Important rules

1. **Delete ALL tenant data** — not just users. Documents, settings, API keys, uploaded files — everything scoped to that tenant must be removed.
2. **Confirm the deletion** — calling the DELETE endpoint tells UUM you have finished cleanup. UUM then hard-deletes the tenant-service link and role assignments.
3. **Idempotent confirmation** — calling DELETE on an already-confirmed deletion returns 404 (not an error). Your code should handle this gracefully.
4. **Grace period is 30 days** — UUM handles the clock. Your service just reacts to `pending_deletions` in the poll response.
5. **SSO is blocked during pending deletion** — users from a tenant that is pending deletion cannot SSO into your service (UUM returns 403). This prevents new sessions from being created after cleanup.

### 8.5. Confirming Deletion — DELETE Endpoint

```
DELETE /api/v1/provisioning/services/acme/tenants/{tenant_id}
Headers:
  X-API-Key: <your_service_api_key>
```

**Success response (200):**

```json
{
  "status": "confirmed",
  "tenant_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "service_slug": "acme",
  "hard_deleted_at": "2026-05-29T16:30:00Z",
  "roles_deleted": 5
}
```

**Error responses:**

| HTTP | Cause |
|------|-------|
| 401 | `X-API-Key` is missing or invalid. |
| 403 | API key does not match the `service_slug` in the URL. |
| 404 | No pending deletion found for this tenant+service pair (already confirmed, or never existed). |

> **Note:** This endpoint is only callable by the service that owns the slug. A service with API key for `acme` cannot confirm deletions for `pbx`.

### 8.6. Lifecycle Summary

```
Tenant subscribes to your service
  → Users SSO in with roles
  → You poll GET /provisioning/services/{slug}/users
        (deletion_status: "active", pending_deletions: [])

Tenant cancels / admin removes service
  → UUM starts 30-day grace period
  → You poll → deletion_status: "pending_deletion"
  → You delete local tenant data
  → You call DELETE /provisioning/services/{slug}/tenants/{id}
  → UUM hard-deletes tenant-service link + roles

(If your service never confirms)
  → After 30 days, UUM auto-hard-deletes
```

---

## 8.5 Tenant Subscription Lookup (server-to-server)

If your service needs to apply per-plan quotas **outside of any user session** (a worker job, a scheduled task, a quota-validation hook), use this endpoint. It does NOT require a user JWT — it uses your service's `X-API-Key` like the rest of the provisioning endpoints.

```
GET /api/v1/provisioning/services/<your_service_slug>/tenants/<tenant_id>/subscription
Headers:
  X-API-Key: <your_service_api_key>
```

**Response 200:**
```json
{
  "tenant_id": "uuid",
  "subscription": {
    "id": "uuid",
    "status": "active",
    "plan": {
      "id": "uuid",
      "slug": "pro",
      "name": "Pro Plan",
      "features": ["full-text", "facets"],
      "price_amount": 4900,
      "price_currency": "USD",
      "billing_interval": "monthly"
    },
    "current_period_start": "2026-09-01T00:00:00Z",
    "current_period_end": "2026-10-01T00:00:00Z",
    "trial_end": null,
    "cancel_at_period_end": false
  }
}
```

**Status values:** `active`, `trialing`, `past_due`, `incomplete`, `canceled`. Throttle or block tenants in `past_due` / `canceled` as appropriate.

**Response 404:** Tenant not found, or tenant has no active add-on to your service (never subscribed, or add-on was removed).

> **Scope:** Only plans owned by your service are visible. A different service's plan for the same tenant returns 404 — the endpoint never leaks cross-service data.

---

## 8.6 Outbound webhooks (UUM → your service)

Instead of polling, your service can subscribe to subscription lifecycle events. UUM POSTs signed JSON to a URL you register.

### 8.6.1. Register your webhook

Ask a `super_admin` to set `webhook_url` (HTTPS) and `webhook_events` on your service:

```bash
curl -X PATCH https://uum.sandboxlabs.uk/api/v1/admin/services/<your_service_id> \
  -H "Authorization: Bearer <super_admin_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook": {
      "webhook_url": "https://your-service.example.com/uum-webhook",
      "webhook_events": ["subscription.created", "subscription.updated", "subscription.canceled", "payment.failed"]
    }
  }'
```

Then rotate the secret to get the signing key (returned exactly once):

```bash
curl -X POST https://uum.sandboxlabs.uk/api/v1/admin/services/<your_service_id>/webhook-secret \
  -H "Authorization: Bearer <super_admin_token>"
```

Store `webhook_secret` securely — UUM will not show it again.

### 8.6.2. Receive the event

UUM POSTs to `webhook_url` with:

| Header | Value |
|--------|-------|
| `Content-Type` | `application/json` |
| `X-UUM-Webhook-Signature` | `v1,<base64>` — HMAC-SHA256 of the raw body |
| `X-UUM-Webhook-Event` | Event type (e.g. `subscription.canceled`) |
| `X-UUM-Webhook-Event-Id` | UUID for idempotency |

Body shape:

```json
{
  "event_id": "uuid",
  "event_type": "subscription.canceled",
  "timestamp": "2026-09-28T12:00:00.000Z",
  "tenant_id": "uuid",
  "subscription": {
    "id": "uuid",
    "status": "canceled",
    "plan": { "id": "uuid", "slug": "pro", "name": "Pro", "features": [], "price_amount": 0, "price_currency": "USD", "billing_interval": "monthly" },
    "current_period_start": "2026-09-01T00:00:00Z",
    "current_period_end": "2026-10-01T00:00:00Z",
    "trial_end": null,
    "cancel_at_period_end": false
  }
}
```

The `subscription` shape mirrors the [tenant subscription lookup](#85-tenant-subscription-lookup-server-to-server) response — one model on your side.

### 8.6.3. Verify the signature

```python
import base64, hmac, hashlib

def verify(body: bytes, secret: str, header: str) -> bool:
    if not header.startswith("v1,"):
        return False
    expected = base64.b64decode(header[3:])
    digest = hmac.new(secret.encode(), body, hashlib.sha256).digest()
    return hmac.compare_digest(digest, expected)
```

> **Same scheme as Dodo webhooks.** If you already verify Dodo signatures, you can reuse your verification code with the UUM secret.

### 8.6.4. Delivery semantics

- **Retries:** 5 attempts with exponential backoff (1s, 2s, 4s, 8s, 16s). First 2xx wins.
- **Idempotency:** `event_id` is stable per logical event but reused across retries. **You MUST deduplicate by `event_id`** — duplicate deliveries are expected.
- **What to do on `subscription.canceled`:** stop billing, archive tenant data, schedule hard-delete (or wait for the periodic sync to see `deletion_status=pending_deletion`).
- **What to do on `payment.failed`:** throttle the tenant — block writes, warn on read.
- **Out of scope:** polling remains your fallback. If your webhook URL is down for more than ~31s, the event is dropped. Recover by re-syncing from `GET /provisioning/services/{slug}/users` + `GET /provisioning/services/{slug}/tenants/{tid}/subscription`.

---

## 9. Public Signup (Self-Registration)

UUM allows a service to accept public registrations without invitation. The user arrives at the login page, sees a "Sign Up" link, and registers by creating their tenant + user + subscription to the default plan.

### 9.1. Enable Public Signup

A `super_admin` enables it from the admin panel or via:

```
PATCH /api/v1/admin/services/<service_id>
Authorization: Bearer <super_admin_access_token>

{
  "allow_public_signup": true,
  "default_plan_id":     "<uuid-of-the-plan>"
}
```

The plan must be active and belong to the service. If `allow_public_signup` is `false` (default), the signup link does not appear.

### 9.2. How to Build the Login and Signup URLs

**Login URL** — add `?service=<slug>` to UUM's login URL:

```
# Correct — UUM shows the "Sign Up" link if allow_public_signup=true
https://uum.sandboxlabs.uk/login?service=acme&redirect=https://acme.com/dashboard
```

**Signup URL** — link directly to signup with a specific plan:

```
https://uum.sandboxlabs.uk/signup/acme?plan=professional
```

| Parameter | Required | Description |
|-----------|----------|-------------|
| `:serviceSlug` | Yes | Service slug (path parameter, e.g. `/signup/acme`). |
| `?plan=` | No | Plan slug to pre-select (e.g. `?plan=professional`). This is the **same slug your service registered** via `POST /provisioning/services/{slug}/plans`. Must match an active plan belonging to `:serviceSlug`. Silently ignored if invalid — the default plan is used instead. If omitted, the service's `default_plan_id` is used. |
| `?plan=` (multiple plans) | No | When the service has multiple active plans and no `?plan=` is provided, the signup form shows a dropdown selector so the user can choose. |

**Where to get valid plan slugs:** Your service already knows its plan slugs — you registered them via the provisioning API (`POST /provisioning/services/acme/plans`). You can also query the public endpoint at any time:
```bash
curl https://uum.sandboxlabs.uk/api/v1/public/services/acme/signup-config | jq '.plans[].slug'
# → "basic", "professional"
```

**Example use-case:** Your landing page shows pricing cards. Each card's CTA links to:
```html
<a href="https://uum.sandboxlabs.uk/signup/acme?plan=professional">Start Pro Trial</a>
```

### 9.3. Full Public Signup Flow

```
1. User arrives at your service without a session
   → your backend redirects to:
     https://uum.sandboxlabs.uk/login?service=acme&redirect=https://acme.com/dashboard

2. UUM shows the login page
   → UUM calls GET /api/v1/public/services/acme/signup-config
   → If allow_public_signup=true, the "Sign Up" link appears

3. User clicks "Sign Up" → UUM shows the signup form

4. User fills in the form (email, password, full name, company name, plan)
   → POST /api/v1/public/signup → 201 Created
   Body: {"service_slug": "acme", "email": "...", "password": "...", "full_name": "...", "company_name": "...", "plan_id": "..."}
   → `plan_id` is optional. Must belong to the service. Falls back to `default_plan_id` if omitted or invalid.
   Response: {"tenant_id": "...", "tenant_slug": "...", "user_id": "...", "email": "...", "message": "..."}
   → UUM creates: Tenant (company_slug auto-generated from company_name) + User + TenantService + Subscription (default plan)

5. UUM redirects to /login with a success message
   → User logs in with email + password

6. Login successful → UUM redirects to your redirect URL with the SSO token
   → your service validates the token and creates a local session
```

### 9.4. Signup Validations

| Condition | HTTP | Detail |
|-----------|------|--------|
| `allow_public_signup=false` | 403 | The service does not allow public signup |
| No `default_plan_id` | 400 | No default plan configured |
| Service not found | 404 | The `service_slug` does not match any registered service |
| Invalid company name | 400 | Company name must be 2-100 characters, alphanumeric + spaces/hyphens |
| Email already registered | 409 | The user already exists in UUM |
| Duplicate company slug | 409 | The company slug already exists |

---

## 10. Recommended Data Model (Your Service)

Do not copy everything from UUM. You only need a lightweight "shadow" table:

```sql
CREATE TABLE local_users (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    uum_user_id     UUID NOT NULL UNIQUE,     -- Original ID from UUM
    email           VARCHAR(255) NOT NULL,
    tenant_id       UUID NOT NULL,            -- current tenant from the SSO token
    roles           JSONB NOT NULL DEFAULT '[]',  -- UUM roles (["admin", "user"])
    internal_perms  JSONB NOT NULL DEFAULT '[]',  -- service-specific permissions (["export_data", "api_access"])
    plan_slug       VARCHAR(50),              -- from the SSO token, nullable
    is_active       BOOLEAN NOT NULL DEFAULT true,
    onboarding_completed BOOLEAN NOT NULL DEFAULT false,  -- see § 6.4
    timezone        VARCHAR(50),              -- internal metadata, asked during onboarding
    last_login_at   TIMESTAMP,
    created_at      TIMESTAMP DEFAULT NOW(),
    updated_at      TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_local_users_tenant ON local_users(tenant_id);
CREATE INDEX idx_local_users_uum    ON local_users(uum_user_id);
```

### Column Explanations

| Column | Source | Purpose |
|---|---|---|
| `uum_user_id` | SSO token `user_id` | Stable foreign key to UUM. Use this as the canonical identity link — never use email. |
| `email` | SSO token `email` | Display and communication only. Not a unique key (users can be re-provisioned). |
| `tenant_id` | SSO token `tenant_id` | Data isolation. All business queries must filter by this column. |
| `roles` | SSO token `roles` | UUM roles on your service (`["admin"]`, `["user"]`). Updated on every login. Use to derive coarse access levels. |
| `internal_perms` | Derived at JIT time + admin UI | Service-specific capabilities (e.g. `["can_export_reports", "billing_admin", "api_full_access"]`). UUM has no knowledge of these. See § 3.4. |
| `plan_slug` | SSO token `plan_slug` | The tenant's active plan on your service. May be `null` for free plans or services without subscriptions. |
| `is_active` | SSO token / provisioning sync | Whether the user is active in UUM. Deactivated users should be denied access locally even if their local session hasn't expired. |
| `onboarding_completed` | Set by `/api/onboarding/complete` | Whether the user filled in all required internal fields (see § 6.4). |
| `timezone` | Asked during onboarding | Example internal field. Extend with your own columns. |

**Principle:** UUM is the **source of truth** for identity, tenant, and roles. Your service is the source of truth for **business data** (documents, calls, tickets, etc.), **internal permissions**, and **user-level configuration** (timezone, preferences, etc.).

---

## 11. FAQ

### Do users need to exist in my database before they SSO?

No. JIT provisioning creates them on their first login. If you need to pre-populate users (e.g. to send notifications before the first login), use `GET /provisioning/services/acme/users` and create them in advance.

### What happens if a user changes roles in UUM?

- **JIT:** Updated on their next login.
- **Periodic sync:** Updated on the next cron job run.
- **Real-time:** Call `GET /provisioning/services/acme/users/<user_id>` before critical actions (e.g. before allowing document deletion, verify they are still `admin`).

### What happens if a user changes tenants in UUM?

The `tenant_id` in the SSO token always reflects the **current tenant**. If your service stores data per tenant, the user sees the data for that tenant.

### Do I need my own backend for sessions?

Yes. UUM authenticates you once via SSO. After that, your service must maintain its own session (cookie or local JWT). It is not practical to validate the SSO token on every request because:
1. The SSO token expires in 60 seconds (single-use).
2. You do not want to make an HTTP call to UUM on every user click.

**Recommended flow:**
1. SSO → validate with UUM → create local session (JWT/cookie with 24h TTL).
2. Subsequent requests use your local session.
3. When your local session expires, redirect to UUM to re-authenticate.

### Can I use UUM for server-to-server APIs (machine-to-machine)?

Not with SSO. SSO is designed for browser + human user. For machine-to-machine APIs, implement your own API keys. UUM does not manage service accounts in the current version.

### How should I handle internal permissions that don't map 1:1 to UUM roles?

UUM roles (`admin`, `user`, `readonly`) are intentionally coarse-grained. If you need fine-grained permissions like "can export reports", "can manage billing", or "can delete other users' content":

1. **Map UUM roles to permission sets** — see § 3.4 for a mapping pattern.
2. **Store additional permissions** in your `local_users.internal_perms` column (see § 10).
3. **Build your own authorization middleware** that reads from `internal_perms` — not from UUM roles directly.
4. **For admin-assigned custom permissions**, expose an internal UI (only visible to users with the UUM `admin` role) to grant/revoke fine-grained permissions. These are stored only in your DB.

```python
# Example: authorization decorator in your service
def require_permission(permission: str):
    """Check if the current user has a specific internal permission."""
    def decorator(func):
        @wraps(func)
        async def wrapper(request: Request, *args, **kwargs):
            local_user = get_current_local_user(request)
            if permission not in local_user.internal_perms:
                raise HTTPException(status_code=403, detail=f"Missing permission: {permission}")
            return await func(request, *args, **kwargs)
        return wrapper
    return decorator

@router.get("/admin/reports")
@require_permission("can_export_reports")
async def get_reports(request: Request):
    ...
```

**Key rule:** UUM answers "who should get in." Your service answers "what can they do once inside." Never conflate the two.

### How can I see which users from a tenant have access to my service?

Use `GET /provisioning/services/{slug}/users?tenant_id=<uuid>` (see § 8.1). This returns only users with a role on your service, not all tenant members. If a user is in the tenant but has no role on your service, they will NOT appear in this list and UUM will reject their SSO attempts with 403.

---

## 12. All UUM Endpoints You May Call

### From Your Backend (Server-to-Server)

| Endpoint | Method | Auth | Purpose |
|----------|--------|------|---------|
| `/api/v1/provisioning/validate-token` | POST | `X-API-Key` | Validate a received SSO token |
| `/api/v1/provisioning/services/{slug}/users` | GET | `X-API-Key` | List users with roles on your service |
| `/api/v1/provisioning/services/{slug}/users/{id}` | GET | `X-API-Key` | Get a single user's details |
| `/api/v1/provisioning/services/{slug}/tenants/{id}` | DELETE | `X-API-Key` | Confirm tenant data deletion after cleanup (see § 8.4–8.5) |
| `/api/v1/provisioning/services/{slug}/plans/sync` | POST | `X-API-Key` | Push plans in bulk to UUM |
| `/api/v1/provisioning/services/{slug}/plans` | POST | `X-API-Key` | Create a single plan |
| `/api/v1/provisioning/services/{slug}/plans/{plan_slug}` | PATCH | `X-API-Key` | Update an existing plan |
| `/api/v1/me/tenants` | GET | `Bearer` | List user's accessible tenants (for session switching) |

### SSO Endpoints (called from UUM frontend or your backend)

| Endpoint | Method | Auth | Purpose |
|----------|--------|------|---------|
| `/api/v1/sso/redirect` | POST | `Bearer` + user | Initiate SSO redirect (called by UUM landing when user clicks a service card) |
| `/api/v1/sso/validate-external` | POST | HMAC | Transparent SSO for external services that embed UUM. Validates a pre-signed token via HMAC signature. |
| `/api/v1/sso/callback` | GET | HMAC | Callback endpoint after transparent SSO. Validates HMAC-signed token and starts local session. |

### From the UUM Admin Panel (Human + JWT)

| Endpoint | Method | Auth | Purpose |
|----------|--------|------|---------|
| `/api/v1/admin/services` | GET | `Bearer` + super_admin | List all registered services |
| `/api/v1/admin/services` | POST | `Bearer` + super_admin | Register a new service |
| `/api/v1/admin/services/{id}` | PATCH | `Bearer` + super_admin | Update a service |
| `/api/v1/admin/services/{id}` | DELETE | `Bearer` + super_admin | Delete a service |
| `/api/v1/admin/services/{id}/api-key` | POST | `Bearer` + super_admin | Generate the API key for a service |
| `/api/v1/admin/services/{slug}/request-plans` | POST | `Bearer` + super_admin | Force plan sync from catalog |

### Public (No Authentication)

| Endpoint | Method | Auth | Purpose |
|----------|--------|------|---------|
| `/api/v1/catalog` | GET | None | List discoverable services for the landing page |
| `/api/v1/public/signup/services` | GET | None | List services allowing public signup |
| `/api/v1/public/signup` | POST | None (rate-limited) | Public user registration |
| `/api/v1/public/services/{slug}/signup-config` | GET | None | Check if a service allows signup |

---

## 13. Production Checklist

Before going live, verify these deployment settings:

| Item | Detail |
|------|--------|
| **HTTPS** | All cookies must use `secure=True`. HTTPS is **required** in production — cookies will be rejected on HTTP. |
| `UUM_BASE_URL` | Must point to the **public** UUM URL (`https://uum.sandboxlabs.uk/api/v1`), not localhost. |
| `APP_URL` / redirect URL | Must be the **public** domain of your service (e.g. `https://acme.com`), not `localhost`. |
| `LOCAL_JWT_SECRET` | Must be the **same** value across all pods/instances if using JWT sessions (otherwise one pod cannot validate tokens issued by another). Store in a secure vault, not in the repo. |
| **Cookie domain** | If your service runs on multiple subdomains, set the cookie `domain` attribute accordingly (e.g. `.acme.com`). |
| **HTTP-only + SameSite** | Local session cookie must use `httponly=True` and `samesite="lax"` (or `"strict"`) to prevent XSS and CSRF. |

---

## 14. Glossary

| Term | Meaning |
|------|---------|
| **UUM** | Unified User Management — Sandbox Labs IdP. |
| **IdP** | Identity Provider — system that authenticates users and issues tokens. |
| **SSO** | Single Sign-On — one login, access to multiple services. |
| **JIT Provisioning** | Automatically creating a local user the first time they arrive via SSO. |
| **Tenant** | Company / organization / data isolation unit. |
| **Home tenant** | The tenant the user originally belongs to. |
| **Current tenant** | The tenant the user is "acting as" in this session (may be a sub-tenant). |
| **Service** | Application registered in UUM (e.g. ACME, PBX). |
| **SSO token** | Single-use JWT (60s TTL) that UUM sends to your service via redirect. |
| **API key** | Shared secret between UUM and your service to authenticate provisioning calls. |
| **RBAC** | Role-Based Access Control — permissions based on roles per service. |
| **MSP** | Managed Service Provider — root tenant managing sub-tenants. |

---

---

## 15. Agent Handoff — Final Configuration Summary

> **This section is a REQUIRED output for AI agents.** After completing all implementation tasks from § 2.1, you MUST produce a final summary like the one below. Replace every `{{PLACEHOLDER}}` with the actual values from the user's project and environment. This is the last thing you output before declaring "done."

### 15.1. Configuration Checklist (to be filled by the agent)

```markdown
## 🎉 UUM Integration — Handoff Summary

Your UUM integration is now implemented. Here is what was done and what you need to configure.

---

### ✅ What was implemented

{{AGENT: list each step from § 2.1 that was completed. Example:
- [x] GET /auth/sso endpoint with JIT provisioning
- [x] GET /api/auth/me and POST /api/auth/logout
- [x] Middleware: session expiry → redirect to UUM login
- [x] Role-based guards using local session roles
- [x] GET /plans catalog endpoint
}}

---

### 🔧 Environment variables to set

Add these to your `.env` file (or your deployment platform's secrets manager):

```bash
# UUM connection
UUM_BASE_URL="https://uum.sandboxlabs.uk/api/v1"
UUM_SERVICE_SLUG="{{AGENT: ask the user or use the slug they provided}}"

# UUM API key — generated in the UUM admin panel
# IMPORTANT: this value is shown only once. Store it securely.
UUM_API_KEY="{{AGENT: ask the user — they must obtain this from the UUM admin}}"

# Local session
LOCAL_JWT_SECRET="{{AGENT: generate a random 64-char string, e.g. with \`openssl rand -hex 32\`}}"
LOCAL_SESSION_TTL_HOURS="24"

# Super admin — the first user to register in UUM will be promoted to superadmin
# if their email matches this value. If your service has a "god mode" role,
# associate it with this email.
SUPER_ADMIN_EMAIL="admin@sandboxlabs.uk"
\`\`\`

---

### 🌐 Values the UUM admin needs to register your service

Provide these to the UUM superadmin so they can register your service via `POST /api/v1/admin/services`:

| Field | Value for your service | Notes |
|-------|----------------------|-------|
| `slug` | `{{AGENT: ask the user or inspect the project}}` | Permanent identifier. e.g. `acme`, `myapp` |
| `name` | `{{AGENT: ask the user — display name}}` | e.g. `ACME Corp Suite` |
| `description` | `{{AGENT: ask the user — short description}}` | e.g. `Enterprise document management` |
| `icon` | `{{AGENT: suggest a Lucide icon or ask the user}}` | e.g. `lucide-file-text`, `lucide-box`, `lucide-globe` |
| `category` | `{{AGENT: pick the best fit}}` | e.g. `Productivity`, `Communication`, `Development`, `Analytics`, `Finance` |
| `callback_url` | `{{AGENT: use the actual domain + /auth/sso}}` | The SSO redirect target. e.g. `https://myapp.com/auth/sso` |
| `landing_page` | `{{AGENT: use the actual post-login path}}` | Where the user lands after SSO. e.g. `/dashboard`, `/app`, `/home` |
| `logo_url` | `{{AGENT: ask the user — public logo URL}}` | Displayed on the UUM landing page. e.g. `https://myapp.com/logo.svg` |
| `available_roles` | `{{AGENT: ask the user or suggest defaults}}` | e.g. `["admin", "user", "readonly"]` |
| `allow_public_signup` | `{{AGENT: ask the user — yes/no}}` | Whether users can self-register for this service |
| `catalog_url` | `{{AGENT: use the actual plans endpoint}}` | Only if you implemented § 7. e.g. `https://myapp.com/api/plans` |
| `is_internal` | `false` | Set to `true` only if this service should not appear in the public catalog |
| `default_role` | `{{AGENT: suggest the lowest-privilege role}}` | e.g. `user` |
| `branding` | `{}` | Optionally provide colors, favicon URL, etc. |

---

### 🔗 Login URLs your frontend should use

| Purpose | URL |
|---------|-----|
| Login | `https://uum.sandboxlabs.uk/login?service={{SLUG}}&redirect={{YOUR_DOMAIN}}/dashboard` |
| Logout (full) | Call your `POST /api/auth/logout`, then redirect to: `https://uum.sandboxlabs.uk/login?service={{SLUG}}&redirect={{YOUR_DOMAIN}}/dashboard` |

---

### 🧪 Verification

Run the checks from § 2.3 to confirm the integration works end-to-end before deploying to production.

---

### 📋 Next steps for you (the human)

1. **Get the API key**: ask a UUM superadmin to register your service via the admin panel (`{{UUM_BASE_URL}}/admin/services`). They will generate an API key — copy it immediately, it is shown only once.
2. **Set the environment variables** listed above in your deployment environment.
3. **Deploy** your service to a publicly accessible URL (HTTPS required for production).
4. **Test** the SSO flow: log into UUM, click your service card, and verify you land on your dashboard.
5. **Enable public signup** (optional): if you want users to self-register, ask the UUM admin to set `allow_public_signup: true` and configure a `default_plan_id`.
```

### 15.2. Agent Instructions for the Handoff

When you (the AI agent) have finished implementing the integration:

1. **Gather the actual values** — read the user's `.env` file, project config, and domain. If a value requires a user decision (e.g. "what roles do you want?"), ask explicitly. Do NOT guess the `UUM_API_KEY` — the user must obtain it from the UUM admin.

2. **Fill the template above** — replace every `{{PLACEHOLDER}}` with real values.

3. **Output the completed handoff summary** as your final response. The user should be able to copy-paste the service registration table and send it to the UUM admin.

4. **If the user is implementing this for a monorepo or dev environment**, note that:
   - `callback_url` in dev may be `http://localhost:3000/auth/sso`
   - `UUM_BASE_URL` in dev may point to a local UUM instance
   - The UUM admin must register the dev callback URL as well (or update it later)

5. **If the user's service has a "Super Admin" or "god mode" role**, instruct them to associate it with the `SUPER_ADMIN_EMAIL` environment variable. The default value is `admin@sandboxlabs.uk`. In UUM, the first user who registers with this email is automatically promoted to superadmin. Explain to the user: *"The email in `SUPER_ADMIN_EMAIL` determines who gets god-mode access to your service. By default this is `admin@sandboxlabs.uk`. Change it if your superadmin uses a different email."*

6. **Always include the verification checklist reminder.** The integration is not done until § 2.3 passes.
