-- Migration: 004_create_api_keys.sql -- Description: Create api_keys table for API authentication -- Created: 2026-01-24 -- -- Security Model: -- ================ -- API keys are NEVER stored in plain text. When a new API key is generated: -- 1. A random key is generated (e.g., "riq_abc123xyz...") -- 2. The full key is returned to the user ONCE and never stored -- 3. We store only the SHA-256 hash of the key (key_hash) -- 4. We store the first 8 characters (key_prefix) for identification in logs/UI -- -- Authentication Flow: -- 1. Client sends API key in Authorization header -- 2. Server hashes the received key with SHA-256 -- 3. Server looks up the hash in this table -- 4. If found and is_active=true and not expired, request is authenticated -- -- This approach ensures that even if the database is compromised, -- attackers cannot recover the actual API keys. -- ============================================================================ -- CREATE TABLE -- ============================================================================ CREATE TABLE api_keys ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Key identification (security: we store hash, not the actual key) key_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hash of API key (64 hex chars) key_prefix VARCHAR(8) NOT NULL, -- First 8 chars for identification in UI/logs name VARCHAR(255) NOT NULL, -- Human-readable name, e.g., "Veritas Production Key" -- Client association client_id VARCHAR(255) NOT NULL, -- External client identifier, e.g., "veritas_client_123" -- Permissions (PostgreSQL array of allowed scopes) scopes TEXT[] DEFAULT '{}', -- e.g., {"jobs:read", "jobs:write", "admin"} -- Rate limiting rate_limit_rpm INTEGER DEFAULT 60, -- Maximum requests per minute for this key -- Status is_active BOOLEAN DEFAULT true, -- Set to false to revoke without deleting -- Timestamps created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_used_at TIMESTAMP, -- Updated on each successful authentication expires_at TIMESTAMP, -- NULL means the key never expires -- Extensible metadata (for future use: IP allowlists, custom limits, etc.) metadata JSONB ); -- ============================================================================ -- INDEXES -- ============================================================================ -- Primary lookup index: This is the critical path for every API request. -- When authenticating, we hash the provided key and look it up here. -- UNIQUE constraint already creates an index, but we're explicit for clarity. CREATE INDEX idx_api_keys_key_hash ON api_keys (key_hash); -- Client lookup index: For admin operations like "list all keys for client X" -- or "revoke all keys for client X" CREATE INDEX idx_api_keys_client_id ON api_keys (client_id); -- Active keys index: Useful for filtering active/inactive keys in queries -- Partial index for efficiency (only indexes active keys) CREATE INDEX idx_api_keys_active ON api_keys (is_active) WHERE is_active = true; -- ============================================================================ -- COMMENTS -- ============================================================================ COMMENT ON TABLE api_keys IS 'API keys for authenticating external clients. Keys are stored as SHA-256 hashes for security.'; COMMENT ON COLUMN api_keys.key_hash IS 'SHA-256 hash of the API key. The actual key is never stored.'; COMMENT ON COLUMN api_keys.key_prefix IS 'First 8 characters of the key for identification in UI and logs.'; COMMENT ON COLUMN api_keys.scopes IS 'Array of permission scopes: jobs:read, jobs:write, admin, etc.'; COMMENT ON COLUMN api_keys.rate_limit_rpm IS 'Rate limit in requests per minute. NULL uses system default.'; COMMENT ON COLUMN api_keys.metadata IS 'Extensible JSON metadata: IP allowlists, usage notes, etc.';