Files
whatsapp/core/Rbac.php
2026-04-16 22:23:33 -05:00

199 lines
7.2 KiB
PHP

<?php
/**
* core/Rbac.php
* Control de acceso basado en roles (RBAC) con granularidad de acciones.
* Consolida la lógica de hasModule() / requireAdmin() dispersa en _helpers.php
* y config.php con soporte para los nuevos campos can_* (Migración 001).
*
* USO EN VISTAS:
* Rbac::requireLogin();
* Rbac::requireModule('lab_domicilios');
* if (Rbac::can('lab_reportes', 'export')) { ... }
*
* USO EN API (reemplaza funciones sueltas de _helpers.php):
* Rbac::requireAdmin();
* Rbac::requireModule('turnero', 'create');
*/
class Rbac
{
// ─── Consulta básica de módulo ───────────────────────────────────────────
/**
* Comprueba si el usuario en sesión tiene acceso (cualquier nivel) al módulo.
* Retrocompatible: admin sin role_id tiene acceso total.
*/
public static function hasModule(string $slug): bool
{
$modules = $_SESSION['admin_user']['modules'] ?? null;
if ($modules === null) {
// Sesión legacy: solo admins pasan
return ($_SESSION['admin_user']['role'] ?? '') === 'admin'
|| ($_SESSION['admin_user']['role'] ?? '') === 'superadmin';
}
return in_array($slug, $modules, true);
}
/**
* Comprueba si el usuario tiene una acción específica sobre el módulo.
*
* $action: 'view' | 'create' | 'edit' | 'delete' | 'export'
*
* Los roles admin y superadmin tienen acceso total a todo.
*/
public static function can(string $slug, string $action = 'view'): bool
{
$role = $_SESSION['admin_user']['role'] ?? 'admin';
// Superadmin y admin tienen acceso total
if (in_array($role, ['superadmin', 'admin'], true)) {
return true;
}
// Primero verificar que tiene el módulo
if (!self::hasModule($slug)) {
return false;
}
// Leer permisos granulares desde sesión (cargados en authenticateUser)
$perms = $_SESSION['admin_user']['module_permissions'] ?? [];
// Formato anterior compatible: 'write' / 'read'
if (isset($perms[$slug]) && is_string($perms[$slug])) {
$legacy = $perms[$slug];
if ($action === 'view') return true; // read implica view
return $legacy === 'write';
}
// Formato nuevo: array con can_* (post Migración 001)
if (isset($perms[$slug]) && is_array($perms[$slug])) {
return (bool) ($perms[$slug]["can_{$action}"] ?? false);
}
// Sin permisos granulares → view por defecto
return $action === 'view';
}
// ─── Guardas / Requisitos ────────────────────────────────────────────────
/**
* Detiene con 403 JSON si el usuario no está autenticado.
* Para uso en vistas, redirige al login.
*/
public static function requireLogin(string $redirect = null): void
{
if (!isset($_SESSION['admin_logged_in']) || $_SESSION['admin_logged_in'] !== true) {
if ($redirect !== null) {
header("Location: $redirect");
exit;
}
self::jsonForbidden('No autenticado', 401);
}
}
/**
* Detiene con 403 si el usuario no es admin ni superadmin.
*/
public static function requireAdmin(): void
{
$role = $_SESSION['admin_user']['role'] ?? '';
if (!in_array($role, ['admin', 'superadmin'], true)) {
self::jsonForbidden('Acceso restringido a administradores', 403);
}
}
/**
* Detiene con 403 si el usuario no tiene acceso al módulo.
* Opcionalmente exige una acción específica.
*/
public static function requireModule(string $slug, string $action = 'view'): void
{
if (!self::can($slug, $action)) {
self::jsonForbidden("Sin permiso en módulo '{$slug}' ({$action})", 403);
}
}
/**
* Alias semántico: detiene si no tiene permiso de escritura en el módulo.
*/
public static function requireWrite(string $slug): void
{
self::requireModule($slug, 'edit');
}
// ─── Helpers internos ───────────────────────────────────────────────────
private static function jsonForbidden(string $msg, int $code = 403): void
{
// Si ya enviamos headers JSON (contexto API), responder JSON
if (isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
// Contexto HTML: página de error simple
http_response_code($code);
echo "<h1>{$code}</h1><p>" . htmlspecialchars($msg) . "</p>";
exit;
}
// ─── Utilidades de sesión ────────────────────────────────────────────────
public static function currentRole(): string
{
return $_SESSION['admin_user']['role'] ?? 'admin';
}
public static function currentModules(): array
{
return $_SESSION['admin_user']['modules'] ?? [];
}
/**
* Carga en sesión los permisos granulares (can_*) desde la BD.
* Se llama desde authenticateUser() después de la Migración 001.
*/
public static function loadGranularPermissions(int $roleId): array
{
try {
$db = Database::getInstance();
$rows = $db->fetchAll(
"SELECT module_slug, can_view, can_create, can_edit, can_delete, can_export, extra_json
FROM role_modules
WHERE role_id = ?",
[$roleId]
);
$perms = [];
foreach ($rows as $row) {
$perms[$row['module_slug']] = [
'can_view' => (bool) $row['can_view'],
'can_create' => (bool) $row['can_create'],
'can_edit' => (bool) $row['can_edit'],
'can_delete' => (bool) $row['can_delete'],
'can_export' => (bool) $row['can_export'],
'extra' => $row['extra_json'] ? json_decode($row['extra_json'], true) : [],
];
}
return $perms;
} catch (Exception $e) {
error_log("[Rbac] Error cargando permisos: " . $e->getMessage());
return [];
}
}
}
// ─── Funciones globales de retrocompatibilidad ──────────────────────────────
// Las vistas y APIs legacy que llaman hasModule(), isEnfermero(), etc.
// siguen funcionando sin cambios apuntando a la clase Rbac.
if (!function_exists('hasModule')) {
function hasModule(string $slug): bool { return Rbac::hasModule($slug); }
}
if (!function_exists('canDo')) {
function canDo(string $slug, string $action): bool { return Rbac::can($slug, $action); }
}