# Multi-tenancy (https://docs.idra.zone/docs/multi-tenancy)

Host many independent organizations on one deployment, each with its own database.

One IdRa deployment can host many organizations (tenants). Each tenant has its **own Postgres database** on the same server, so a tenant's queries can only reach its own data. Each tenant gets its own address, OIDC issuer, signing keys, settings, users, apps, policies, System Log and jobs.

Tenancy is **off by default**: without `TENANT_BASE_DOMAIN`, IdRa is single-tenant.

## Turn it on

Set `TENANT_BASE_DOMAIN`, for example `idra.example`. Then:

- Each tenant lives at `https://<slug>.idra.example`, or at a verified custom domain.
- The existing database becomes the **default** tenant, still at `BASE_URL`. Nothing is copied or rewritten.
- Unknown hosts get **404**; suspended tenants get **403**. `X-Forwarded-Host` is never trusted.

On Railway, add the wildcard `*.idra.example` as a custom domain on the service (plus the operator host from `BASE_URL`), and create the DNS records Railway shows.

## What's per tenant

- OIDC issuer `https://<tenant host>/api/auth`, its JWKS, SAML certificates, SCIM tokens, admin API tokens, Shared Signals streams and Fleet webhook secrets.
- Branding, email templates, policies, System Log.
- Cookies are host-only. A tenant on a custom domain can widen them for Access Gateway apps (**Customizations → Domains → Access Gateway cookie domain**).
- Session and token secrets are derived from `BETTER_AUTH_SECRET` plus the tenant id; `ENCRYPTION_KEY` is shared.
- The same email address can exist in two tenants as two unrelated people, with separate passwords and sessions.

## Platform administrators

```sh
bun run admin:create you@company.com "Your Name" --platform
```

This creates a Platform Administrator in the operator tenant (`PLATFORM_TENANT`, default `default`). The platform area is at `https://<operator host>/platform` (also in the admin console's user menu) and only answers on that host. There you can:

- create tenants (database, migrations and the first admin's activation email), and follow new organizations under **Onboarding**;
- suspend and resume them (a suspended tenant's jobs wait);
- delete them (type the slug to confirm; the database is dropped);
- manage domains and limits (users, apps, API requests per minute: authenticated API and OAuth-client requests; sign-in and console traffic don't count), and see platform events.

The `platform_admin` role can't be granted from the console or the admin API. To create the first admin inside an existing tenant: `bun run admin:create admin@customer.com "Name" --tenant <slug>`.

## Self-serve signup

Set `SIGNUP_ENABLED=true` to open `https://<operator host>/signup`. A new organization goes through these steps:

1. **Sign up.** The person enters the organization name, an address (checked live: invalid, reserved and taken addresses are refused as they type), their name and work email. Nothing is created yet: the address is held for 24 hours and a confirmation link is emailed.
2. **Confirm the email.** The link opens a confirmation page on the operator host. Selecting **Create organization** proves the address and starts provisioning. Opening the link alone does nothing, so mail scanners can't create organizations.
3. **Provisioning.** IdRa creates the database, applies migrations, seeds defaults and the organization name, and creates the first Super Administrator, then sends the browser to `https://<slug>.<base domain>/login`.
4. **Activate.** The admin gets an activation email (printed in the log without `RESEND_API_KEY`), sets a password, signs in and enrolls MFA.
5. **Setup wizard.** The console opens `/admin/setup`: secure the account, branding, invite people, add a first app. Any step can be skipped; **Finish later** leaves a link on the Overview page, and the wizard resumes where it was.

Asking again with the same email replaces the link. If provisioning fails, the database and registration are removed, the person sees a **Try again** button on the same link, and the operator sees the reason. Only tenants created after this feature shipped get the wizard.

### Abuse protection

- Each IP can attempt 5 signups per hour, and each email address 3. The availability check allows 60 lookups per IP per minute. Set `TRUST_PROXY`, or every visitor shares one bucket.
- The operator tenant's CAPTCHA (**Security → General**) is required when one is configured.
- No database exists until an email address is confirmed, and an unconfirmed address is released after 24 hours.

### Tracking onboarding

**Platform → Onboarding** lists every signup, and every tenant an operator created with a first admin:

| Status | Meaning | Actions |
|---|---|---|
| Awaiting email verification | The link hasn't been confirmed | Resend verification, Cancel |
| Link expired | Not confirmed within 24 hours. The address is free | Cancel |
| Provisioning | Running now | |
| Failed | Rolled back; the reason is shown | Retry, Cancel |
| Awaiting activation | The tenant is live; the admin has no password yet | Resend activation |
| Setting up | The admin activated; the wizard isn't finished | |
| Complete | The wizard is finished | |

Cancelling kills the link and frees the address. A tenant that already exists is removed with **Delete** on its page. Each step is a platform event (`platform.signup.*`, `platform.tenant.create`, `platform.onboarding.*`), shown under **Events**; the wizard's steps are in the tenant's own System Log (`system.setup.*`). Abandoned signups are removed at boot a week after they expire.

## Custom domains

1. A tenant admin adds the hostname under **Customizations → Domains**.
2. They create the TXT record shown (`_idra.<host>` = `idra-verification=<token>`) and a CNAME from the host to the service, then select **Verify**.
3. The operator adds the hostname to the Railway service for TLS (or fronts it with Cloudflare for SaaS).
4. **Make primary** moves the issuer and sign-in URLs to that domain.

**Changing the primary domain**

  OIDC and SAML apps need the new issuer, sessions on the old host don't carry over, and passkeys
  registered on the old host stop working.

## Database and connections

- The `DATABASE_URL` role needs `CREATEDB` (Railway's `postgres` user has it). Tenant databases are named `idra_<id>`; deleting a tenant runs `DROP DATABASE … WITH (FORCE)`.
- The main pool (10 connections) plus active tenants × `TENANT_POOL_MAX` (default 2) must stay below Postgres `max_connections`. Idle tenants close their connections after `TENANT_POOL_IDLE_TIMEOUT` seconds.
- Still **one replica**: the tenant cache and the signup rate limits are per process.

## Development

`TENANT_BASE_DOMAIN=localhost`, then open `http://<slug>.localhost:5173`. Browsers resolve `*.localhost` to loopback. Tenant databases live under `.data/pglite-tenants/`.