erp-core-database-v2/ddl/06-auth-extended.sql
rckrdmrd 5043a640e4 refactor: Restructure DDL with numbered schema files
- Replace old DDL structure with new numbered files (01-24)
- Update migrations and seeds for new schema
- Clean up deprecated files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 00:40:32 -06:00

338 lines
11 KiB
PL/PgSQL

-- =============================================================
-- ARCHIVO: 06-auth-extended.sql
-- DESCRIPCION: Extensiones de autenticacion SaaS (JWT, OAuth, MFA)
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- EPIC: SAAS-CORE-AUTH (EPIC-SAAS-001)
-- HISTORIAS: US-001, US-002, US-003
-- =============================================================
-- =====================
-- MODIFICACIONES A TABLAS EXISTENTES
-- =====================
-- Agregar columnas OAuth a auth.users (US-002)
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS oauth_provider VARCHAR(50);
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS oauth_provider_id VARCHAR(255);
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
-- Agregar columnas MFA a auth.users (US-003)
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS mfa_secret_encrypted TEXT;
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS mfa_backup_codes TEXT[];
-- Agregar columna superadmin (EPIC-SAAS-006)
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS is_superadmin BOOLEAN DEFAULT FALSE;
-- Indices para nuevas columnas
CREATE INDEX IF NOT EXISTS idx_users_oauth_provider ON auth.users(oauth_provider, oauth_provider_id) WHERE oauth_provider IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_mfa_enabled ON auth.users(mfa_enabled) WHERE mfa_enabled = TRUE;
-- =====================
-- TABLA: auth.sessions
-- Sesiones de usuario con refresh tokens (US-001)
-- =====================
CREATE TABLE IF NOT EXISTS auth.sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Token info
refresh_token_hash VARCHAR(255) NOT NULL,
jti VARCHAR(255) UNIQUE NOT NULL, -- JWT ID para blacklist
-- Device info
device_info JSONB DEFAULT '{}',
device_fingerprint VARCHAR(255),
user_agent TEXT,
ip_address INET,
-- Geo info
country_code VARCHAR(2),
city VARCHAR(100),
-- Timestamps
last_activity_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
revoked_reason VARCHAR(100),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para sessions
CREATE INDEX IF NOT EXISTS idx_sessions_user ON auth.sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_tenant ON auth.sessions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_sessions_jti ON auth.sessions(jti);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON auth.sessions(expires_at) WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_active ON auth.sessions(user_id, revoked_at) WHERE revoked_at IS NULL;
-- =====================
-- TABLA: auth.token_blacklist
-- Tokens revocados/invalidados (US-001)
-- =====================
CREATE TABLE IF NOT EXISTS auth.token_blacklist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
jti VARCHAR(255) UNIQUE NOT NULL,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
token_type VARCHAR(20) NOT NULL CHECK (token_type IN ('access', 'refresh')),
-- Metadata
reason VARCHAR(100),
revoked_by UUID REFERENCES auth.users(id),
-- Timestamps
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indice para limpieza de tokens expirados
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON auth.token_blacklist(expires_at);
CREATE INDEX IF NOT EXISTS idx_token_blacklist_jti ON auth.token_blacklist(jti);
-- =====================
-- TABLA: auth.oauth_providers
-- Proveedores OAuth vinculados a usuarios (US-002)
-- =====================
CREATE TABLE IF NOT EXISTS auth.oauth_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Provider info
provider VARCHAR(50) NOT NULL, -- google, github, microsoft, apple
provider_user_id VARCHAR(255) NOT NULL,
provider_email VARCHAR(255),
-- Tokens (encrypted)
access_token_encrypted TEXT,
refresh_token_encrypted TEXT,
token_expires_at TIMESTAMPTZ,
-- Profile data from provider
profile_data JSONB DEFAULT '{}',
-- Timestamps
linked_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMPTZ,
unlinked_at TIMESTAMPTZ,
UNIQUE(provider, provider_user_id)
);
-- Indices para oauth_providers
CREATE INDEX IF NOT EXISTS idx_oauth_providers_user ON auth.oauth_providers(user_id);
CREATE INDEX IF NOT EXISTS idx_oauth_providers_provider ON auth.oauth_providers(provider, provider_user_id);
CREATE INDEX IF NOT EXISTS idx_oauth_providers_email ON auth.oauth_providers(provider_email);
-- =====================
-- TABLA: auth.mfa_devices
-- Dispositivos MFA registrados (US-003)
-- =====================
CREATE TABLE IF NOT EXISTS auth.mfa_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Device info
device_type VARCHAR(50) NOT NULL, -- totp, sms, email, hardware_key
device_name VARCHAR(255),
-- TOTP specific
secret_encrypted TEXT,
-- Status
is_primary BOOLEAN DEFAULT FALSE,
is_verified BOOLEAN DEFAULT FALSE,
verified_at TIMESTAMPTZ,
-- Usage tracking
last_used_at TIMESTAMPTZ,
use_count INTEGER DEFAULT 0,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
disabled_at TIMESTAMPTZ
);
-- Indices para mfa_devices
CREATE INDEX IF NOT EXISTS idx_mfa_devices_user ON auth.mfa_devices(user_id);
CREATE INDEX IF NOT EXISTS idx_mfa_devices_primary ON auth.mfa_devices(user_id, is_primary) WHERE is_primary = TRUE;
-- =====================
-- TABLA: auth.mfa_backup_codes
-- Codigos de respaldo MFA (US-003)
-- =====================
CREATE TABLE IF NOT EXISTS auth.mfa_backup_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Code (hashed)
code_hash VARCHAR(255) NOT NULL,
-- Status
used_at TIMESTAMPTZ,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ
);
-- Indices para mfa_backup_codes
CREATE INDEX IF NOT EXISTS idx_mfa_backup_codes_user ON auth.mfa_backup_codes(user_id);
CREATE INDEX IF NOT EXISTS idx_mfa_backup_codes_unused ON auth.mfa_backup_codes(user_id, used_at) WHERE used_at IS NULL;
-- =====================
-- TABLA: auth.login_attempts
-- Intentos de login para rate limiting y seguridad
-- =====================
CREATE TABLE IF NOT EXISTS auth.login_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificacion
email VARCHAR(255),
ip_address INET NOT NULL,
user_agent TEXT,
-- Resultado
success BOOLEAN NOT NULL,
failure_reason VARCHAR(100),
-- MFA
mfa_required BOOLEAN DEFAULT FALSE,
mfa_passed BOOLEAN,
-- Metadata
tenant_id UUID REFERENCES auth.tenants(id),
user_id UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para login_attempts
CREATE INDEX IF NOT EXISTS idx_login_attempts_email ON auth.login_attempts(email, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON auth.login_attempts(ip_address, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_login_attempts_cleanup ON auth.login_attempts(created_at);
-- =====================
-- RLS POLICIES
-- =====================
ALTER TABLE auth.sessions ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_sessions ON auth.sessions
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE auth.oauth_providers ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_oauth ON auth.oauth_providers
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE auth.mfa_devices ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_isolation_mfa_devices ON auth.mfa_devices
USING (user_id = current_setting('app.current_user_id', true)::uuid);
ALTER TABLE auth.mfa_backup_codes ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_isolation_mfa_codes ON auth.mfa_backup_codes
USING (user_id = current_setting('app.current_user_id', true)::uuid);
-- =====================
-- FUNCIONES
-- =====================
-- Funcion para limpiar sesiones expiradas
CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM auth.sessions
WHERE expires_at < CURRENT_TIMESTAMP
OR revoked_at IS NOT NULL;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Funcion para limpiar tokens expirados del blacklist
CREATE OR REPLACE FUNCTION auth.cleanup_expired_tokens()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM auth.token_blacklist
WHERE expires_at < CURRENT_TIMESTAMP;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Funcion para limpiar intentos de login antiguos (mas de 30 dias)
CREATE OR REPLACE FUNCTION auth.cleanup_old_login_attempts()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM auth.login_attempts
WHERE created_at < CURRENT_TIMESTAMP - INTERVAL '30 days';
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Funcion para verificar rate limit de login
CREATE OR REPLACE FUNCTION auth.check_login_rate_limit(
p_email VARCHAR(255),
p_ip_address INET,
p_max_attempts INTEGER DEFAULT 5,
p_window_minutes INTEGER DEFAULT 15
)
RETURNS BOOLEAN AS $$
DECLARE
attempt_count INTEGER;
BEGIN
SELECT COUNT(*) INTO attempt_count
FROM auth.login_attempts
WHERE (email = p_email OR ip_address = p_ip_address)
AND success = FALSE
AND created_at > CURRENT_TIMESTAMP - (p_window_minutes || ' minutes')::INTERVAL;
RETURN attempt_count < p_max_attempts;
END;
$$ LANGUAGE plpgsql;
-- Funcion para revocar todas las sesiones de un usuario
CREATE OR REPLACE FUNCTION auth.revoke_all_user_sessions(
p_user_id UUID,
p_reason VARCHAR(100) DEFAULT 'manual_revocation'
)
RETURNS INTEGER AS $$
DECLARE
revoked_count INTEGER;
BEGIN
UPDATE auth.sessions
SET revoked_at = CURRENT_TIMESTAMP,
revoked_reason = p_reason
WHERE user_id = p_user_id
AND revoked_at IS NULL;
GET DIAGNOSTICS revoked_count = ROW_COUNT;
RETURN revoked_count;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE auth.sessions IS 'Sesiones de usuario con refresh tokens para JWT';
COMMENT ON TABLE auth.token_blacklist IS 'Tokens JWT revocados antes de su expiracion';
COMMENT ON TABLE auth.oauth_providers IS 'Proveedores OAuth vinculados a cuentas de usuario';
COMMENT ON TABLE auth.mfa_devices IS 'Dispositivos MFA registrados por usuario';
COMMENT ON TABLE auth.mfa_backup_codes IS 'Codigos de respaldo para MFA';
COMMENT ON TABLE auth.login_attempts IS 'Registro de intentos de login para seguridad';
COMMENT ON FUNCTION auth.cleanup_expired_sessions IS 'Limpia sesiones expiradas o revocadas';
COMMENT ON FUNCTION auth.cleanup_expired_tokens IS 'Limpia tokens del blacklist que ya expiraron';
COMMENT ON FUNCTION auth.check_login_rate_limit IS 'Verifica si un email/IP ha excedido intentos de login';
COMMENT ON FUNCTION auth.revoke_all_user_sessions IS 'Revoca todas las sesiones activas de un usuario';