wacrm/supabase/migrations/026_api_keys.sql
Arnas Donauskas 456321a2f2
feat(api): public API groundwork — scoped API keys + /api/v1/me
Adds the machine-to-machine auth layer the public REST API needs so
external scripts and automations can drive wacrm (issue #245). This is
the groundwork release; data endpoints land one at a time on top of it.

- Migration 026: account-scoped `api_keys` table. SHA-256 hash at rest
  (plaintext shown once), display `key_prefix`, `scopes[]`, RLS via
  `is_account_member` (members read, admin+ manage).
- `src/lib/api-keys/`: key gen/hash (wacrm_live_ prefix, timing-safe),
  scope vocabulary, service-role auth-path store.
- `requireApiKey(request, scope?)` (src/lib/auth/api-context.ts):
  bearer parse -> hash lookup -> liveness -> per-key rate limit ->
  scopes-only authz. Returns a service-role-scoped account context.
- `/api/v1` envelope ({data} / {error:{code,message}}) + GET /api/v1/me
  reference endpoint that exercises the whole stack.
- Dashboard: admin+ key management routes + Settings -> API keys tab.
- Docs (docs/public-api.md), CHANGELOG, README, .env note.

Checks: typecheck clean, lint 0 errors, full suite green (+27 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:35:43 +03:00

85 lines
4.1 KiB
SQL

-- ============================================================
-- 026_api_keys.sql — Public API credentials (groundwork)
--
-- Adds the `api_keys` table backing the public REST API
-- (`/api/v1/*`). A key authenticates a *machine* caller (a script,
-- an n8n/Zapier-style automation, a cron) against one account, the
-- same way the cookie session authenticates a *human* in the
-- dashboard.
--
-- Design notes
-- - Account-scoped, never user-scoped. A key belongs to the
-- account; `created_by` only records who minted it (audit), and
-- is ON DELETE SET NULL so removing a teammate doesn't cascade-
-- delete the keys their automations still depend on.
-- - We store only the SHA-256 *hash* of the key, never plaintext.
-- A leaked DB snapshot (backup, log, support export) therefore
-- can't be replayed against the API — the caller would need the
-- original key, which is returned exactly once at creation. Same
-- pattern as `account_invitations.token_hash` (migration 017/019).
-- - `key_prefix` is a short, non-secret display string
-- (`wacrm_live_a1b2c3d4`) so the dashboard can show "which key
-- is this" in a list without ever resurfacing the secret.
-- - Authorization is by `scopes[]` (scopes-only model), resolved
-- in the application layer (`src/lib/api-keys/scopes.ts`). The
-- DB doesn't constrain the scope vocabulary — a future scope is
-- a code change, not a migration.
--
-- RLS
-- `api_keys` is a settings-class table: any member may *read* the
-- roster of keys for their account; only admin+ may create/revoke
-- (mirrors the `tags` / `custom_fields` policies in 017). The
-- public-API auth path itself reads keys with the service-role
-- client (RLS-bypassing) because an API caller has no Supabase
-- session and therefore no `auth.uid()` for a policy to match.
--
-- Idempotent — safe to run multiple times. Table uses IF NOT
-- EXISTS; policies are dropped before recreate (Postgres has no
-- CREATE POLICY IF NOT EXISTS).
-- ============================================================
CREATE TABLE IF NOT EXISTS api_keys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
name text NOT NULL,
key_prefix text NOT NULL, -- display only, e.g. "wacrm_live_a1b2c3d4"
key_hash text NOT NULL UNIQUE, -- SHA-256 hex of the full plaintext key
scopes text[] NOT NULL DEFAULT '{}',
last_used_at timestamptz,
expires_at timestamptz, -- NULL = never expires
revoked_at timestamptz, -- NULL = active
created_at timestamptz NOT NULL DEFAULT now()
);
-- account_id: every "list this account's keys" query filters on it.
CREATE INDEX IF NOT EXISTS api_keys_account_id_idx ON api_keys (account_id);
-- key_hash: the hot path is the per-request auth lookup by hash. The
-- UNIQUE constraint already creates an index, but spell it out so the
-- intent (this is the lookup key) is documented and survives a future
-- drop of the UNIQUE constraint.
CREATE INDEX IF NOT EXISTS api_keys_key_hash_idx ON api_keys (key_hash);
ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
-- SELECT: any member of the account (viewer+) can see the roster.
-- key_hash is in the table but the dashboard never selects it.
DROP POLICY IF EXISTS api_keys_select ON api_keys;
CREATE POLICY api_keys_select ON api_keys FOR SELECT
USING (is_account_member(account_id));
-- INSERT / UPDATE / DELETE: admin+ only (settings-class). Revoking a
-- key is an UPDATE that sets `revoked_at`; we keep DELETE available
-- too for operators who'd rather hard-delete.
DROP POLICY IF EXISTS api_keys_insert ON api_keys;
CREATE POLICY api_keys_insert ON api_keys FOR INSERT
WITH CHECK (is_account_member(account_id, 'admin'));
DROP POLICY IF EXISTS api_keys_update ON api_keys;
CREATE POLICY api_keys_update ON api_keys FOR UPDATE
USING (is_account_member(account_id, 'admin'));
DROP POLICY IF EXISTS api_keys_delete ON api_keys;
CREATE POLICY api_keys_delete ON api_keys FOR DELETE
USING (is_account_member(account_id, 'admin'));