mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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>
85 lines
4.1 KiB
SQL
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'));
|