-- ============================================================================ -- SCHEMA: admin -- TABLE: api_keys -- DESCRIPTION: API Keys para acceso programatico -- VERSION: 1.0.0 -- CREATED: 2026-01-16 -- SPRINT: Sprint 1 - DDL Implementation Roadmap Q1-2026 -- ============================================================================ -- Enum para tipo de API key DO $$ BEGIN CREATE TYPE admin.api_key_type AS ENUM ( 'public', -- Solo lectura publica 'private', -- Acceso completo a recursos propios 'admin', -- Acceso administrativo 'service', -- Servicio a servicio 'webhook', -- Solo para recibir webhooks 'readonly' -- Solo lectura ); EXCEPTION WHEN duplicate_object THEN null; END $$; -- Enum para estado de API key DO $$ BEGIN CREATE TYPE admin.api_key_status AS ENUM ( 'active', -- Activa y funcional 'suspended', -- Suspendida temporalmente 'revoked', -- Revocada permanentemente 'expired' -- Expirada ); EXCEPTION WHEN duplicate_object THEN null; END $$; -- Tabla de API Keys CREATE TABLE IF NOT EXISTS admin.api_keys ( -- Identificadores id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, -- Identificacion de la key name VARCHAR(100) NOT NULL, description TEXT, -- Key values (solo se almacena hash del secret) key_prefix VARCHAR(8) NOT NULL, -- Primeros 8 caracteres para identificacion key_hash VARCHAR(255) NOT NULL, -- Hash bcrypt del secret completo key_hint VARCHAR(4), -- Ultimos 4 caracteres para referencia -- Tipo y estado type admin.api_key_type NOT NULL DEFAULT 'private', status admin.api_key_status NOT NULL DEFAULT 'active', -- Permisos scopes JSONB NOT NULL DEFAULT '["read"]'::JSONB, -- Array de scopes permitidos permissions JSONB DEFAULT '{}'::JSONB, -- Permisos granulares -- Restricciones allowed_ips INET[], -- IPs permitidas (NULL = todas) allowed_origins TEXT[], -- Origenes CORS permitidos allowed_user_agents TEXT[], -- User agents permitidos -- Rate limiting rate_limit_per_minute INTEGER DEFAULT 60, rate_limit_per_hour INTEGER DEFAULT 1000, rate_limit_per_day INTEGER DEFAULT 10000, -- Uso last_used_at TIMESTAMPTZ, last_used_ip INET, last_used_user_agent TEXT, total_requests BIGINT NOT NULL DEFAULT 0, total_errors BIGINT NOT NULL DEFAULT 0, -- Tracking de uso diario requests_today INTEGER NOT NULL DEFAULT 0, requests_today_date DATE DEFAULT CURRENT_DATE, -- Expiracion expires_at TIMESTAMPTZ, -- Rotacion previous_key_hash VARCHAR(255), -- Hash anterior durante rotacion previous_key_valid_until TIMESTAMPTZ, -- Validez del key anterior rotated_at TIMESTAMPTZ, rotation_count INTEGER NOT NULL DEFAULT 0, -- Auditoria created_by UUID REFERENCES users.users(id), revoked_by UUID REFERENCES users.users(id), revoked_at TIMESTAMPTZ, revocation_reason TEXT, -- Metadata environment VARCHAR(20) DEFAULT 'production', -- 'development', 'staging', 'production' metadata JSONB DEFAULT '{}'::JSONB, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Constraints CONSTRAINT api_keys_unique_name_per_user UNIQUE (user_id, name), CONSTRAINT api_keys_unique_prefix UNIQUE (key_prefix) ); COMMENT ON TABLE admin.api_keys IS 'API Keys para acceso programatico a la plataforma'; COMMENT ON COLUMN admin.api_keys.key_prefix IS 'Primeros 8 caracteres de la key para identificacion sin exponer el secret'; COMMENT ON COLUMN admin.api_keys.key_hash IS 'Hash bcrypt del secret completo (el secret solo se muestra una vez al crear)'; COMMENT ON COLUMN admin.api_keys.scopes IS 'Array de scopes: ["read", "write", "trade", "admin"]'; -- Indices CREATE INDEX IF NOT EXISTS idx_api_keys_tenant ON admin.api_keys(tenant_id); CREATE INDEX IF NOT EXISTS idx_api_keys_user ON admin.api_keys(user_id); CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON admin.api_keys(key_prefix); CREATE INDEX IF NOT EXISTS idx_api_keys_status_active ON admin.api_keys(status) WHERE status = 'active'; CREATE INDEX IF NOT EXISTS idx_api_keys_type ON admin.api_keys(type); CREATE INDEX IF NOT EXISTS idx_api_keys_expires ON admin.api_keys(expires_at) WHERE expires_at IS NOT NULL AND status = 'active'; CREATE INDEX IF NOT EXISTS idx_api_keys_last_used ON admin.api_keys(last_used_at DESC) WHERE status = 'active'; -- Trigger para updated_at DROP TRIGGER IF EXISTS api_key_updated_at ON admin.api_keys; CREATE TRIGGER api_key_updated_at BEFORE UPDATE ON admin.api_keys FOR EACH ROW EXECUTE FUNCTION admin.update_admin_timestamp(); -- Trigger para resetear contador diario CREATE OR REPLACE FUNCTION admin.reset_daily_requests() RETURNS TRIGGER AS $$ BEGIN IF NEW.requests_today_date < CURRENT_DATE THEN NEW.requests_today := 0; NEW.requests_today_date := CURRENT_DATE; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS api_key_reset_daily ON admin.api_keys; CREATE TRIGGER api_key_reset_daily BEFORE UPDATE ON admin.api_keys FOR EACH ROW EXECUTE FUNCTION admin.reset_daily_requests(); -- Funcion para validar API key CREATE OR REPLACE FUNCTION admin.validate_api_key( p_key_prefix VARCHAR(8), p_client_ip INET DEFAULT NULL ) RETURNS TABLE ( is_valid BOOLEAN, key_id UUID, user_id UUID, tenant_id UUID, key_type admin.api_key_type, scopes JSONB, rate_limit_remaining INTEGER, error_message TEXT ) AS $$ DECLARE v_key RECORD; v_rate_remaining INTEGER; BEGIN -- Buscar key por prefijo SELECT * INTO v_key FROM admin.api_keys ak WHERE ak.key_prefix = p_key_prefix AND ak.status = 'active' LIMIT 1; IF v_key IS NULL THEN RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, NULL::UUID, NULL::admin.api_key_type, NULL::JSONB, NULL::INTEGER, 'API key not found or inactive'::TEXT; RETURN; END IF; -- Verificar expiracion IF v_key.expires_at IS NOT NULL AND v_key.expires_at < NOW() THEN -- Marcar como expirada UPDATE admin.api_keys SET status = 'expired' WHERE id = v_key.id; RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, NULL::UUID, NULL::admin.api_key_type, NULL::JSONB, NULL::INTEGER, 'API key has expired'::TEXT; RETURN; END IF; -- Verificar IP si hay restricciones IF v_key.allowed_ips IS NOT NULL AND array_length(v_key.allowed_ips, 1) > 0 THEN IF p_client_ip IS NULL OR NOT (p_client_ip = ANY(v_key.allowed_ips)) THEN RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, NULL::UUID, NULL::admin.api_key_type, NULL::JSONB, NULL::INTEGER, 'IP address not allowed'::TEXT; RETURN; END IF; END IF; -- Verificar rate limit diario v_rate_remaining := v_key.rate_limit_per_day - v_key.requests_today; IF v_rate_remaining <= 0 THEN RETURN QUERY SELECT FALSE, v_key.id, v_key.user_id, v_key.tenant_id, v_key.type, v_key.scopes, 0, 'Daily rate limit exceeded'::TEXT; RETURN; END IF; -- Key valida - actualizar uso UPDATE admin.api_keys SET last_used_at = NOW(), last_used_ip = p_client_ip, total_requests = total_requests + 1, requests_today = requests_today + 1 WHERE id = v_key.id; RETURN QUERY SELECT TRUE, v_key.id, v_key.user_id, v_key.tenant_id, v_key.type, v_key.scopes, v_rate_remaining - 1, NULL::TEXT; END; $$ LANGUAGE plpgsql; -- Funcion para revocar API key CREATE OR REPLACE FUNCTION admin.revoke_api_key( p_key_id UUID, p_revoked_by UUID, p_reason TEXT DEFAULT NULL ) RETURNS BOOLEAN AS $$ BEGIN UPDATE admin.api_keys SET status = 'revoked', revoked_by = p_revoked_by, revoked_at = NOW(), revocation_reason = p_reason WHERE id = p_key_id AND status = 'active'; RETURN FOUND; END; $$ LANGUAGE plpgsql; -- Funcion para rotar API key (genera nuevo secret, mantiene el anterior temporalmente) CREATE OR REPLACE FUNCTION admin.rotate_api_key( p_key_id UUID, p_new_key_hash VARCHAR(255), p_grace_period_hours INTEGER DEFAULT 24 ) RETURNS BOOLEAN AS $$ BEGIN UPDATE admin.api_keys SET previous_key_hash = key_hash, previous_key_valid_until = NOW() + (p_grace_period_hours || ' hours')::INTERVAL, key_hash = p_new_key_hash, rotated_at = NOW(), rotation_count = rotation_count + 1 WHERE id = p_key_id AND status = 'active'; RETURN FOUND; END; $$ LANGUAGE plpgsql; -- Vista de API keys activas CREATE OR REPLACE VIEW admin.v_active_api_keys AS SELECT ak.id, ak.tenant_id, ak.user_id, u.email AS user_email, ak.name, ak.key_prefix, ak.key_hint, ak.type, ak.scopes, ak.rate_limit_per_day, ak.requests_today, ak.total_requests, ak.last_used_at, ak.expires_at, ak.created_at FROM admin.api_keys ak JOIN users.users u ON ak.user_id = u.id WHERE ak.status = 'active' ORDER BY ak.last_used_at DESC NULLS LAST; -- Vista de uso de API keys por dia CREATE OR REPLACE VIEW admin.v_api_key_usage AS SELECT ak.id, ak.name, ak.key_prefix, ak.type, ak.total_requests, ak.total_errors, ak.requests_today, CASE WHEN ak.rate_limit_per_day > 0 THEN (ak.requests_today::DECIMAL / ak.rate_limit_per_day * 100)::DECIMAL(5,2) ELSE 0 END AS usage_percent, ak.last_used_at, ak.last_used_ip FROM admin.api_keys ak WHERE ak.status = 'active' ORDER BY ak.total_requests DESC; -- RLS Policy para multi-tenancy ALTER TABLE admin.api_keys ENABLE ROW LEVEL SECURITY; CREATE POLICY api_keys_tenant_isolation ON admin.api_keys FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -- Los usuarios solo pueden ver sus propias API keys CREATE POLICY api_keys_user_isolation ON admin.api_keys FOR SELECT USING (user_id = current_setting('app.current_user_id', true)::UUID); -- Grants GRANT SELECT, INSERT, UPDATE ON admin.api_keys TO trading_app; GRANT SELECT ON admin.api_keys TO trading_readonly; GRANT SELECT ON admin.v_active_api_keys TO trading_app; GRANT SELECT ON admin.v_api_key_usage TO trading_app; GRANT EXECUTE ON FUNCTION admin.validate_api_key TO trading_app; GRANT EXECUTE ON FUNCTION admin.revoke_api_key TO trading_app; GRANT EXECUTE ON FUNCTION admin.rotate_api_key TO trading_app;