diff --git a/IA.md b/IA.md index 4be54ce..8019f1e 100644 --- a/IA.md +++ b/IA.md @@ -43,13 +43,13 @@ live at `/docs/errors/` — permanent, never restructured (CIP-3338). - [x] Section scaffold 🚧 (index + supabase stub with facet exemplar) - [ ] `/integrations` index — category grid w/ setup badges -- [ ] `/integrations/supabase` — flagship tutorial (CIP-3328) -- [ ] `/integrations/supabase/database` -- [ ] `/integrations/supabase/auth` -- [ ] `/integrations/supabase/dashboard-experience` — Table Editor, expose eql schema +- [x] `/integrations/supabase` — flagship tutorial (CIP-3328) +- [x] `/integrations/supabase/database` +- [x] `/integrations/supabase/auth` +- [x] `/integrations/supabase/dashboard-experience` — Table Editor, expose eql schema - [ ] ⛔ `/integrations/supabase/edge-functions` — pending Deno/FFI answer - [ ] ⛔ `/integrations/supabase/realtime` — pending product verification -- [ ] `/integrations/drizzle` — merge the two divergent Drizzle pages +- [ ] `/integrations/drizzle` 🚧 — merge the two divergent Drizzle pages - [ ] `/integrations/prisma-next` - [ ] `/integrations/aws/rds-aurora` — Proxy path - [ ] `/integrations/aws/dynamodb` @@ -155,7 +155,7 @@ live at `/docs/errors/` — permanent, never restructured (CIP-3338). - [ ] `/reference/stack` — client + configuration (port encryption/* pages) - [ ] `/reference/stack/schema` - [ ] `/reference/stack/encrypt-decrypt` (+ bulk, models) -- [ ] `/reference/stack/supabase` — THE canonical `encryptedSupabase` page, ONE signature (CIP-3328) +- [x] `/reference/stack/supabase` — THE canonical `encryptedSupabase` page, ONE signature (CIP-3328) - [ ] `/reference/stack/drizzle-operators` - [ ] `/reference/stack/errors` — port error-handling; miette catalog later (CIP-3338) - [ ] `/reference/stack/upgrading-from-protect` (retitled package-rename guide) @@ -188,5 +188,9 @@ live at `/docs/errors/` — permanent, never restructured (CIP-3338). documents the release as decided, ahead of the eql_v3 branch: payload `v: 3`, OPE SEM specifier, Docker tag `:17-3.0.0`, `version()` output, schema files. Each must land upstream or be walked back in the docs before merge +- [ ] ⛔ Stack SDK Supabase-wrapper v3 alignment (CIP-3355, blocks CIP-3335) — the + Supabase section documents the 0.18 wrapper API with v3 wire semantics; the + wrapper itself is still v2 (composite type, `like` wire op, v2 payloads) and + the SDK's v3 branches don't touch `src/supabase/` yet - [ ] Flip `ENABLE_V2_REDIRECTS=1`, delete `content/stack` + `/stack` routes + legacy loader (CIP-3335) - [ ] Consistency sweep + Supabase listing v3 revision (CIP-3335) diff --git a/content/docs/concepts/identity-aware-encryption.mdx b/content/docs/concepts/identity-aware-encryption.mdx new file mode 100644 index 0000000..3f11530 --- /dev/null +++ b/content/docs/concepts/identity-aware-encryption.mdx @@ -0,0 +1,9 @@ +--- +title: Identity-aware encryption +description: "Lock contexts and CTS: binding encrypted values to a user identity so only that identity can decrypt them." +type: concept +--- + +This page is being built as part of the docs V2 overhaul ([CIP-3330](https://linear.app/cipherstash/issue/CIP-3330)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md). + +Until it lands, the current version lives in the [existing docs](/stack/cipherstash/encryption/identity). diff --git a/content/docs/guides/development/schema-design.mdx b/content/docs/guides/development/schema-design.mdx new file mode 100644 index 0000000..b11397f --- /dev/null +++ b/content/docs/guides/development/schema-design.mdx @@ -0,0 +1,9 @@ +--- +title: Schema design +description: "Choosing the right encrypted type and capability for each column." +type: guide +--- + +This page is being built as part of the docs V2 overhaul ([CIP-3327](https://linear.app/cipherstash/issue/CIP-3327)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md). + +Until it lands, [EQL core concepts](/reference/eql/core-concepts) covers the capability model, and the per-type pages ([numbers](/reference/eql/numbers), [dates & times](/reference/eql/dates-and-times), [text](/reference/eql/text), [JSON](/reference/eql/json)) cover choosing variants. diff --git a/content/docs/guides/migration/encrypt-existing-data.mdx b/content/docs/guides/migration/encrypt-existing-data.mdx new file mode 100644 index 0000000..df00457 --- /dev/null +++ b/content/docs/guides/migration/encrypt-existing-data.mdx @@ -0,0 +1,9 @@ +--- +title: Encrypt existing data +description: "Backfilling encryption onto live tables, column by column." +type: guide +--- + +This page is being built as part of the docs V2 overhaul ([CIP-3329](https://linear.app/cipherstash/issue/CIP-3329)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md). + +Until it lands, the current version lives in the [existing docs](/stack/cipherstash/proxy/encrypt-tool). diff --git a/content/docs/integrations/drizzle.mdx b/content/docs/integrations/drizzle.mdx new file mode 100644 index 0000000..ccaad4f --- /dev/null +++ b/content/docs/integrations/drizzle.mdx @@ -0,0 +1,13 @@ +--- +title: Drizzle +description: "Encrypted columns with Drizzle ORM." +type: tutorial +integration: + category: orm + setup: code-only + pairsWith: [supabase, nextjs] +--- + +This page is being built as part of the docs V2 overhaul ([CIP-3336](https://linear.app/cipherstash/issue/CIP-3336)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md). + +Until it lands, the current version lives in the [existing docs](/stack/cipherstash/encryption/drizzle). diff --git a/content/docs/integrations/supabase/auth.mdx b/content/docs/integrations/supabase/auth.mdx new file mode 100644 index 0000000..84f67e5 --- /dev/null +++ b/content/docs/integrations/supabase/auth.mdx @@ -0,0 +1,100 @@ +--- +title: Supabase Auth +description: "Lock decryption to the authenticated user: use the Supabase Auth session JWT as a lock context so encrypted data only decrypts for the identity it belongs to." +type: guide +components: [encryption, auth] +audience: [developer] +verifiedAgainst: + stack: "0.18.0" +--- + +Row Level Security scopes *queries* to the logged-in user. Identity-aware encryption goes one layer deeper: values encrypted with a **lock context** can only be *decrypted* by presenting the same user's identity — enforced by [CTS](/security/cts), CipherStash's token service, not by your application code. Even your own backend, holding valid workspace credentials, cannot decrypt another user's locked values without that user's session. + +With Supabase Auth, the identity is the session JWT you already have. + + +Lock contexts require a Business or Enterprise workspace plan. + + +## Register Supabase Auth with your workspace + +CTS needs to trust your Supabase project as an OIDC issuer — that's what lets it exchange a Supabase session JWT for a CipherStash identity token. The issuer for a Supabase project is: + +``` +https://.supabase.co/auth/v1 +``` + +The easiest path is the CipherStash dashboard's [Supabase integration](/integrations/supabase/dashboard-experience), which configures this during setup. Manual OIDC configuration is covered in the [auth reference](/reference/auth). + +## Lock queries to the session user + +Identify the user with their Supabase session token, then attach the lock context to any [`encryptedSupabase`](/reference/stack/supabase) query: + +```typescript +import { LockContext } from "@cipherstash/stack/identity" +import { db } from "./lib/db" +import { patients } from "./lib/schema" + +// 1. The Supabase session you already have +const { data: { session } } = await supabaseClient.auth.getSession() + +// 2. Identify: exchanges the Supabase JWT for a CTS identity token +const lc = new LockContext() +const identified = await lc.identify(session.access_token) + +if (identified.failure) { + throw new Error(identified.failure.message) +} +const lockContext = identified.data + +// 3. Encrypt and decrypt under that identity +await db.from("patients", patients) + .insert({ email: "alice@example.com", name: "Alice Chen" }) + .withLockContext(lockContext) + +const { data } = await db.from("patients", patients) + .select("id, email, name") + .eq("email", "alice@example.com") + .withLockContext(lockContext) +``` + +A value written with `.withLockContext()` can only be decrypted with a lock context for the **same identity**. Reading it without one — or as a different user — fails with an encryption error, regardless of what RLS allows. + +By default the identity is the JWT's `sub` claim, which for Supabase Auth is the user's UUID. You can widen or change that: + +```typescript +const lc = new LockContext({ + context: { identityClaim: ["sub"] }, // default; add "scopes" to bind permissions too +}) +``` + +## Where it fits + +- **Per-user data** (a user's own medical history, messages, documents): lock to `sub`. The row is useless to anyone but its owner — including operators with database access and your own service role. +- **Shared/tenant data** (a support queue, org-wide records): don't lock it, or you'll be unable to decrypt it outside a user session. Plain encryption already protects it from the database side; RLS scopes who can query it. +- **Server-side jobs** that must read locked data need the owning user's session — by design. If a background job must read a field, that field shouldn't be identity-locked. + +## Errors + +`identify()` returns a result, not a throw. The common failures: + +| Scenario | What you see | +| --- | --- | +| Expired or invalid Supabase JWT | `CtsTokenError` from `identify()` | +| Issuer not registered with the workspace | `CtsTokenError` — register the OIDC issuer first | +| Expired CTS token at query time | `LockContextError` — call `identify()` again | +| Decrypting another user's locked value | `encryptionError` on the [query response](/reference/stack/supabase#responses-and-errors) | + +## Where to next + + + + The concept: lock contexts, CTS, and what identity binding proves. + + + `.withLockContext()` and `.audit()` on the query builder. + + + OIDC configuration, access keys, and clients. + + diff --git a/content/docs/integrations/supabase/dashboard-experience.mdx b/content/docs/integrations/supabase/dashboard-experience.mdx new file mode 100644 index 0000000..df4fb0f --- /dev/null +++ b/content/docs/integrations/supabase/dashboard-experience.mdx @@ -0,0 +1,96 @@ +--- +title: Dashboard experience +description: "What encrypted columns look like across the Supabase dashboard — Table Editor, SQL Editor, API settings — and how to connect your project to CipherStash via OAuth." +type: guide +components: [eql] +audience: [developer] +verifiedAgainst: + eql: "3.0.0" +--- + +Encrypted columns live inside your normal Supabase dashboard — no separate tool. This page sets expectations for what you'll see in each part of the dashboard, and covers the CipherStash-side integration that connects your project. + +## Table Editor + +Encrypted columns show **ciphertext payloads**, not plaintext. A `patients` row looks like: + +| id | email | name | plan | +| --- | --- | --- | --- | +| 1 | `{"v": 3, "i": {"t": "patients", "c": "email"}, "c": "mBbKmsMM%bK#…", "hm": "9c8ec1d2…"}` | `{"v": 3, "i": …, "c": "x7Qq…", "bf": [42, 1290, …]}` | `standard` | + +This is the point, not a limitation: what the Table Editor shows is exactly what a backup, a replication stream, a leaked `service_role` key, or a curious operator sees. The payload structure (`v`, `i`, `c`, and the index terms) is documented in [EQL core concepts](/reference/eql/core-concepts) — none of it reveals the plaintext. + +Practical consequences: + +- **Reading data** happens through your app (or any client using the [Stack SDK](/reference/stack)); the dashboard can't decrypt, because the keys never go near it. +- **Hand-editing an encrypted cell** in the Table Editor will produce a value that fails the column's `CHECK` constraint or fails to decrypt — treat encrypted columns as read-only in the dashboard. +- **Filtering and sorting** on encrypted columns in the Table Editor UI operates on the raw payloads, so it won't return meaningful results. Query through your app instead. + +## Creating encrypted columns + +Use the **SQL Editor** for schema work on encrypted columns: + +```sql +ALTER TABLE patients ADD COLUMN tax_id eql_v3.text_eq; +``` + +The `eql_v3` domain types are user-defined types, and the Table Editor's column-type picker doesn't reliably surface custom domains — SQL is the dependable path, and it's what your migrations should contain anyway (see [Database setup](/integrations/supabase/database)). + +## SQL Editor + +Everything in the [EQL reference](/reference/eql) can be run from the SQL Editor — installing EQL, creating indexes, `EXPLAIN`-ing query plans. Two checks worth knowing: + +```sql +-- Is EQL installed, and which version? +SELECT eql_v3.version(); + +-- Which columns are encrypted? +SELECT table_name, column_name, udt_name +FROM information_schema.columns +WHERE udt_schema = 'eql_v3'; +``` + +## API settings + +Normal operation needs **no** API-settings changes: your tables are in the `public` schema, and the wrapper's queries are ordinary PostgREST calls. The `eql_v3` schema needs role *grants* (covered in [Database setup](/integrations/supabase/database#grants)), but does **not** need to be in the *Exposed schemas* list unless you want to call EQL functions directly over REST. + +## Connecting your project to CipherStash + +The CipherStash dashboard has a first-class Supabase integration at [dashboard.cipherstash.com](https://dashboard.cipherstash.com): + + + +### Connect via OAuth + +Authorize CipherStash against your Supabase organization. The integration requests read-only scopes (`projects:read`, `database:read`) — it never sees your database secrets. + + +### Pick a project and run the readiness checks + +The setup hub verifies EQL is installed (`eql_v3.version()`), checks for encrypted columns, and flags anything missing. + + +### Configure identity (optional) + +One click registers your project's Supabase Auth issuer (`https://.supabase.co/auth/v1`) with your workspace, enabling [identity-locked encryption](/integrations/supabase/auth). + + +### Copy your environment + +The hub emits the `CS_*` credentials block for your app's environment, ready to pair with your existing `SUPABASE_URL` and keys. + + + +## Where to next + + + + The end-to-end setup this page supports. + + + Grants, migrations, and indexes in detail. + + + What's actually inside those ciphertext payloads. + + diff --git a/content/docs/integrations/supabase/database.mdx b/content/docs/integrations/supabase/database.mdx new file mode 100644 index 0000000..128b24e --- /dev/null +++ b/content/docs/integrations/supabase/database.mdx @@ -0,0 +1,106 @@ +--- +title: Database setup +description: "Installing and operating EQL on Supabase Postgres: migrations vs the SQL Editor, role grants, functional indexes, and how encryption composes with RLS." +type: guide +components: [eql] +audience: [developer] +verifiedAgainst: + eql: "3.0.0" +--- + +EQL installs into Supabase Postgres as plain SQL — no extension packaging, no superuser, no separate Supabase build. This page covers the database-side setup in full: how to install so it survives `supabase db reset`, the grants Supabase's roles need, indexing, and how encrypted columns interact with Row Level Security. + +## Installing EQL + +Both paths install the same artefact — the versioned `cipherstash-encrypt.sql` from the [EQL releases page](https://github.com/cipherstash/encrypt-query-language/releases). The script is idempotent: re-running a newer version upgrades the `eql_v3` schema in place. + +### Path 1: SQL Editor (fastest) + +Paste the script into the [SQL Editor](https://supabase.com/dashboard/project/_/sql/new) and run it. Good for a first look or a hosted-only project. + + +If you use the Supabase CLI locally, `supabase db reset` rebuilds your database *from your migrations directory* — anything installed directly through the SQL Editor is wiped. If you run local resets, use the migration path. + + +### Path 2: As a migration (recommended for teams) + +Commit the install script as your **earliest** migration, so EQL exists before any migration that references its types: + +```sh +curl -sLo supabase/migrations/00000000000000_install-eql.sql \ + https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt.sql + +supabase db push +``` + +The all-zero timestamp prefix sorts it before every real migration. From then on, EQL is part of your schema history: local resets, preview branches, and fresh environments all get it automatically. + +## Grants + +Supabase's API roles need access to the `eql_v3` schema, because your table columns are typed with its domains and your queries resolve its operators and functions: + +```sql +GRANT USAGE ON SCHEMA eql_v3 TO anon, authenticated, service_role; +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA eql_v3 TO anon, authenticated, service_role; +``` + +Run this once after install (and after upgrades that add functions). Without `USAGE`, PostgREST queries against tables with encrypted columns fail with a permission error on the schema. + +You do **not** need to add `eql_v3` to the *Exposed schemas* list in the [API settings](https://supabase.com/dashboard/project/_/settings/api) for normal queries — your tables live in `public`. Expose it only if you want to call EQL functions directly over the REST API. + +## Choosing column types + +The column's domain type fixes what it can do, and the [Stack SDK schema](/reference/stack/supabase#declaring-the-schema) must declare the matching capability. The full type catalogue lives in the EQL reference — [numbers](/reference/eql/numbers), [dates & times](/reference/eql/dates-and-times), [text](/reference/eql/text), [JSON](/reference/eql/json), [booleans](/reference/eql/booleans) — but the shape is always the same: + +| You query it with | Type the column as | SDK schema capability | +| --- | --- | --- | +| Nothing (store/decrypt only) | `eql_v3.` | none | +| `.eq()` / `.in()` | `eql_v3._eq` | `.equality()` | +| Ranges, `.order()` | `eql_v3._ord` | `.orderAndRange()` | +| Free-text match | `eql_v3.text_match` | `.freeTextSearch()` | +| All three (text) | `eql_v3.text_search` | all three | +| Encrypted JSON | `eql_v3.json` | `.searchableJson()` | + +Declare only the capabilities you use: every extra capability stores extra searchable material with defined leakage — see [Searchable encryption](/concepts/searchable-encryption). + +## Indexes + +Encrypted columns index through **ordinary functional indexes** over EQL's term-extractor functions. This is why EQL works on Supabase unchanged: no custom operator classes (which managed platforms block), just `CREATE INDEX`, which any migration can run: + +```sql +CREATE INDEX patients_email_eq ON patients USING hash (eql_v3.eq_term(email)); +CREATE INDEX patients_dob_ord ON patients USING btree (eql_v3.ord_term(date_of_birth)); +CREATE INDEX patients_name_match ON patients USING gin (eql_v3.match_term(name)); +ANALYZE patients; +``` + +Add them once a table has real row counts (thousands, not dozens). Index selection, `EXPLAIN` verification, and large-table build guidance are in [EQL indexes](/reference/eql/indexes) — all of it applies to Supabase verbatim. + +## Row Level Security + +Encrypted columns and RLS compose — they solve different problems: + +- **RLS** decides *which rows* a role may query. It's enforced in the database, on whatever the database stores. +- **Encryption** decides *whether a value can be read at all*. Keys stay outside the database, so anything that bypasses RLS — a leaked `service_role` key, an over-broad policy, a stolen backup, direct disk access — yields ciphertext, not plaintext. + +Keep writing policies exactly as before; the `encryptedSupabase` wrapper goes through your own supabase-js client, so the caller's JWT and your policies apply to every query. Policies can freely reference *non-encrypted* columns (`user_id`, `tenant_id`, timestamps). Policies **cannot usefully compare encrypted values to plaintext** — the database never sees plaintext, so keep RLS predicates on plaintext columns and encrypted-value filtering in your queries. + +One practical pattern: leave your row-ownership predicate on a plaintext `user_id uuid` column, and encrypt the *contents* of the row. RLS scopes the rows to `auth.uid()`; CipherStash makes their contents unreadable to everything except your app. + +## Connections and pooling + +The `encryptedSupabase` wrapper talks to Supabase over PostgREST (your existing `SUPABASE_URL`), so connection pooling concerns don't change — nothing about encryption touches the connection layer. If you also connect to the database directly (migrations, [CipherStash Proxy](/reference/proxy), an ORM like [Drizzle](/integrations/drizzle)), use the same connection strings you'd use without encryption; EQL is passive SQL and has no session requirements. + +## Where to next + + + + The end-to-end walkthrough this page backs. + + + Full indexing recipes and EXPLAIN verification. + + + Adopt encryption column-by-column on a live table. + + diff --git a/content/docs/integrations/supabase/index.mdx b/content/docs/integrations/supabase/index.mdx index 86e2ef6..0752c4f 100644 --- a/content/docs/integrations/supabase/index.mdx +++ b/content/docs/integrations/supabase/index.mdx @@ -1,6 +1,6 @@ --- title: Supabase -description: "Searchable, application-level encryption for your Supabase project — encrypt in your app, query in Postgres." +description: "Add searchable, application-level encryption to a Supabase project: install EQL, declare encrypted columns, and query them with the Supabase.js calls you already use." type: tutorial components: [encryption, eql, auth] audience: [developer] @@ -8,13 +8,188 @@ integration: category: platform setup: dashboard-required pairsWith: [drizzle, prisma-next, clerk, nextjs] +verifiedAgainst: + eql: "3.0.0" + stack: "0.18.0" --- -CipherStash adds application-level encryption to your Supabase project: -sensitive fields are encrypted in your application before they reach Postgres, -and stay queryable with the same Supabase.js calls you already use. +CipherStash adds application-level encryption to your Supabase project. Sensitive fields are encrypted in your application before they reach Postgres — plaintext never touches your database, its backups, or its replication streams — and the ciphertext stays queryable through the same Supabase.js calls you already use. CipherStash never sees your data or your keys. -This page is being rebuilt as part of the docs V2 overhaul -([CIP-3328](https://linear.app/cipherstash/issue/CIP-3328)). Until it lands, -the current Supabase integration guide lives at -[CipherStash + Supabase](/stack/cipherstash/supabase). +This tutorial takes an empty Supabase project to an encrypted, searchable table. It takes about fifteen minutes. + +## How it fits together + +Two pieces, one on each side of your Supabase connection: + +- **[EQL](/reference/eql)** installs into your Supabase Postgres as plain SQL. It provides encrypted column types — `eql_v3.text_eq`, `eql_v3.date_ord`, `eql_v3.text_match` — where the type name declares what queries the column supports. +- **[`@cipherstash/stack`](/reference/stack)** runs in your application. Its [`encryptedSupabase` wrapper](/reference/stack/supabase) encrypts values before they're written, encrypts filter terms before they're compared, and decrypts results — around your existing supabase-js client, so Supabase Auth and Row Level Security apply unchanged. + + + + +### Install EQL on your database + +Download the latest EQL install script and run it in the [SQL Editor](https://supabase.com/dashboard/project/_/sql/new): + +```sh +curl -sLo cipherstash-encrypt.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt.sql +``` + +Paste and run it — it needs no superuser and completes in seconds, creating the `eql_v3` schema. Then grant your Supabase roles access to it: + +```sql +GRANT USAGE ON SCHEMA eql_v3 TO anon, authenticated, service_role; +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA eql_v3 TO anon, authenticated, service_role; +``` + +If you manage your database with `supabase migrations` instead, commit the script as your earliest migration — the [database guide](/integrations/supabase/database) covers that path and why it survives `supabase db reset`. + + + + +### Create a table with encrypted columns + +The column type declares what each column can do. Equality lookups, free-text matching, and range/ordering are different capabilities — declare only what you query: + +```sql +CREATE TABLE patients ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email eql_v3.text_eq NOT NULL, -- exact lookup + name eql_v3.text_match, -- free-text match + date_of_birth eql_v3.date_ord, -- ranges and ORDER BY + plan text -- not sensitive: ordinary column +); +``` + +Which type fits which column — and what each stores — is covered by the [EQL type pages](/reference/eql/core-concepts). + + + + +### Install the SDK and connect your workspace + +```sh +npm install @cipherstash/stack @supabase/supabase-js +``` + +Sign up at [dashboard.cipherstash.com](https://dashboard.cipherstash.com), create a workspace, and set its credentials alongside your Supabase keys: + +```sh +CS_WORKSPACE_CRN=... +CS_CLIENT_ID=... +CS_CLIENT_KEY=... +CS_CLIENT_ACCESS_KEY=... +SUPABASE_URL=https://.supabase.co +SUPABASE_ANON_KEY=... +``` + +The CipherStash dashboard's [Supabase integration](/integrations/supabase/dashboard-experience) can generate all of this against your project via OAuth. + + + + +### Declare the encrypted schema in your app + +Mirror the table's encrypted columns, with the capability matching each column type: + +```typescript title="lib/schema.ts" +import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" + +export const patients = encryptedTable("patients", { + email: encryptedColumn("email").equality(), + name: encryptedColumn("name").freeTextSearch(), + date_of_birth: encryptedColumn("date_of_birth").dataType("date").orderAndRange(), +}) +``` + + + + +### Wrap your Supabase client + +```typescript title="lib/db.ts" +import { createClient } from "@supabase/supabase-js" +import { Encryption } from "@cipherstash/stack" +import { encryptedSupabase } from "@cipherstash/stack/supabase" +import { patients } from "./schema" + +const encryptionClient = await Encryption({ schemas: [patients] }) + +const supabaseClient = createClient( + process.env.SUPABASE_URL!, + process.env.SUPABASE_ANON_KEY!, +) + +export const db = encryptedSupabase({ encryptionClient, supabaseClient }) +``` + + + + +### Write and query encrypted data + +```typescript +import { db } from "./lib/db" +import { patients } from "./lib/schema" + +// Insert — encrypted columns are encrypted before the request leaves your app +await db.from("patients", patients).insert({ + email: "alice@example.com", + name: "Alice Chen", + date_of_birth: "1987-04-12", + plan: "standard", +}) + +// Exact lookup on an encrypted column +const { data } = await db.from("patients", patients) + .select("id, email, name") + .eq("email", "alice@example.com") + +// Free-text match (encrypted token containment — not SQL LIKE) +await db.from("patients", patients) + .select("id, name") + .ilike("name", "ali") + +// Range + newest-first on an encrypted date +await db.from("patients", patients) + .select("id, name, date_of_birth") + .gt("date_of_birth", "1980-01-01") + .order("date_of_birth", { ascending: false }) + .limit(20) +``` + +The full method surface — filters, upserts, error shapes, identity-locked queries — is in the [`encryptedSupabase` reference](/reference/stack/supabase). + + + + +### See what the database sees + +Open the Table Editor and look at the `patients` rows: every encrypted cell is a ciphertext payload, not plaintext. That's what a backup, a replication stream, or anyone bypassing your app sees — including a leaked `service_role` key. The [dashboard experience guide](/integrations/supabase/dashboard-experience) walks through what encrypted data looks like across the Supabase dashboard. + + + + +## Production checklist + +- **Indexes** — at real row counts, add functional indexes for the capabilities you query. Two lines of SQL per column: [EQL indexes](/reference/eql/indexes). +- **RLS** — keep your Row Level Security policies; they compose. RLS controls *who can query* rows, encryption controls *whether their contents can be read at all* — a bypassed policy or leaked key yields only ciphertext. +- **Identity-locked decryption** — tie decryption to the logged-in Supabase Auth user: [Supabase Auth integration](/integrations/supabase/auth). +- **Adopt incrementally** — you can encrypt one column at a time; nothing requires a big-bang migration. See [encrypting existing data](/guides/migration/encrypt-existing-data). + +## Go deeper + + + + Migrations vs SQL Editor, grants, indexes, and RLS interaction in detail. + + + Lock decryption to the authenticated user with identity-aware encryption. + + + What encrypted columns look like in the Table Editor and API settings. + + + The complete wrapper API: every filter, response shapes, and how it works. + + diff --git a/content/docs/integrations/supabase/meta.json b/content/docs/integrations/supabase/meta.json index b4690bf..e1a7455 100644 --- a/content/docs/integrations/supabase/meta.json +++ b/content/docs/integrations/supabase/meta.json @@ -1,5 +1,5 @@ { "title": "Supabase", "icon": "Supabase", - "pages": ["..."] + "pages": ["database", "auth", "dashboard-experience"] } diff --git a/content/docs/reference/stack/supabase.mdx b/content/docs/reference/stack/supabase.mdx new file mode 100644 index 0000000..d0a7b8a --- /dev/null +++ b/content/docs/reference/stack/supabase.mdx @@ -0,0 +1,228 @@ +--- +title: Supabase wrapper +description: "The canonical encryptedSupabase reference: one signature, the full query-builder surface, and how each filter maps to an encrypted index term." +type: reference +components: [encryption, eql] +audience: [developer] +verifiedAgainst: + stack: "0.18.0" + eql: "3.0.0" +--- + +`encryptedSupabase` wraps a `@supabase/supabase-js` client so that queries on encrypted columns work like queries on plaintext ones: mutations are encrypted before they leave your app, filter values are encrypted into the matching index term, and results are decrypted on the way back. You keep writing `.eq()`, `.gt()`, `.order()` — the wrapper handles the encryption on both sides of every call. + +This page is the single source of truth for the wrapper's API. The [Supabase integration](/integrations/supabase) shows it in context; the [EQL reference](/reference/eql) covers the database side it queries against. + +## Setup + +There is one signature: `encryptedSupabase` takes a config object with an encryption client and a Supabase client, and `.from()` takes the table name **and its encrypted schema**. + +```typescript +import { createClient } from "@supabase/supabase-js" +import { Encryption } from "@cipherstash/stack" +import { encryptedSupabase } from "@cipherstash/stack/supabase" +import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" + +// 1. Declare which columns are encrypted, and with which capabilities +const users = encryptedTable("users", { + email: encryptedColumn("email").equality(), + name: encryptedColumn("name").freeTextSearch(), +}) + +// 2. Create the encryption client and the ordinary Supabase client +const encryptionClient = await Encryption({ schemas: [users] }) +const supabaseClient = createClient( + process.env.SUPABASE_URL!, + process.env.SUPABASE_ANON_KEY!, +) + +// 3. Wrap +const db = encryptedSupabase({ encryptionClient, supabaseClient }) + +// 4. Query — .from() takes the table name AND the schema +const { data, error } = await db + .from("users", users) + .select("id, email, name") + .eq("email", "alice@example.com") +``` + + +Older examples show `encryptedSupabase(url, key)` and `.from("users")` with a single argument. Neither has ever been the shipped API: the config is one object with `encryptionClient` and `supabaseClient`, and `.from(tableName, schema)` always takes the schema as its second argument — that's how the wrapper knows which columns to encrypt and decrypt. + + +### `encryptedSupabase(config)` + +| Config key | Type | Purpose | +| --- | --- | --- | +| `encryptionClient` | `EncryptionClient` | The client from `Encryption({ schemas: [...] })` — does all encrypt/decrypt work | +| `supabaseClient` | `SupabaseClient` | Your existing `createClient(...)` instance — auth, RLS, and connection settings all apply unchanged | + +Returns an `EncryptedSupabaseInstance` with a single method: + +### `.from(tableName, schema)` + +`tableName` is the database table; `schema` is the `encryptedTable(...)` definition for it (the two names should match). Returns an `EncryptedQueryBuilder` that mirrors the supabase-js builder. Columns *not* declared in the schema pass through untouched — mixed plaintext/encrypted tables are the normal case. + +## Declaring the schema + +Each capability you declare on a column determines the encrypted index terms its values carry, and therefore which filters work — the same capability model the database enforces through [EQL's domain variants](/reference/eql/core-concepts): + +| Schema builder | Enables | Column type | EQL term | +| --- | --- | --- | --- | +| `encryptedColumn(name)` | Store and decrypt only | `eql_v3.` | — | +| `.equality(tokenFilters?)` | `.eq()`, `.neq()`, `.in()` | `eql_v3._eq` | `hm` | +| `.orderAndRange()` | `.gt()`, `.gte()`, `.lt()`, `.lte()`, `.order()` | `eql_v3._ord` | `ob` | +| `.freeTextSearch(opts?)` | `.like()`, `.ilike()` — encrypted token match | `eql_v3.text_match` | `bf` | +| `.equality().orderAndRange().freeTextSearch()` | All of the above (text) | `eql_v3.text_search` | all three | +| `.searchableJson()` | Encrypted JSON documents | `eql_v3.json` | `sv` | + +Use `.dataType()` for non-string columns — `"number"`, `"bigint"`, `"boolean"`, `"date"`, `"json"` (default is `"string"`). The declared capabilities must match the column's declared database type; [Schema design](/guides/development/schema-design) covers choosing them. + +```typescript +const patients = encryptedTable("patients", { + email: encryptedColumn("email").equality(), + name: encryptedColumn("name").freeTextSearch(), + date_of_birth: encryptedColumn("date_of_birth").dataType("date").orderAndRange(), + history: encryptedColumn("history").searchableJson(), +}) +``` + +## Writing data + +`insert`, `update`, and `upsert` encrypt every schema-declared column before the request leaves your process. All values in a batch are encrypted in a single ZeroKMS round trip, so large inserts don't multiply key-service calls. + +```typescript +await db.from("users", users).insert({ + email: "alice@example.com", // encrypted + name: "Alice Chen", // encrypted + plan: "free", // not in schema — passes through as plaintext +}) + +await db.from("users", users) + .update({ name: "Alice Nakamura" }) + .eq("email", "alice@example.com") + +await db.from("users", users).delete().eq("email", "alice@example.com") +``` + +`insert` and `upsert` accept a single row or an array. `upsert` supports `onConflict` and `ignoreDuplicates` as in supabase-js. + +## Reading data + +`select` takes an explicit column list. Encrypted columns in the result are decrypted before `data` is returned to you. + +```typescript +const { data, error } = await db + .from("users", users) + .select("id, email, name") + .eq("email", "alice@example.com") +``` + + +`select('*')` throws. The wrapper needs to know which columns to cast and decrypt, so list columns explicitly. Aliases (`email as contact`) and non-encrypted columns are fine. + + +## Filters + +Every filter value on an encrypted column is encrypted client-side into the term that capability queries, then sent in place of the plaintext — the database only ever compares ciphertext. Filters on columns outside the schema pass through to supabase-js unchanged. + +| Method | Requires capability | What the database compares | +| --- | --- | --- | +| `.eq(col, value)` / `.neq(col, value)` | `.equality()` | Equality (`hm`) terms | +| `.in(col, values)` | `.equality()` | Each element encrypted as an equality term | +| `.gt()` `.gte()` `.lt()` `.lte()` | `.orderAndRange()` | ORE (`ob`) terms | +| `.like(col, pattern)` / `.ilike(col, pattern)` | `.freeTextSearch()` | Bloom-filter (`bf`) token containment | +| `.is(col, value)` | — | Passed through — SQL `NULL` is never encrypted, so `is` works on every column | +| `.match({ col: value, ... })` | per column | Each pair applied as `.eq()` | +| `.filter(col, op, value)` / `.not(col, op, value)` | per operator | Escape hatch: value is encrypted, operator string passes through | +| `.or(filters)` | per column | Encrypted-column values inside the OR string are encrypted in place | + +```typescript +// Range on an orderAndRange column +await db.from("patients", patients) + .select("id, name, date_of_birth") + .gt("date_of_birth", "1980-01-01") + +// Combine encrypted and plaintext filters freely +await db.from("users", users) + .select("id, email") + .eq("email", "alice@example.com") // encrypted comparison + .eq("plan", "free") // ordinary supabase-js filter +``` + +### Free-text matching is not `LIKE` + +`.like()` and `.ilike()` on a `.freeTextSearch()` column perform **encrypted token containment**: the pattern is tokenized and encrypted into a bloom-filter query, and the database tests whether the stored value contains those (encrypted) tokens. It is a probabilistic n-gram match, not SQL pattern matching — `%` wildcards and positional anchors have no effect, and matching is case-insensitive by default (the `downcase` token filter). SQL `LIKE` itself is meaningless on ciphertext and [raises on every encrypted column](/reference/eql/text). + +```typescript +// Matches rows whose name contains the tokens of "ali" +await db.from("users", users).select("id, name").ilike("name", "ali") +``` + +Tokenizer, filter, and filter-size options are set on `.freeTextSearch()` in the schema — they're properties of how values were encrypted, not of the query. + +## Ordering and pagination + +`.order()` on an `.orderAndRange()` column sorts correctly on the encrypted ORE terms — [ordering encrypted values](/reference/eql/sorting) is exactly what that capability exists for. `.limit()`, `.range()`, `.single()`, `.maybeSingle()`, and `.csv()` behave as in supabase-js. + +```typescript +await db.from("patients", patients) + .select("id, name, date_of_birth") + .order("date_of_birth", { ascending: false }) + .limit(20) +``` + +Ordering a column *without* `.orderAndRange()` doesn't error — it sorts on the raw stored payload, which is meaningless. Declare the capability if you sort on it. + +## Identity and audit + +Two methods have no supabase-js equivalent: + +```typescript +await db.from("users", users) + .select("id, email") + .eq("email", "alice@example.com") + .withLockContext(lockContext) // decryption requires this user's identity + .audit({ metadata: { reason: "support-lookup" } }) +``` + +- `.withLockContext(lockContext)` scopes every encrypt/decrypt in the query to an identity-locked context — see [Supabase Auth integration](/integrations/supabase/auth) for wiring it to Supabase Auth sessions. +- `.audit(config)` attaches metadata to the query's decryption events in the [audit log](/security/audit-logging). + +`.abortSignal()`, `.throwOnError()`, and `.returns()` pass through as in supabase-js. + +## Responses and errors + +Awaiting the builder resolves to the supabase-js response shape, extended with encryption context: + +```typescript +type EncryptedSupabaseResponse = { + data: T | null + error: EncryptedSupabaseError | null + count: number | null + status: number + statusText: string +} + +type EncryptedSupabaseError = { + message: string + details?: string + hint?: string + code?: string + encryptionError?: EncryptionError // set when encrypt/decrypt failed, not the query +} +``` + +Check `error.encryptionError` to distinguish an encryption failure (bad workspace credentials, unreachable ZeroKMS, a payload that fails validation) from an ordinary PostgREST error. + +## How it works + +The builder is deferred: method calls record intent, and nothing executes until you `await`. At that point the wrapper, in order: + +1. Encrypts mutation payloads (all rows, one ZeroKMS call) +2. Encrypts every filter value on an encrypted column into its index term +3. Adds `::jsonb` casts to encrypted columns in the `select` list +4. Replays the recorded calls onto the real supabase-js builder +5. Decrypts encrypted columns in the response + +Because the wrapped client is your own `createClient(...)` instance, Supabase Auth sessions and **Row Level Security policies apply unchanged** — RLS decides which rows come back; encryption decides whether what's in them can be read. Database-side setup (installing EQL, column types, grants, indexes) is covered in the [Supabase database guide](/integrations/supabase/database). diff --git a/content/docs/security/audit-logging.mdx b/content/docs/security/audit-logging.mdx new file mode 100644 index 0000000..ae1bba0 --- /dev/null +++ b/content/docs/security/audit-logging.mdx @@ -0,0 +1,9 @@ +--- +title: Audit logging +description: "Decryption-event logging: what's recorded, and how to attach query metadata." +type: concept +--- + +This page is being built as part of the docs V2 overhaul ([CIP-3331](https://linear.app/cipherstash/issue/CIP-3331)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md). + +Until it lands, the current version lives in the [existing docs](/stack/cipherstash/proxy/audit). diff --git a/content/docs/security/cts.mdx b/content/docs/security/cts.mdx new file mode 100644 index 0000000..23e541d --- /dev/null +++ b/content/docs/security/cts.mdx @@ -0,0 +1,9 @@ +--- +title: CTS +description: "The CipherStash Token Service: exchanging identity-provider JWTs for tokens that gate decryption." +type: concept +--- + +This page is being built as part of the docs V2 overhaul ([CIP-3330](https://linear.app/cipherstash/issue/CIP-3330)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md). + +Until it lands, the current version lives in the [existing docs](/stack/cipherstash/kms/cts).