diff --git a/IA.md b/IA.md index a56a5cc..c96ab5d 100644 --- a/IA.md +++ b/IA.md @@ -303,13 +303,13 @@ Two notes: - [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` @@ -420,7 +420,7 @@ and the old `/compare/*` paths redirect there (`v2-redirects.mjs`). - [ ] `/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) @@ -453,5 +453,9 @@ and the old `/compare/*` paths redirect there (`v2-redirects.mjs`). 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/nextjs.mdx b/content/docs/integrations/nextjs.mdx new file mode 100644 index 0000000..18ef87d --- /dev/null +++ b/content/docs/integrations/nextjs.mdx @@ -0,0 +1,13 @@ +--- +title: Next.js +description: "Application-level encryption in a Next.js app: where encryption runs across Server Components, Route Handlers, and Server Actions." +type: tutorial +integration: + category: framework + setup: code-only + pairsWith: [supabase, drizzle, prisma] +--- + +This page is being built as part of the docs V2 overhaul ([CIP-3307](https://linear.app/cipherstash/issue/CIP-3307)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md). + +Until it lands, current documentation lives in the [existing docs](/stack). diff --git a/content/docs/integrations/prisma.mdx b/content/docs/integrations/prisma.mdx new file mode 100644 index 0000000..f23a5c0 --- /dev/null +++ b/content/docs/integrations/prisma.mdx @@ -0,0 +1,13 @@ +--- +title: Prisma +description: "Encrypted columns with Prisma 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-3307](https://linear.app/cipherstash/issue/CIP-3307)). 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/prisma-next). diff --git a/content/docs/integrations/supabase/auth.mdx b/content/docs/integrations/supabase/auth.mdx new file mode 100644 index 0000000..e4b79a9 --- /dev/null +++ b/content/docs/integrations/supabase/auth.mdx @@ -0,0 +1,131 @@ +--- +title: Supabase Auth +description: "Federate the Supabase Auth session into CipherStash so encryption authenticates as the signed-in user — then, optionally, lock decryption to that user's identity." +type: guide +components: [encryption, platform] +audience: [developer] +verifiedAgainst: + stack: "0.18.0" + auth: "0.41.0" +--- + +Supabase Auth already knows who your user is. This page connects that identity to CipherStash in two layers you can adopt independently: + +1. **Federation** — exchange the user's Supabase session for a CipherStash token, so every encryption and decryption request authenticates *as that user* instead of as a shared service credential. This is the foundation. +2. **Identity-bound encryption (lock context)** — an optional layer on top of federation that binds a value to the user's identity claim, so only that user can decrypt it — enforced by ZeroKMS, not by your application code. + +Federation is useful on its own. Lock context requires it. Start with federation; add lock context where per-user secrecy matters. + +## Register Supabase as an OIDC provider + +CipherStash needs to trust your Supabase project as an OIDC issuer before it will accept a Supabase session token. Add the provider once, in the CipherStash dashboard at [dashboard.cipherstash.com/workspaces/_/oidc-providers](https://dashboard.cipherstash.com/workspaces/_/oidc-providers) (the `_` resolves to whichever workspace you select). A Supabase project's issuer is: + +``` +https://.supabase.co/auth/v1 +``` + +> **Good to know**: the dashboard's [Supabase integration](/integrations/supabase/fundamentals#connecting-your-project-to-cipherstash) can register this for you in one click while it's connected to your project over OAuth. Manual OIDC configuration is covered in the [auth reference](/reference/auth). + +## Federate the Supabase session + +Authenticate the `Encryption` client with an `OidcFederationStrategy`. It takes your workspace CRN and a `getJwt` callback that returns the user's **current** Supabase access token — the strategy calls it on first use and again on every re-federation, so return a fresh token each time rather than capturing one: + +```typescript title="lib/db.ts" +import { createClient } from "@supabase/supabase-js" +import { Encryption, OidcFederationStrategy } from "@cipherstash/stack" +import { encryptedSupabase } from "@cipherstash/stack/supabase" +import { patients } from "./schema" + +const supabaseClient = createClient( + process.env.SUPABASE_URL!, + process.env.SUPABASE_ANON_KEY!, +) + +// Return the current Supabase session token — re-invoked on every re-federation. +const getJwt = async () => { + const { data: { session } } = await supabaseClient.auth.getSession() + if (!session) throw new Error("No active Supabase session") + return session.access_token +} + +const encryptionClient = await Encryption({ + schemas: [patients], + config: { + authStrategy: OidcFederationStrategy.create(process.env.CS_WORKSPACE_CRN!, getJwt), + }, +}) + +export const db = encryptedSupabase({ encryptionClient, supabaseClient }) +``` + +That's the whole integration. Every `db.from(...)` query now encrypts and decrypts under the signed-in user's identity, and your Supabase Auth session and RLS policies apply to the underlying request exactly as before. + +> **Good to know**: the federation endpoint issues no refresh token — when the CipherStash token expires, the strategy re-federates by calling `getJwt` again. Because `getJwt` reads from `supabaseClient.auth.getSession()`, supabase-js's own token refresh keeps it valid. This is also why the strategy runs **server-side only** (a Next.js Route Handler / Server Action, an Edge Function) — never in the browser, where it would expose the session token. For the Edge runtime, see [Edge Functions](/integrations/supabase/edge-functions). + +## Identity-bound encryption (lock context) + +Federation authenticates *as* the user. **Lock context** goes further: it bakes a claim from the user's JWT (by default `sub`, which for Supabase Auth is the user's UUID) into the data key, so a value can only be decrypted by presenting the same identity. Even your own backend — holding valid workspace credentials — cannot decrypt another user's locked values. + + +Lock context requires the client to be authenticated with `OidcFederationStrategy` (above). It cannot be used with `AccessKeyStrategy`, which authenticates a *service*, not a user — there is no user `sub` claim to bind to. Lock context also requires a Business or Enterprise workspace plan. + + +Attach a lock context to any [`encryptedSupabase`](/reference/stack/supabase) query with `.withLockContext()`, passing the identity claim to bind: + +```typescript +import { db } from "./lib/db" +import { patients } from "./lib/schema" + +// Insert — the value is bound to the signed-in user's `sub` claim +await db.from("patients", patients) + .insert({ email: "alice@example.com", name: "Alice Chen" }) + .withLockContext({ identityClaim: ["sub"] }) + +// Read — the same claim must be supplied, and the same user must be authenticated +const { data } = await db.from("patients", patients) + .select("id, email, name") + .eq("email", "alice@example.com") + .withLockContext({ identityClaim: ["sub"] }) +``` + +ZeroKMS resolves the claim's *value* from the token that authenticated the request — the federated Supabase identity — so no separate identify step or per-operation token is needed. Reading a locked value without a matching context, or as a different user, fails with an encryption error regardless of what RLS allows. + + +Earlier releases used `new LockContext().identify(jwt)` to fetch a per-operation token. That flow is **deprecated** — per-operation CTS tokens were removed in protect-ffi 0.25. Authenticate the client with `OidcFederationStrategy` and pass the claim directly to `.withLockContext()`, as above. + + +## 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. Federation alone still authenticates access as the user; RLS scopes which rows they see. +- **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 + +Both federation and lock context surface failures as values, not throws. + +| Scenario | What you see | +| --- | --- | +| Provider not registered with the workspace | Federation fails — register the Supabase OIDC issuer first | +| Expired or invalid Supabase session | `getJwt` throws / returns no session; federation cannot proceed | +| CipherStash token expired mid-request | Strategy re-federates automatically via `getJwt` | +| Decrypting another user's locked value | `encryptionError` on the [query response](/reference/stack/supabase#responses-and-errors) | + +The strategy's own factory and token errors follow the `@cipherstash/auth` `Result` shape (`failure.type`); see the [auth reference](/reference/auth). + +## Where to next + + + + The concept: federation, lock contexts, and what identity binding proves. + + + Run the federation strategy server-side in Supabase Edge Functions. + + + `.withLockContext()` and `.audit()` on the query builder. + + + OIDC providers, federation strategies, and access keys. + + diff --git a/content/docs/integrations/supabase/edge-functions.mdx b/content/docs/integrations/supabase/edge-functions.mdx new file mode 100644 index 0000000..49ce8a2 --- /dev/null +++ b/content/docs/integrations/supabase/edge-functions.mdx @@ -0,0 +1,127 @@ +--- +title: Edge Functions +description: "Run CipherStash encryption inside Supabase Edge Functions using the @cipherstash/stack WebAssembly build, with the CipherStash token cached across invocations in an HTTP-only cookie." +type: guide +components: [encryption, platform] +audience: [developer] +verifiedAgainst: + stack: "0.18.0" + auth: "0.41.0" +--- + +Encryption and decryption must run somewhere your keys and credentials are safe — never in the browser. Supabase Edge Functions are that place: server-side compute next to your database. CipherStash ships a **WebAssembly build** of the SDK for exactly this runtime, so the same encrypt/decrypt code runs in Deno with no native bindings. + + +**Verifying the Supabase wrapper on the edge.** The `@cipherstash/stack/wasm-inline` entry exports the core `Encryption` client (`client.encrypt` / `client.decrypt`), which this page uses. Whether the higher-level [`encryptedSupabase`](/reference/stack/supabase) wrapper is available on the edge build is being confirmed — this page will be updated with the wrapper pattern once verified. Until then, use the core client shown below inside your Edge Function. + + +## Why the wasm build + +Supabase Edge Functions run on Deno, which loads npm packages through the `node` compatibility layer. The SDK's default entry resolves to native NAPI bindings that don't exist in that runtime, so the edge uses a dedicated entry: `@cipherstash/stack/wasm-inline`. It embeds the WebAssembly module as base64 inside the JS bundle — no separate `.wasm` fetch, no bundler plugins, no `static_files` config. The same applies to the auth package (`@cipherstash/auth/wasm-inline`). + +## Set up the function + +Map the `wasm-inline` entries in your function's `deno.json`: + +```jsonc title="supabase/functions/encrypt/deno.json" +{ + "imports": { + "@cipherstash/stack/wasm-inline": "npm:@cipherstash/stack@^0.18/wasm-inline", + "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^0.41/wasm-inline", + "@cipherstash/auth/cookies": "npm:@cipherstash/auth@^0.41/cookies" + } +} +``` + +Provide the `CS_*` credentials when you serve the function — locally via an env file: + +```bash +supabase functions serve --env-file ./supabase/functions/.env.local +``` + +```sh title="supabase/functions/.env.local" +CS_WORKSPACE_CRN=crn:ap-southeast-2.aws: +CS_CLIENT_ID=... +CS_CLIENT_KEY=... +CS_CLIENT_ACCESS_KEY=... +``` + +For deployed functions, set the same values as [Edge Function secrets](https://supabase.com/dashboard/project/_/settings/functions). + +## Encrypt and decrypt in a function + +Each invocation builds a fresh client, but the CipherStash service token is cached in an HTTP-only cookie via `cookieStore`, so only the first request per session pays the full round-trip to CipherStash: + +```typescript title="supabase/functions/encrypt/index.ts" +import { Encryption, AccessKeyStrategy, encryptedTable, encryptedColumn } from "@cipherstash/stack/wasm-inline" +import { cookieStore } from "@cipherstash/auth/cookies" + +const patients = encryptedTable("patients", { + email: encryptedColumn("email").equality(), +}) + +Deno.serve(async (req) => { + const responseHeaders = new Headers({ "content-type": "application/json" }) + + // Cache the CipherStash token across invocations in an HTTP-only cookie + const authStrategy = AccessKeyStrategy.create( + Deno.env.get("CS_WORKSPACE_CRN")!, + Deno.env.get("CS_CLIENT_ACCESS_KEY")!, + { store: cookieStore({ request: req, responseHeaders }) }, + ) + if (authStrategy.failure) { + return Response.json({ error: authStrategy.failure.type }, { status: 500, headers: responseHeaders }) + } + + const client = await Encryption({ + schemas: [patients], + config: { + authStrategy: authStrategy.data, + clientId: Deno.env.get("CS_CLIENT_ID")!, + clientKey: Deno.env.get("CS_CLIENT_KEY")!, + }, + }) + + const encrypted = await client.encrypt("alice@example.com", { column: patients.email, table: patients }) + const decrypted = await client.decrypt(encrypted.data) + + return Response.json({ ok: decrypted.data === "alice@example.com" }, { headers: responseHeaders }) +}) +``` + +Note the `.failure` / `.data` result shape: on the wasm entry, `AccessKeyStrategy.create` returns a `Result` you unwrap (unlike the Node build, where it returns the strategy directly). + +## Per-user identity on the edge + +To encrypt as the signed-in Supabase user rather than a service credential, swap `AccessKeyStrategy` for `OidcFederationStrategy` — its `getJwt` callback returns the current Supabase session token. The mechanics and the identity-lock layer are covered in [Supabase Auth](/integrations/supabase/auth); the only edge-specific difference is the `wasm-inline` import and the `Result` unwrap: + +```typescript +import { OidcFederationStrategy } from "@cipherstash/stack/wasm-inline" +import { cookieStore } from "@cipherstash/auth/cookies" + +const authStrategy = OidcFederationStrategy.create( + Deno.env.get("CS_WORKSPACE_CRN")!, + () => getSupabaseSessionToken(req), // return the current session JWT + { store: cookieStore({ request: req, responseHeaders }) }, +) +``` + +## Caveats + +- **Cold start.** The first request pays the full path: WebAssembly compile, auth round-trip to CipherStash, and ZeroKMS dataset-key initialization. Subsequent requests reuse the cookie-cached token and only pay for the encrypt/decrypt work. +- **Bundle size.** The inline wasm build is larger than a native binding (the module ships as base64 in the JS). That's the trade for zero runtime config — acceptable for an edge function that boots once per worker. +- **Server-side only.** These credentials and the session token must never reach the browser. Keep this code in the Edge Function. + +## Where to next + + + + Federation and identity-locked encryption — the full identity story. + + + The standard (Node) setup with the encryptedSupabase wrapper. + + + The core Encryption client API used here. + + diff --git a/content/docs/integrations/supabase/fundamentals.mdx b/content/docs/integrations/supabase/fundamentals.mdx new file mode 100644 index 0000000..3209491 --- /dev/null +++ b/content/docs/integrations/supabase/fundamentals.mdx @@ -0,0 +1,186 @@ +--- +title: Fundamentals +description: "Install and operate EQL on Supabase Postgres — migrations vs the SQL Editor, role grants, column types, functional indexes, RLS — and work with encrypted columns across the Supabase dashboard." +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 in full — how to install so it survives `supabase db reset`, the grants Supabase's roles need, choosing column types, indexing, and how encryption composes with Row Level Security — then walks through what encrypted columns look like across the Supabase dashboard. + +If you just want the fastest path from empty project to encrypted table, start with the [Quickstart](/integrations/supabase/quickstart); this page is the reference behind it. + +## 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. To go one step further and make a row's contents decryptable *only* by its owner — even to your own service role — see [Supabase Auth](/integrations/supabase/auth). + +## 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), or 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. The direct-connection path is covered in [Data layer](/integrations/supabase/orms). + +## Working in the dashboard + +Encrypted columns live inside your normal Supabase dashboard — no separate tool. Here's what to expect in each part of it. + +### 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. + +### 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* (see [Grants](#grants) above), 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 + +Connecting is optional — you can set the `CS_*` credentials by hand ([Quickstart](/integrations/supabase/quickstart#install-the-sdk-and-connect-your-workspace)). The CipherStash dashboard's first-class Supabase integration at [dashboard.cipherstash.com](https://dashboard.cipherstash.com) automates it: + + + +### 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-aware 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 walkthrough this page backs. + + + Full indexing recipes and EXPLAIN verification. + + + Federate the session and lock decryption to the authenticated user. + + + 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 5ce53db..14dac0a 100644 --- a/content/docs/integrations/supabase/index.mdx +++ b/content/docs/integrations/supabase/index.mdx @@ -1,20 +1,97 @@ --- -title: Supabase -description: "Searchable, application-level encryption for your Supabase project — encrypt in your app, query in Postgres." -type: tutorial +title: Overview +description: "Searchable, application-level encryption for your Supabase project: encrypt sensitive fields in your app before they reach Postgres, and keep them queryable with the Supabase.js calls you already use." +type: concept components: [encryption, eql, platform] -audience: [developer] +audience: [developer, ciso] 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 — names, emails, health records, anything you choose — are encrypted inside your application before they ever reach Postgres, so plaintext never touches your database, its backups, or its replication streams. 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). +Unlike ordinary field encryption, the ciphertext stays **fully queryable**. You install [Encrypt Query Language (EQL)](/reference/eql) on your Supabase database, then declare each protected column with the encrypted type that matches the operations you need — and query it with the same `.eq()`, `.order()`, and text-search calls you use today. + + + + Empty project to an encrypted, searchable table in about fifteen minutes. + + + Install and operate EQL on Supabase Postgres, and work with encrypted columns in the dashboard. + + + +## 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 — the type name declares what a column can do: + - `eql_v3.text_eq` — encrypted text with equality (`=`) + - `eql_v3.text_match` — encrypted text with free-text search (token match) + - `eql_v3.int4_ord` — encrypted integers with range (`<`, `>`) and `ORDER BY` + - `eql_v3.timestamp_ord` — encrypted timestamps with range and ordering + - `eql_v3.json` — encrypted JSON with field access (`->`) and containment (`@>`) +- **[`@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 RLS apply unchanged. + +There are encrypted types for text, integers, floats, numerics, dates, timestamps, booleans, and JSON — so your schema declares intent, queries run with no decryption at the database, and encrypted columns still **index using standard Postgres indexes** ([EQL indexes](/reference/eql/indexes)). + +## RLS is not encryption + +Row Level Security controls *who can query* your data; CipherStash controls *whether it can be read at all*. RLS is enforced inside the database on plaintext, so its protection ends the moment that layer is bypassed — a leaked `service_role` key, an over-broad policy, a stolen backup, or direct disk access all expose data in the clear. Because CipherStash encrypts values in your app and keeps the keys outside the database, every one of those cases yields only ciphertext. + +> **RLS keeps the wrong user out. CipherStash keeps your data safe even when the wrong user gets in.** + +Use both: RLS to authorize access, CipherStash so a bypass never turns into a data breach. How the two compose on Supabase — including where RLS predicates can and can't reference encrypted columns — is covered in [Fundamentals → Row Level Security](/integrations/supabase/fundamentals#row-level-security). + +## Built for the Supabase stack + +- **Native Supabase.js** — encrypt in your app, then store and query through the same client with the same `.eq()`, `.order()`, and text-search calls. See the [Quickstart](/integrations/supabase/quickstart). +- **Supabase Auth** — federate the signed-in user's session so encryption authenticates as them, and optionally lock decryption to their identity. See [Supabase Auth](/integrations/supabase/auth). +- **Edge Functions** — the SDK runs in Supabase Edge Functions via a WebAssembly build. See [Edge Functions](/integrations/supabase/edge-functions). +- **Row Level Security** — layers on top of your RLS policies rather than replacing them. +- **Your ORM or framework** — first-class support for Prisma and Drizzle, and frameworks like Next.js, alongside Supabase. See [Data layer](/integrations/supabase/orms). + +## Key management and auditing, built in + +There's no key vault to run and no KMS to wire up. Every value is encrypted with its own key through CipherStash ZeroKMS, and key rotation is handled for you. Decryption events are logged, so you get an [access trail](/security/audit-logging) out of the box. Prefer to hold the root key yourself? Bring Your Own Key roots it in your own KMS. + +## Performance + +Measured on the [`cipherstash/benches`](https://github.com/cipherstash/benches) suite, up to 10M rows: + +- **~0.1 ms** median for exact-match lookups +- **~0.5 ms** median for encrypted range queries +- Latency stays flat as row counts scale from 10k to 10M + +Key operations run through ZeroKMS at **up to 14× the throughput of AWS KMS** (≈615 requests/sec peak), generating up to 10,000 data keys in a single operation — so key handling never becomes the bottleneck. + +## For your security team + +- **Audit logging** of decryption events +- **Bring Your Own Key (BYOK)** +- **FIPS compliance** and **SOC 2 compliance** +- **Zero plaintext exposure** — CipherStash never sees your data or your keys + +There's a free developer tier, so you can add encryption from your very first migration. Encryption is far cheaper to design in than to retrofit — building it in now saves re-architecting your data layer later. + +## Where to next + + + + The end-to-end walkthrough: install EQL, wrap your client, query encrypted data. + + + Federate the Supabase session and lock decryption to the authenticated user. + + + Run encryption server-side in Supabase Edge Functions. + + + 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..02d5b3e 100644 --- a/content/docs/integrations/supabase/meta.json +++ b/content/docs/integrations/supabase/meta.json @@ -1,5 +1,12 @@ { "title": "Supabase", "icon": "Supabase", - "pages": ["..."] + "pages": [ + "index", + "quickstart", + "fundamentals", + "orms", + "auth", + "edge-functions" + ] } diff --git a/content/docs/integrations/supabase/orms.mdx b/content/docs/integrations/supabase/orms.mdx new file mode 100644 index 0000000..e6e0fe0 --- /dev/null +++ b/content/docs/integrations/supabase/orms.mdx @@ -0,0 +1,74 @@ +--- +title: Data layer +description: "Two ways to reach your Supabase data with CipherStash: the encryptedSupabase wrapper over PostgREST, or a direct Postgres connection through an ORM like Drizzle or Prisma." +type: guide +components: [encryption, eql] +audience: [developer] +--- + +There are two ways an application talks to a Supabase database, and CipherStash rides along on both. Which one you use decides where encryption plugs in — so start here before reaching for the ORM-specific guide. + +## Two paths to your data + +| | PostgREST path | Direct-connection path | +| --- | --- | --- | +| **Client** | `supabase-js` (+ `encryptedSupabase`) | Drizzle, Prisma, or raw `pg` | +| **Transport** | Supabase's REST API (PostgREST) | A Postgres connection (pooler or direct) | +| **How encryption plugs in** | The [`encryptedSupabase`](/reference/stack/supabase) wrapper encrypts/decrypts around each request | The [Stack SDK](/reference/stack) encrypts values in your app; EQL-typed columns store and search them | +| **Best for** | Supabase-native apps, Edge Functions, RLS via the user's JWT | Existing ORM codebases, migrations, complex joins | + +Both write the **same ciphertext into the same `eql_v3` columns** — the [Fundamentals](/integrations/supabase/fundamentals) database setup is identical either way. The difference is only in how your app connects and where the encrypt/decrypt step lives. You can even mix them: an `encryptedSupabase` client for app queries and Drizzle for migrations against the same tables. + +The [Quickstart](/integrations/supabase/quickstart) covers the PostgREST path end-to-end. This page covers the direct-connection path and the Supabase-specific glue it needs. + +## Using an ORM + +The ORM integrations are **database-agnostic** — Supabase is Postgres, so the encryption mechanics are the same as anywhere else. Follow the canonical guide for your ORM, then apply the Supabase connection notes below: + + + + Encrypted column types, query operators, and EQL migrations with Drizzle. + + + Encrypted fields and searchable queries with Prisma. + + + Where encryption runs across Server Components, Route Handlers, and Server Actions. + + + +## Connecting to Supabase Postgres + +An ORM connects over a Postgres connection string, not the REST API. Supabase gives you two, and the choice matters for a serverless or edge deployment: + +- **Direct connection** (port `5432`) — one long-lived connection to the database. Use it for migrations and for long-running servers. Not suitable for serverless functions, which open a connection per invocation. +- **Supavisor pooler** — a connection pooler in front of Postgres: + - **Transaction mode** (port `6543`) — a pooled connection per transaction. Use this for serverless / edge / Next.js Route Handlers, where connections are short-lived and numerous. + - **Session mode** (port `5432` via the pooler) — pooled but session-scoped, for clients that need session features. + +Nothing about encryption changes the connection layer: EQL is passive SQL and the Stack SDK encrypts in your app, so use the **same connection string you'd use without CipherStash**. Point your migration tooling at the direct connection and your app at the transaction pooler, exactly as Supabase recommends. + +> **Good to know**: run EQL migrations through the direct connection (or Supabase's migrations directory), not the transaction pooler — DDL and the EQL install script want a real session. See [Fundamentals → Installing EQL](/integrations/supabase/fundamentals#installing-eql). + +## Row Level Security on the direct path + +RLS composes differently depending on which connection role you use: + +- The **`encryptedSupabase` / PostgREST path** carries the end user's JWT, so `auth.uid()` and your policies apply automatically. +- The **direct-connection path** typically authenticates as a privileged role (a pooler connection string for `postgres` or `service_role`), which **bypasses RLS**. If you rely on RLS for row scoping, either connect as the `authenticated` role with the user's JWT (setting `request.jwt.claims`), or enforce that scoping in your ORM query. + +Either way, encryption is unaffected: values are ciphertext regardless of which role reads them. RLS decides *which rows* are returned; CipherStash decides *whether their contents can be read at all* — see [Fundamentals → Row Level Security](/integrations/supabase/fundamentals#row-level-security). + +## Where to next + + + + The PostgREST path end-to-end with encryptedSupabase. + + + Database setup, grants, indexes, and RLS — shared by both paths. + + + Encrypt at the connection layer instead of in the app — an option for some ORM setups. + + diff --git a/content/docs/integrations/supabase/quickstart.mdx b/content/docs/integrations/supabase/quickstart.mdx new file mode 100644 index 0000000..3af2558 --- /dev/null +++ b/content/docs/integrations/supabase/quickstart.mdx @@ -0,0 +1,193 @@ +--- +title: Quickstart +description: "Take an empty Supabase project to an encrypted, searchable table: install EQL, declare encrypted columns, wrap your Supabase client, and query with the calls you already use." +type: tutorial +components: [encryption, eql, platform] +audience: [developer] +integration: + category: platform + setup: dashboard-required + pairsWith: [drizzle, prisma-next, clerk, nextjs] +verifiedAgainst: + eql: "3.0.0" + stack: "0.18.0" +--- + +This tutorial takes an empty Supabase project to an encrypted, searchable table. It takes about fifteen minutes. For what CipherStash is and why you'd add it, start with the [overview](/integrations/supabase). + +## 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 — [Fundamentals](/integrations/supabase/fundamentals#installing-eql) 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=... +``` + +> **Good to know**: the CipherStash dashboard's Supabase integration can generate this whole block for you — it connects to your project over OAuth, runs readiness checks, and emits the `CS_*` credentials. It's optional; the manual block above works everywhere. See [Fundamentals → Connecting your project](/integrations/supabase/fundamentals#connecting-your-project-to-cipherstash). + + + + +### 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. [Fundamentals → Working in the dashboard](/integrations/supabase/fundamentals#working-in-the-dashboard) 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, RLS, and the dashboard experience in detail. + + + Federate the Supabase session and lock decryption to the authenticated user. + + + Using Drizzle, Prisma, or a framework against Supabase with CipherStash. + + + The complete wrapper API: every filter, response shapes, and how it works. + + diff --git a/content/docs/reference/stack/supabase.mdx b/content/docs/reference/stack/supabase.mdx new file mode 100644 index 0000000..c183c56 --- /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 [Supabase Fundamentals](/integrations/supabase/fundamentals). 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).