-- ============================================================= -- ARCHIVO: 07-users-rbac.sql -- DESCRIPCION: Sistema RBAC (Roles, Permisos, Invitaciones) -- VERSION: 1.0.0 -- PROYECTO: ERP-Core V2 -- FECHA: 2026-01-10 -- EPIC: SAAS-CORE-AUTH (EPIC-SAAS-001) -- HISTORIAS: US-004, US-005, US-006, US-030 -- ============================================================= -- ===================== -- SCHEMA: users -- ===================== CREATE SCHEMA IF NOT EXISTS users; -- ===================== -- TABLA: users.roles -- Roles del sistema con herencia (US-004) -- ===================== CREATE TABLE IF NOT EXISTS users.roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Info basica name VARCHAR(100) NOT NULL, display_name VARCHAR(255), description TEXT, color VARCHAR(20), icon VARCHAR(50), -- Jerarquia parent_role_id UUID REFERENCES users.roles(id) ON DELETE SET NULL, hierarchy_level INTEGER DEFAULT 0, -- Flags is_system BOOLEAN DEFAULT FALSE, -- No editable por usuarios is_default BOOLEAN DEFAULT FALSE, -- Asignado a nuevos usuarios is_superadmin BOOLEAN DEFAULT FALSE, -- Acceso total -- Metadata metadata JSONB DEFAULT '{}', -- Timestamps created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_by UUID REFERENCES auth.users(id), deleted_at TIMESTAMPTZ, -- Constraint: nombre unico por tenant (o global si tenant_id es NULL) UNIQUE NULLS NOT DISTINCT (tenant_id, name) ); -- Indices para roles CREATE INDEX IF NOT EXISTS idx_roles_tenant ON users.roles(tenant_id); CREATE INDEX IF NOT EXISTS idx_roles_parent ON users.roles(parent_role_id); CREATE INDEX IF NOT EXISTS idx_roles_system ON users.roles(is_system) WHERE is_system = TRUE; CREATE INDEX IF NOT EXISTS idx_roles_default ON users.roles(tenant_id, is_default) WHERE is_default = TRUE; -- ===================== -- TABLA: users.permissions -- Permisos granulares del sistema (US-004) -- ===================== CREATE TABLE IF NOT EXISTS users.permissions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificacion resource VARCHAR(100) NOT NULL, -- users, tenants, branches, invoices, etc. action VARCHAR(50) NOT NULL, -- create, read, update, delete, export, etc. scope VARCHAR(50) DEFAULT 'own', -- own, tenant, global -- Info display_name VARCHAR(255), description TEXT, category VARCHAR(100), -- auth, billing, inventory, sales, etc. -- Flags is_dangerous BOOLEAN DEFAULT FALSE, -- Requiere confirmacion adicional -- Timestamps created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE(resource, action, scope) ); -- Indices para permissions CREATE INDEX IF NOT EXISTS idx_permissions_resource ON users.permissions(resource); CREATE INDEX IF NOT EXISTS idx_permissions_category ON users.permissions(category); -- ===================== -- TABLA: users.role_permissions -- Asignacion de permisos a roles (US-004) -- ===================== CREATE TABLE IF NOT EXISTS users.role_permissions ( role_id UUID NOT NULL REFERENCES users.roles(id) ON DELETE CASCADE, permission_id UUID NOT NULL REFERENCES users.permissions(id) ON DELETE CASCADE, -- Condiciones opcionales conditions JSONB DEFAULT '{}', -- Condiciones adicionales (ej: solo ciertos estados) -- Metadata granted_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, granted_by UUID REFERENCES auth.users(id), PRIMARY KEY (role_id, permission_id) ); -- Indices para role_permissions CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON users.role_permissions(role_id); CREATE INDEX IF NOT EXISTS idx_role_permissions_permission ON users.role_permissions(permission_id); -- ===================== -- TABLA: users.user_roles -- Asignacion de roles a usuarios (US-004) -- ===================== CREATE TABLE IF NOT EXISTS users.user_roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, role_id UUID NOT NULL REFERENCES users.roles(id) ON DELETE CASCADE, -- Contexto tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, branch_id UUID REFERENCES core.branches(id) ON DELETE CASCADE, -- Opcional: rol por sucursal -- Flags is_primary BOOLEAN DEFAULT FALSE, -- Vigencia valid_from TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, valid_until TIMESTAMPTZ, -- Metadata assigned_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, assigned_by UUID REFERENCES auth.users(id), revoked_at TIMESTAMPTZ, revoked_by UUID REFERENCES auth.users(id), -- Constraint: un usuario solo puede tener un rol una vez por branch (o global si branch es NULL) UNIQUE (user_id, role_id, branch_id) ); -- Indices para user_roles CREATE INDEX IF NOT EXISTS idx_user_roles_user ON users.user_roles(user_id); CREATE INDEX IF NOT EXISTS idx_user_roles_role ON users.user_roles(role_id); CREATE INDEX IF NOT EXISTS idx_user_roles_tenant ON users.user_roles(tenant_id); CREATE INDEX IF NOT EXISTS idx_user_roles_branch ON users.user_roles(branch_id) WHERE branch_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_user_roles_active ON users.user_roles(user_id, revoked_at) WHERE revoked_at IS NULL; -- ===================== -- TABLA: users.invitations -- Invitaciones de usuario (US-006) -- ===================== CREATE TABLE IF NOT EXISTS users.invitations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Destinatario email VARCHAR(255) NOT NULL, first_name VARCHAR(100), last_name VARCHAR(100), -- Token de invitacion token_hash VARCHAR(255) NOT NULL UNIQUE, token_expires_at TIMESTAMPTZ NOT NULL, -- Rol a asignar role_id UUID REFERENCES users.roles(id) ON DELETE SET NULL, branch_id UUID REFERENCES core.branches(id) ON DELETE SET NULL, -- Estado status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'expired', 'revoked')), -- Mensaje personalizado message TEXT, -- Metadata metadata JSONB DEFAULT '{}', -- Timestamps invited_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, invited_by UUID NOT NULL REFERENCES auth.users(id), accepted_at TIMESTAMPTZ, accepted_user_id UUID REFERENCES auth.users(id), resent_at TIMESTAMPTZ, resent_count INTEGER DEFAULT 0, -- Constraint: email unico por tenant mientras este pendiente CONSTRAINT unique_pending_invitation UNIQUE (tenant_id, email, status) ); -- Indices para invitations CREATE INDEX IF NOT EXISTS idx_invitations_tenant ON users.invitations(tenant_id); CREATE INDEX IF NOT EXISTS idx_invitations_email ON users.invitations(email); CREATE INDEX IF NOT EXISTS idx_invitations_token ON users.invitations(token_hash); CREATE INDEX IF NOT EXISTS idx_invitations_status ON users.invitations(status) WHERE status = 'pending'; CREATE INDEX IF NOT EXISTS idx_invitations_expires ON users.invitations(token_expires_at) WHERE status = 'pending'; -- ===================== -- TABLA: users.tenant_settings -- Configuraciones por tenant (US-005) -- ===================== CREATE TABLE IF NOT EXISTS users.tenant_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE UNIQUE, -- Limites max_users INTEGER DEFAULT 10, max_branches INTEGER DEFAULT 5, max_storage_mb INTEGER DEFAULT 1024, -- Features habilitadas features_enabled TEXT[] DEFAULT '{}', -- Configuracion de branding branding JSONB DEFAULT '{ "logo_url": null, "primary_color": "#2563eb", "secondary_color": "#64748b" }', -- Configuracion regional locale VARCHAR(10) DEFAULT 'es-MX', timezone VARCHAR(50) DEFAULT 'America/Mexico_City', currency VARCHAR(3) DEFAULT 'MXN', date_format VARCHAR(20) DEFAULT 'DD/MM/YYYY', -- Configuracion de seguridad security_settings JSONB DEFAULT '{ "require_mfa": false, "session_timeout_minutes": 480, "password_min_length": 8, "password_require_special": true }', -- Configuracion de notificaciones notification_settings JSONB DEFAULT '{ "email_enabled": true, "push_enabled": true, "sms_enabled": false }', -- Metadata metadata JSONB DEFAULT '{}', -- Timestamps created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- ===================== -- TABLA: users.profile_role_mapping -- Mapeo de perfiles ERP a roles RBAC (US-030) -- ===================== CREATE TABLE IF NOT EXISTS users.profile_role_mapping ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), profile_code VARCHAR(10) NOT NULL, -- ADM, CNT, VNT, ALM, etc. role_id UUID NOT NULL REFERENCES users.roles(id) ON DELETE CASCADE, -- Flags is_default BOOLEAN DEFAULT TRUE, -- Timestamps created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE(profile_code, role_id) ); -- Indice para profile_role_mapping CREATE INDEX IF NOT EXISTS idx_profile_role_mapping_profile ON users.profile_role_mapping(profile_code); -- ===================== -- RLS POLICIES -- ===================== ALTER TABLE users.roles ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_roles ON users.roles USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL); ALTER TABLE users.role_permissions ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_role_permissions ON users.role_permissions USING (role_id IN ( SELECT id FROM users.roles WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL )); ALTER TABLE users.user_roles ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_user_roles ON users.user_roles USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL); ALTER TABLE users.invitations ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_invitations ON users.invitations USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ALTER TABLE users.tenant_settings ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_settings ON users.tenant_settings USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- ===================== -- FUNCIONES -- ===================== -- Funcion para verificar si un usuario tiene un permiso especifico CREATE OR REPLACE FUNCTION users.has_permission( p_user_id UUID, p_resource VARCHAR(100), p_action VARCHAR(50), p_scope VARCHAR(50) DEFAULT 'own' ) RETURNS BOOLEAN AS $$ DECLARE has_perm BOOLEAN; BEGIN -- Verificar si es superadmin IF EXISTS ( SELECT 1 FROM auth.users WHERE id = p_user_id AND is_superadmin = TRUE ) THEN RETURN TRUE; END IF; -- Verificar permisos via roles SELECT EXISTS ( SELECT 1 FROM users.user_roles ur JOIN users.role_permissions rp ON rp.role_id = ur.role_id JOIN users.permissions p ON p.id = rp.permission_id WHERE ur.user_id = p_user_id AND ur.revoked_at IS NULL AND (ur.valid_until IS NULL OR ur.valid_until > CURRENT_TIMESTAMP) AND p.resource = p_resource AND p.action = p_action AND (p.scope = p_scope OR p.scope = 'global') ) INTO has_perm; RETURN has_perm; END; $$ LANGUAGE plpgsql STABLE; -- Funcion para obtener todos los permisos de un usuario CREATE OR REPLACE FUNCTION users.get_user_permissions(p_user_id UUID) RETURNS TABLE ( resource VARCHAR(100), action VARCHAR(50), scope VARCHAR(50), conditions JSONB ) AS $$ BEGIN -- Si es superadmin, devolver wildcard IF EXISTS ( SELECT 1 FROM auth.users WHERE id = p_user_id AND is_superadmin = TRUE ) THEN RETURN QUERY SELECT '*'::VARCHAR(100), '*'::VARCHAR(50), 'global'::VARCHAR(50), '{}'::JSONB; RETURN; END IF; RETURN QUERY SELECT DISTINCT p.resource, p.action, p.scope, rp.conditions FROM users.user_roles ur JOIN users.role_permissions rp ON rp.role_id = ur.role_id JOIN users.permissions p ON p.id = rp.permission_id WHERE ur.user_id = p_user_id AND ur.revoked_at IS NULL AND (ur.valid_until IS NULL OR ur.valid_until > CURRENT_TIMESTAMP); END; $$ LANGUAGE plpgsql STABLE; -- Funcion para obtener permisos heredados de un rol CREATE OR REPLACE FUNCTION users.get_role_permissions_with_inheritance(p_role_id UUID) RETURNS TABLE ( permission_id UUID, resource VARCHAR(100), action VARCHAR(50), scope VARCHAR(50), inherited_from UUID ) AS $$ WITH RECURSIVE role_hierarchy AS ( -- Rol base SELECT id, parent_role_id, 0 as level FROM users.roles WHERE id = p_role_id UNION ALL -- Roles padre (herencia) SELECT r.id, r.parent_role_id, rh.level + 1 FROM users.roles r JOIN role_hierarchy rh ON r.id = rh.parent_role_id WHERE rh.level < 10 -- Limite de profundidad ) SELECT p.id as permission_id, p.resource, p.action, p.scope, rh.id as inherited_from FROM role_hierarchy rh JOIN users.role_permissions rp ON rp.role_id = rh.id JOIN users.permissions p ON p.id = rp.permission_id; $$ LANGUAGE sql STABLE; -- Funcion para limpiar invitaciones expiradas CREATE OR REPLACE FUNCTION users.cleanup_expired_invitations() RETURNS INTEGER AS $$ DECLARE updated_count INTEGER; BEGIN UPDATE users.invitations SET status = 'expired' WHERE status = 'pending' AND token_expires_at < CURRENT_TIMESTAMP; GET DIAGNOSTICS updated_count = ROW_COUNT; RETURN updated_count; END; $$ LANGUAGE plpgsql; -- ===================== -- TRIGGERS -- ===================== -- Trigger para actualizar updated_at en roles CREATE OR REPLACE FUNCTION users.update_role_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_roles_updated_at BEFORE UPDATE ON users.roles FOR EACH ROW EXECUTE FUNCTION users.update_role_timestamp(); -- Trigger para actualizar updated_at en tenant_settings CREATE TRIGGER trg_tenant_settings_updated_at BEFORE UPDATE ON users.tenant_settings FOR EACH ROW EXECUTE FUNCTION users.update_role_timestamp(); -- ===================== -- SEED DATA: Permisos Base -- ===================== INSERT INTO users.permissions (resource, action, scope, display_name, description, category) VALUES -- Auth ('users', 'create', 'tenant', 'Crear usuarios', 'Crear nuevos usuarios en el tenant', 'auth'), ('users', 'read', 'tenant', 'Ver usuarios', 'Ver lista de usuarios del tenant', 'auth'), ('users', 'read', 'own', 'Ver perfil propio', 'Ver su propio perfil', 'auth'), ('users', 'update', 'tenant', 'Editar usuarios', 'Editar cualquier usuario del tenant', 'auth'), ('users', 'update', 'own', 'Editar perfil propio', 'Editar su propio perfil', 'auth'), ('users', 'delete', 'tenant', 'Eliminar usuarios', 'Eliminar usuarios del tenant', 'auth'), ('roles', 'create', 'tenant', 'Crear roles', 'Crear nuevos roles', 'auth'), ('roles', 'read', 'tenant', 'Ver roles', 'Ver roles del tenant', 'auth'), ('roles', 'update', 'tenant', 'Editar roles', 'Editar roles existentes', 'auth'), ('roles', 'delete', 'tenant', 'Eliminar roles', 'Eliminar roles', 'auth'), ('invitations', 'create', 'tenant', 'Invitar usuarios', 'Enviar invitaciones', 'auth'), ('invitations', 'read', 'tenant', 'Ver invitaciones', 'Ver invitaciones pendientes', 'auth'), ('invitations', 'delete', 'tenant', 'Cancelar invitaciones', 'Revocar invitaciones', 'auth'), -- Tenants ('tenants', 'read', 'own', 'Ver configuracion', 'Ver configuracion del tenant', 'tenants'), ('tenants', 'update', 'own', 'Editar configuracion', 'Editar configuracion del tenant', 'tenants'), ('tenant_settings', 'read', 'own', 'Ver ajustes', 'Ver ajustes del tenant', 'tenants'), ('tenant_settings', 'update', 'own', 'Editar ajustes', 'Editar ajustes del tenant', 'tenants'), -- Branches ('branches', 'create', 'tenant', 'Crear sucursales', 'Crear nuevas sucursales', 'branches'), ('branches', 'read', 'tenant', 'Ver sucursales', 'Ver todas las sucursales', 'branches'), ('branches', 'read', 'own', 'Ver sucursal asignada', 'Ver solo su sucursal', 'branches'), ('branches', 'update', 'tenant', 'Editar sucursales', 'Editar cualquier sucursal', 'branches'), ('branches', 'delete', 'tenant', 'Eliminar sucursales', 'Eliminar sucursales', 'branches'), -- Billing ('billing', 'read', 'tenant', 'Ver facturacion', 'Ver informacion de facturacion', 'billing'), ('billing', 'update', 'tenant', 'Gestionar facturacion', 'Cambiar plan, metodo de pago', 'billing'), ('invoices', 'read', 'tenant', 'Ver facturas', 'Ver historial de facturas', 'billing'), ('invoices', 'export', 'tenant', 'Exportar facturas', 'Descargar facturas', 'billing'), -- Audit ('audit_logs', 'read', 'tenant', 'Ver auditoria', 'Ver logs de auditoria', 'audit'), ('audit_logs', 'export', 'tenant', 'Exportar auditoria', 'Exportar logs', 'audit'), ('activity', 'read', 'own', 'Ver mi actividad', 'Ver actividad propia', 'audit'), -- Notifications ('notifications', 'read', 'own', 'Ver notificaciones', 'Ver notificaciones propias', 'notifications'), ('notifications', 'update', 'own', 'Gestionar notificaciones', 'Marcar como leidas', 'notifications'), ('notification_settings', 'read', 'own', 'Ver preferencias', 'Ver preferencias de notificacion', 'notifications'), ('notification_settings', 'update', 'own', 'Editar preferencias', 'Editar preferencias', 'notifications') ON CONFLICT DO NOTHING; -- ===================== -- SEED DATA: Roles Base del Sistema -- ===================== INSERT INTO users.roles (id, tenant_id, name, display_name, description, is_system, is_superadmin, hierarchy_level, icon, color) VALUES ('10000000-0000-0000-0000-000000000001', NULL, 'superadmin', 'Super Administrador', 'Acceso total a la plataforma', TRUE, TRUE, 0, 'shield-check', '#dc2626'), ('10000000-0000-0000-0000-000000000002', NULL, 'admin', 'Administrador', 'Administrador del tenant', TRUE, FALSE, 1, 'shield', '#ea580c'), ('10000000-0000-0000-0000-000000000003', NULL, 'manager', 'Gerente', 'Gerente con acceso a reportes', TRUE, FALSE, 2, 'briefcase', '#0891b2'), ('10000000-0000-0000-0000-000000000004', NULL, 'user', 'Usuario', 'Usuario estandar', TRUE, FALSE, 3, 'user', '#64748b'), ('10000000-0000-0000-0000-000000000005', NULL, 'viewer', 'Visor', 'Solo lectura', TRUE, FALSE, 4, 'eye', '#94a3b8') ON CONFLICT DO NOTHING; -- Asignar permisos al rol Admin INSERT INTO users.role_permissions (role_id, permission_id) SELECT '10000000-0000-0000-0000-000000000002', id FROM users.permissions WHERE resource NOT IN ('audit_logs') -- Admin no tiene acceso a audit ON CONFLICT DO NOTHING; -- Asignar permisos al rol Manager INSERT INTO users.role_permissions (role_id, permission_id) SELECT '10000000-0000-0000-0000-000000000003', id FROM users.permissions WHERE scope = 'own' OR (resource IN ('branches', 'users', 'invoices') AND action = 'read') ON CONFLICT DO NOTHING; -- Asignar permisos al rol User INSERT INTO users.role_permissions (role_id, permission_id) SELECT '10000000-0000-0000-0000-000000000004', id FROM users.permissions WHERE scope = 'own' ON CONFLICT DO NOTHING; -- Asignar permisos al rol Viewer INSERT INTO users.role_permissions (role_id, permission_id) SELECT '10000000-0000-0000-0000-000000000005', id FROM users.permissions WHERE action = 'read' AND scope = 'own' ON CONFLICT DO NOTHING; -- ===================== -- SEED DATA: Mapeo de Perfiles ERP a Roles (US-030) -- ===================== INSERT INTO users.profile_role_mapping (profile_code, role_id) VALUES ('ADM', '10000000-0000-0000-0000-000000000002'), -- Admin ('CNT', '10000000-0000-0000-0000-000000000003'), -- Manager ('VNT', '10000000-0000-0000-0000-000000000004'), -- User ('CMP', '10000000-0000-0000-0000-000000000004'), -- User ('ALM', '10000000-0000-0000-0000-000000000004'), -- User ('HRH', '10000000-0000-0000-0000-000000000003'), -- Manager ('PRD', '10000000-0000-0000-0000-000000000004'), -- User ('EMP', '10000000-0000-0000-0000-000000000005'), -- Viewer ('GER', '10000000-0000-0000-0000-000000000003'), -- Manager ('AUD', '10000000-0000-0000-0000-000000000005') -- Viewer (read-only) ON CONFLICT DO NOTHING; -- ===================== -- COMENTARIOS -- ===================== COMMENT ON TABLE users.roles IS 'Roles del sistema con soporte para herencia'; COMMENT ON TABLE users.permissions IS 'Permisos granulares (resource.action.scope)'; COMMENT ON TABLE users.role_permissions IS 'Asignacion de permisos a roles'; COMMENT ON TABLE users.user_roles IS 'Asignacion de roles a usuarios'; COMMENT ON TABLE users.invitations IS 'Invitaciones para nuevos usuarios'; COMMENT ON TABLE users.tenant_settings IS 'Configuraciones personalizadas por tenant'; COMMENT ON TABLE users.profile_role_mapping IS 'Mapeo de perfiles ERP a roles RBAC'; COMMENT ON FUNCTION users.has_permission IS 'Verifica si un usuario tiene un permiso especifico'; COMMENT ON FUNCTION users.get_user_permissions IS 'Obtiene todos los permisos de un usuario'; COMMENT ON FUNCTION users.get_role_permissions_with_inheritance IS 'Obtiene permisos de un rol incluyendo herencia';