Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,73 @@ Continue to [Validation](#validation), [Import](#importing-users), and [Post-Imp

---

## Migrating from Supabase Auth

Full Supabase Auth migration support: users + OAuth identities via the Admin API; bcrypt password hashes, TOTP MFA factors, SAML SSO connections, and organizations + role assignments via direct Postgres. Available end-to-end via the interactive wizard (`workos-migrate wizard` → choose Supabase Auth) or the individual CLI commands below.

### 1. Set up Supabase credentials

You will need:

- **Project URL** — `https://<project-ref>.supabase.co` (Settings → API in the Supabase dashboard).
- **Service Role Key** — the `service_role` JWT (Settings → API → Project API keys). This is _not_ the `anon` key; the service-role key is required for the Admin API.
- **Postgres connection string** (optional, but required for passwords/MFA/SSO) — the **direct** connection string (Settings → Database → Connection string). Use the direct connection on port `5432`, not the pooler on `6543`; PgBouncer in transaction mode breaks prepared statements.

```bash
export SUPABASE_URL=https://your-project.supabase.co
export SUPABASE_SERVICE_ROLE_KEY=eyJ...
export SUPABASE_DB_URL="postgresql://postgres:...@db.your-project.supabase.co:5432/postgres?sslmode=require"
```

### 2. Export users (and MFA + SSO if `--db-url` is provided)

```bash
workos-migrate export-supabase \
--url "$SUPABASE_URL" \
--service-role-key "$SUPABASE_SERVICE_ROLE_KEY" \
--db-url "$SUPABASE_DB_URL" \
--entities users,identities,mfa,sso \
--package \
--output-dir ./migration-supabase
```

This produces a migration package directory with `users.csv`, `totp_secrets.csv`, `sso/saml_connections.csv`, `manifest.json`, `warnings.jsonl`, and `skipped_users.jsonl`. Linked OAuth identities (Google, GitHub, etc.) are preserved on each user row as `metadata.supabase_identities`. Users without an email and users currently banned (`banned_until` in the future) are skipped and recorded in `skipped_users.jsonl`.

If you omit `--db-url`, the export still produces `users.csv` (with `metadata.supabase_identities`) but `mfa` and `sso` are skipped with warnings.

| Flag | Default | Description |
| ------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--rate-limit <n>` | 50 | Admin API requests per second |
| `--page-size <n>` | 1000 | Users per Admin API page |
| `--entities <list>` | `users` | Comma-separated entities — `users`, `identities`, `mfa`, `sso`, `organizations` |
| `--db-url <conn-string>` | — | Postgres connection string (required for `mfa`/`sso`/`organizations`); also reads `SUPABASE_DB_URL` |
| `--totp-issuer <name>` | `Supabase` | Issuer label written into `totp_secrets.csv` |
| Org schema flags | — | See [docs/supabase-org-schema.md](./docs/supabase-org-schema.md) for the full schema-flag reference (`--org-table`, `--org-members-table`, `--membership-role-column`, `--role-slug-map`, etc.) |

### 3. Merge bcrypt password hashes (optional)

The Admin API does not expose `auth.users.encrypted_password`. Run `merge-passwords-supabase` to pull bcrypt hashes from Postgres and merge them into the package's `users.csv`:

```bash
workos-migrate merge-passwords-supabase \
--package ./migration-supabase \
--db-url "$SUPABASE_DB_URL"
```

Only bcrypt prefixes (`$2a$`, `$2b$`, `$2y$`) are accepted — other algorithms are skipped and recorded in the manifest's warnings.

### 4. Validate, import, and post-import

```bash
workos-migrate validate --csv ./migration-supabase/users.csv
workos-migrate import-package ./migration-supabase
workos-migrate enroll-totp --input ./migration-supabase/totp_secrets.csv
```

SAML SSO connections in `sso/saml_connections.csv` are imported manually via the WorkOS dashboard (Settings → SSO → Import). Continue to [Validation](#validation), [Import](#importing-users), and [Post-Import](#post-import-totp-and-roles) below.

---

## Custom CSV

If you already have a CSV in WorkOS format (see [CSV Format](#csv-format) above), skip straight to validation:
Expand Down
179 changes: 179 additions & 0 deletions docs/supabase-org-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# Supabase organizations & role mapping

Supabase Auth has no built-in organization concept — orgs typically live in your application's `public.*` schema. To export them, `export-supabase` accepts a group of flags that describe your schema, then builds safe parameterized queries against the tables you name.

## Required flags (must all be supplied together)

| Flag | Description |
| ----------------------------- | --------------------------------------------------------------------------- |
| `--org-table` | Postgres table holding organizations (e.g., `public.organizations`) |
| `--org-id-column` | Column on `--org-table` that holds the primary id |
| `--org-name-column` | Column on `--org-table` that holds the display name |
| `--org-members-table` | Postgres table holding org memberships (e.g., `public.org_members`) |
| `--membership-user-column` | Column on `--org-members-table` that holds the user UUID (joined to `auth.users.id`) |
| `--membership-org-column` | Column on `--org-members-table` that holds the org id (joined to `--org-id-column`) |

If any of the above is supplied without all the others, `export-supabase` fails with `Incomplete org schema flags`.

## Optional flags

| Flag | Description |
| ----------------------------- | --------------------------------------------------------------------------- |
| `--org-external-id-column` | Column on `--org-table` that holds the external org identifier (e.g., a `slug`). Defaults to `--org-id-column`. |
| `--org-domains-column` | Column on `--org-table` that holds the org domain. Accepts a `text` scalar or a `text[]` array; arrays are comma-joined. |
| `--membership-role-column` | Column on `--org-members-table` that holds the per-membership role (e.g., `owner`, `member`). Required if you want `role_slugs` populated. |
| `--role-slug-map` | Path to a JSON or CSV file mapping raw DB role values to WorkOS role slugs (see below). |

## Identifier rules

Every flag value is validated against `/^[a-zA-Z_][a-zA-Z0-9_]*$/` before any SQL is constructed. This is stricter than what Postgres allows — non-ASCII identifiers and identifiers containing special characters are rejected even though they may exist in your database. This is defense-in-depth against SQL injection through CLI arguments.

If you have an identifier that doesn't match this pattern, create a database VIEW that renames it (see Example B).

## Role-slug map format

The role-slug map translates raw DB role values into the role slugs your WorkOS environment expects. Two formats are accepted:

**JSON dict (`.json`):**

```json
{
"owner": "admin",
"admin": "admin",
"member": "member",
"guest": "viewer"
}
```

**CSV with `role,slug` columns (`.csv`):**

```csv
role,slug
owner,admin
admin,admin
member,member
guest,viewer
```

Matching is **case-sensitive**: a DB row with `role = 'Owner'` will not match the key `owner`. Normalize at the source if needed.

A DB role with no corresponding map entry produces a per-membership warning (`Unmapped role: <value>`) in `warnings.jsonl` and leaves `role_slugs` empty for that membership. Memberships are still exported.

If `--role-slug-map` is not supplied, the DB role value is written verbatim into `role_slugs`.

## Example A — Single-tenant teams schema

Schema:

```sql
CREATE TABLE public.teams (
id UUID PRIMARY KEY,
slug TEXT UNIQUE,
name TEXT,
domain TEXT
);

CREATE TABLE public.team_members (
team_id UUID REFERENCES public.teams(id),
user_id UUID REFERENCES auth.users(id),
role TEXT, -- 'owner', 'admin', 'member'
PRIMARY KEY (team_id, user_id)
);
```

Invocation:

```bash
workos-migrate export-supabase \
--url https://abc.supabase.co \
--service-role-key sk_... \
--db-url postgresql://postgres:...@db.abc.supabase.co:5432/postgres \
--package --output-dir ./migration-supabase \
--entities users,identities,mfa,organizations \
--org-table public.teams \
--org-id-column id \
--org-name-column name \
--org-external-id-column slug \
--org-domains-column domain \
--org-members-table public.team_members \
--membership-user-column user_id \
--membership-org-column team_id \
--membership-role-column role \
--role-slug-map ./roles.json
```

## Example B — B2B with separate orgs and roles tables

If your schema stores roles in a join table rather than as a column on the membership row, the schema-flag approach won't work directly — only a single column per membership is supported.

Workaround: create a VIEW that flattens the roles into a single text column.

Schema:

```sql
CREATE TABLE public.organizations (
id UUID PRIMARY KEY,
name TEXT
);

CREATE TABLE public.organization_members (
organization_id UUID,
user_id UUID,
PRIMARY KEY (organization_id, user_id)
);

CREATE TABLE public.organization_member_roles (
organization_id UUID,
user_id UUID,
role TEXT,
PRIMARY KEY (organization_id, user_id, role)
);
```

View:

```sql
CREATE VIEW public.organization_members_with_roles AS
SELECT om.organization_id,
om.user_id,
(
SELECT string_agg(omr.role, ',' ORDER BY omr.role)
FROM public.organization_member_roles omr
WHERE omr.organization_id = om.organization_id
AND omr.user_id = om.user_id
) AS role
FROM public.organization_members om;
```

Invocation:

```bash
workos-migrate export-supabase \
--url https://abc.supabase.co \
--service-role-key sk_... \
--db-url postgresql://postgres:...@db.abc.supabase.co:5432/postgres \
--package --output-dir ./migration-supabase \
--entities users,organizations \
--org-table public.organizations \
--org-id-column id \
--org-name-column name \
--org-members-table public.organization_members_with_roles \
--membership-user-column user_id \
--membership-org-column organization_id \
--membership-role-column role
```

The view returns a single `role` column with comma-joined values per membership; the role-slug map maps each raw value through to a WorkOS slug.

## Orphan memberships

The exporter joins `--org-members-table` against `auth.users` via INNER JOIN. Memberships referencing a `user_id` that is not present in `auth.users` (orphan memberships, common after manual user deletions) are silently dropped from the output. A separate count query reports them in `warnings.jsonl` so they're visible in the manifest's warnings list.

## What ends up where

- `organizations.csv` — one row per org from `--org-table`
- `organization_memberships.csv` — one row per (user, org, role) tuple from `--org-members-table`, joined to `auth.users` for email + external_id
- `warnings.jsonl` — unmapped roles, orphan counts, table-not-found, role-slug-map load failures
- `manifest.json` — `entitiesExported.organizations` and `entitiesExported.memberships` reflect emitted row counts

Downstream `import-package` consumes these unchanged.
Loading