# 06 — Seguridad, roles y permisos

## 6.0 Cómo funciona la autorización

### Capa 1 — `AccessControl` (por acción, en el controlador)

Cada controlador declara en `loadAccessControl()` un mapa `accion => modo`:

| Modo | Significado |
|---|---|
| `'*'` | Abierto: **cualquiera, sin sesión** |
| `'@'` | Requiere sesión iniciada **y** permiso en la tabla `permisos` |
| ausente | Denegado para todos |

`Controller::validateAccess()` devuelve `ACCESS` sólo si existe la entrada, se cumple el modo y
**existe el método `<accion>Action()`**. Cualquier otro resultado redirige a `DIR_INDEX` sin mensaje.

### Capa 2 — Permisos por rol (tabla `permisos`)

Para las acciones `@`, además de la sesión se exige `$this->Permission[$action] > 0`, donde
`Permission` viene de `PermisosModel::getPermissions($NamePermission)` y se carga en el login a
`$_SESSION[appId]['Permissions'][<ModuleName>]` desde el JSON de `permisos.Permission`.

Excepciones que otorgan acceso aunque no haya permiso explícito:
- El módulo está en `Config::$classesGeneral` (`Public`, `Home`, `Ajax`, `Log`, `Login`, `Perfil`, `CarritoFacturacion`, `CarritoDevolucion`, `CarritoCotizacion`).
- La acción está en `Config::$actionsGeneral` (`dataListAjax`, `testDB`).
- El usuario en sesión es el **superusuario**: `UsuariosModel::getUserId() == Controller::getUserAccess()`, que devuelve el **Id 1 hardcodeado**. El usuario 1 tiene todos los permisos siempre.

`permisos.Tabs` guarda un JSON análogo para las pestañas dentro de una vista
(`Controller::getCheckTabs()`).

### Capa 3 — Ocultamiento de UI

`ROUTER::create_action_url($c, $a)` devuelve `'#'` si `PermisosModel::hasAccess()` falla. Eso hace
que los enlaces sin permiso queden inertes y que `UsuariosModel::validarMenuItems()` **pode el menú**
en el login. El menú queda cacheado en sesión: **tras cambiar permisos hay que volver a iniciar sesión**.

> `hasAccess()` **instancia el controlador destino** para preguntarle. Un constructor con efectos
> secundarios se ejecutaría una vez por enlace del menú: mantén los constructores baratos.

## 6.1 Autenticación

### Backoffice (`usuarios`)

`UsuariosModel::validateUser($user, $pass)`:
1. Busca por `Usuario`; si no existe → `no_user`; si `Estado = 0` → `user_inactive`.
2. `MysqlPDO::validateUser()` compara `PassHelper::verify($pass, $usuario['Contrasena'])`.
3. Si falla y `Config::$directorioActivo` está activo, intenta LDAP (`DirectorioActivoModel`).
4. Al autenticar: guarda el usuario en sesión (sin `Contrasena`), resuelve su rol
   (`roles`), inicializa los filtros de sesión, carga menú y permisos, y registra el acceso.

### Portal público (`estudiantes` / `docentes` / `directores`)

`PublicModel::validateUser($user, $pass, $idRol)`:
1. `setRolesDisponibles($user)` busca el login en las cuatro tablas y guarda los roles encontrados.
2. `getUser($user, $codigoRol)` recupera el registro de la tabla que corresponde al rol
   (`director` → `directores` con `Tipo IN (1,4)`; `lider` → `Tipo IN (2,3)`; `coordinador` → `Tipo = 5`).
3. Autentica si **(a)** `Config::$LOGIN_MANUAL` y `docentes.LoginManual = 1` y la contraseña
   introducida es **su propio número de identificación**, **o (b)** la contraseña es la
   `userMasterKey` global, **o (c)** LDAP si está activo.
4. `validateChange` permite alternar de rol sin volver a autenticarse.

### SSO Microsoft (`public/validated`)

Flujo OAuth2 *authorization code* contra el tenant de la universidad → Microsoft Graph `/me` →
se toma `userPrincipalName` y se autentica **sin contraseña** con
`PublicModel::validateUserWithoutPass()` y, si no encuentra nada allí,
`UsuariosModel::validateUserWithoutPass()`. Es el camino por defecto cuando
`Config::$LOGIN_AUTOMATICO = true`.

## 6.2 Auditoría

| Qué | Dónde | Interruptor |
|---|---|---|
| Accesos, login/logout, IP y geolocalización (ipinfo.io) | `log_acceso` | `Config::$LOG_ACCESS` |
| URL/acción visitada y si pasó el control de acceso | `log_urls` | — |
| Diff de datos en create/edit/delete (`OldData`/`NewData` JSON) | `log_modules` | `Config::$LOG_MODULES` + `$LOG = true` en el modelo |
| Bitácora de desarrollo | `debug` | `DebugModel::registrar()` |

Solo algunos modelos activan `$LOG = true` (p. ej. `UsuariosModel`). Si necesitas trazabilidad de
una tabla, actívalo en su modelo.

## 6.3 Otras banderas de seguridad en `Config`

| Bandera | Efecto |
|---|---|
| `$Publicado` | Si es `false`, el portal muestra la pantalla de mantenimiento |
| `$LOGIN_AUTOMATICO` / `$LOGIN_MANUAL` | Qué formulario de acceso se ofrece |
| `$IP_BLOCKING` | Restringe el acceso a `ips_autorizadas` |
| `$NO_COPY`, `$DROP_FILES` | Restricciones de UI |
| `$debug` | Activa `ErrorHandler`, el log de SQL en `LogsConsole` y mensajes de validación en pantalla |
| `$noPayment` | Bloqueo comercial de la aplicación |
| `$userMasterKey` | **Contraseña maestra válida para cualquier usuario** |

---

# Riesgos conocidos

Esto es un inventario técnico, no un juicio: la aplicación funciona y está en producción. Pero
cualquiera que la modifique necesita conocer estos puntos, y varios merecen plan de remediación.

## 6.1 Inyección SQL

`MysqlPDO` **construye el SQL concatenando strings**; no usa parámetros vinculados.
`criteriaToSql()` interpola `name`, `operator`, `separatorValues` y `value` tal cual:

```php
$condition .= " " . $criteria["name"] . " " . $criteria["operator"] . " "
            . $criteria["separatorValues"] . $criteria["value"] . $criteria["separatorValues"];
```

`insert()`/`update()` sí aplican `addslashes()` a los valores, pero `queryInt`/`queryAll`
(las lecturas) **no aplican nada**. Hay puntos donde entrada de usuario llega directa al `criteria`:

- `AjaxController::getAllAction()` — `$_GET['q']`, `$_GET['group_by']`, `$_GET['facultad']`… se
  insertan en cláusulas `LIKE '%…%'` y `GROUP BY`.
- `PeriodosController::loadCriteria()` (y sus gemelos en otros controladores) acepta
  `$_REQUEST['criteriaExt']` **como criteria completo**, lo que permite construir la cláusula
  `WHERE` arbitraria desde el cliente.
- `ListaAjax` recibe filtros y ordenamiento desde DataTables.

**Recomendación**: migrar `MysqlPDO` a sentencias preparadas (los valores del `criteria` pueden
convertirse en placeholders sin cambiar el DSL), y eliminar el soporte de `criteriaExt`.

## 6.2 Hash de contraseñas MD5

```php
// core/helpers/PassHelper.php
public static function encode($pass) { return md5($pass); }
public static function verify($pass, $hash) { return md5($pass) == $hash; }
```

MD5 sin sal ni coste. Además `verify` usa `==` (comparación no estricta y no *constant-time*).
**Recomendación**: `password_hash()`/`password_verify()` con rehash progresivo en el login.

## 6.3 Contraseña maestra global

`Config::$userMasterKey = "123"` y `MysqlPDO::validateUser()` acepta
`PassHelper::verify($pass, Controller::getPassAccess())` **para cualquier usuario**, tanto en el
backoffice como en el portal público. Es una puerta trasera de soporte que en la práctica
equivale a "cualquiera que sepa la clave entra como quien quiera".

## 6.4 Login por número de identificación

En el portal, con `LOGIN_MANUAL` activo y `LoginManual = 1`, la contraseña válida es el
**número de documento** del usuario, que es un dato semipúblico dentro de la institución.

## 6.5 Secretos en código versionado

- `app/config/Config.php`: credenciales SMTP de ejemplo y **`client_id`/`client_secret` de la
  aplicación de Microsoft Entra ID** del tenant de producción.
- `app/controllers/SincronizacionController.php`: constante `OPENIA_API_KEY` con una **API key de
  OpenAI** (ya no se usa —quedó el código comentado— pero sigue en el repo y en el historial).
- `app/models/SincronizacionModel.php`: cabecera `WWW-Authenticate: Basic …` con las credenciales
  del integrador Academusoft en base64.
- `app/config/ConfigEnv.php` (no versionado) contiene además el token de Ollama.

**Recomendación**: rotar todas esas credenciales (asumirlas comprometidas por estar en el
historial de git), moverlas a `ConfigEnv.php`/variables de entorno, y purgarlas del historial si
el repositorio se comparte fuera del equipo.

## 6.6 Endpoints públicos que ejecutan trabajo

| Endpoint | Riesgo |
|---|---|
| `?c=alertasEmail&a=sendCron` (`*`) | Cualquiera puede disparar el envío de correos encolados. Debería exigir un token compartido o restringirse por IP/CLI |
| `?c=consolidados&a=graficaEstudiante` y las otras tres `grafica*` (`*`) | Regeneran la caché de gráficas sin autenticación; consumen CPU y BD |
| `?c=public&a=docentesDesempeno` (`*`) | Marcado como público aunque el resto del bloque docente es `@` — verificar si es intencional |

## 6.7 Otros puntos a vigilar

- **XSS**: las vistas imprimen valores con `echo` directo sin `htmlspecialchars()` en muchos
  sitios (`KHtml::encode()` existe pero se usa poco). Los `Observaciones` escritos por
  estudiantes se muestran en reportes y se envían al LLM sin sanear.
- **PDF arbitrario**: `archivos/exportPDF` (`@`) toma `$_POST['contenido']` y lo pasa a mPDF.
  Con un usuario autenticado permite renderizar HTML arbitrario del lado servidor.
- **`ArchivosController::$requisitos` / consolas de sincronización**: acciones `@` que ejecutan
  procesos masivos y destructivos (`ConsolidadosController` borra el consolidado del periodo,
  `actualizarEstudiantes` pone todos los estudiantes en `Estado = 0` antes de reactivarlos). Un
  fallo a mitad de proceso deja datos inconsistentes: **no hay transacciones en ninguna parte**.
- **`cambiarNombres`**: acción `@` que anonimiza tablas con Faker. Un clic accidental en producción
  destruye datos reales.
- **Sesiones**: se usan las cookies de sesión PHP por defecto; no se ven `session_regenerate_id()`
  tras el login (riesgo de *session fixation*) ni flags `Secure`/`HttpOnly`/`SameSite` explícitos.
- **`Controller::$valuesToUpper`**: si se activa, convierte a mayúsculas **todos** los valores
  recibidos por `receiveData()`. Está desactivado; activarlo tendría efectos amplios.
