ADR-067: Credencial de asistente por persona, peldaño 4 y MCP compacto¶
Estado: Aceptado Fecha: 2026-11-05 Autores: Giampiero (mantenedor principal) Relacionados: ADR-019 (capa MCP, enmendado), ADR-053 (catálogo convergente, enmendado), ADR-036 (identidad firmada), ADR-037 (asistente por entidad), ADR-065 (nada se borra)
Contexto¶
Un cliente MCP externo (Hermes) usaba un puente propio que guardaba la contraseña de una cuenta .asistente, pedía el token por ROPC y lo refrescaba, cambiaba el token de la propuesta por una referencia, autocompletaba la frase de confirmación y subía los bytes. Cuatro defectos de fondo: la cuenta .asistente es otra persona (salta la separación de funciones de ADR-039/045, no ve la bandeja de quien la usa y atribuye los actos a un homónimo); el servidor no puede saber quién aprobó; el resultado de una herramienta no distingue «no ocurrió» de «ocurrió» (ejecutado: true con un 422); y el catálogo completo (170 mil caracteres de guía) no cabe en el contexto de un modelo.
Decisiones¶
D1. Credencial de asistente por persona, sobre la cuenta de la persona. Forma opc_{slug}_{id32}_{secreto64} (hex): el slug evita pedir X-Tenant-Slug. Se guarda solo la huella HMAC-SHA256(ASISTENTE_PEPPER, secreto) en asistente_credenciales (migración de tenant auth 023); nunca el secreto en claro ni en un registro. Tabla sin DELETE (ADR-065): una credencial revocada se marca (revocada_en, revocada_por, motivo_revocacion). Vigencia 90 días por defecto, tope 365 (CHECK en el motor), con aviso antes de vencer. Se descarta OAuth de MCP con Keycloak: validate_token exige azp de una lista cerrada, el login se pausa justo en el caso de uso (reautorizar sin terminal) y no hay pantalla de revocación; queda como incremento aditivo.
D2. Emisión solo desde sesión interactiva. POST/GET/DELETE /api/v1/auth/me/credenciales-asistente. Exige azp=orpycamcp-frontend (ni la propia credencial ni ROPC emiten), segundo factor si la persona tiene TOTP, y la bandera de entidad mcp_externo_habilitado (D5). El secreto se muestra una vez. La administración (listar y revocar las de otros) es USUA_PERM_ADMIN:3. Asientos asistente_credencial.emitida|revocada|rechazada. Se excluye del MCP (FRO).
D3. Techo. La credencial lleva techo_nivel (por defecto 1, la persona lo sube hasta el suyo diciendo que el modelo es local; un ROOT debe fijarlo siempre) y techo_crud. El techo se aplica en un chokepoint: orpycamcp_common.rbac lee un ContextVar que fija IdentidadFirmadaMiddleware después de verificar la firma; resolve_caller_clearance devuelve min(nivel, techo) y check_permission/check_any_permission/nivel_efectivo niegan si min_crud > techo_crud. Las lecturas de role_clearance que vivían fuera de rbac.py (archive: archive_repository, fisico_repository; workflow: visto_bueno_repository) pasan por el mismo chokepoint o por una función común que aplica el techo; un guardián prohíbe role_clearance fuera de rbac.py, auth-service y la lista declarada.
D4. Validación y alcance. auth-service ramifica por prefijo opc_ antes de PyJWT (forma anclada, slug_valido, estado de la entidad, comparación en tiempo constante, no vencida, no revocada, CIDR, is_active, bandera). validate_token sigue solo-JWT: todo lo que consume un factor (sign-digest, /internal/totp/verify) falla por sí solo con invalid_token. El gateway acepta la credencial solo en /api/v1/mcp/*, en llamadas con X-Origen-Canal firmado por el mcp-server y en POST /api/v1/storage/upload* si permite_subida; en cualquier otra ruta, 401. derive_canal da mcp. X-Credencial-Id y X-Techo entran en CAMPOS_FIRMADOS: corte atómico de los nueve servicios con IdentidadFirmadaMiddleware y del gateway en el mismo despliegue, como ADR-036. La revocación tarda lo que la caché de validación (AUTH_CACHE_TTL = 15 s).
D5. Bandera de entidad propia. public.tenants.mcp_externo_habilitado BOOLEAN NOT NULL DEFAULT false (global 009), distinta de asistente_habilitado (ADR-037, que gobierna el asistente de la app). Apagada, no se emite y toda credencial viva da 401.
D6. Peldaño 4: la última firma en sesión. Tool.requiere_sesion: el MCP prepara el acto (argumentos, tarjeta del catálogo) y write_gate.ejecutar responde 403 sesion_requerida si /auth/me no dice credencial: "interactiva". La propuesta lleva url_aprobacion (/aprobar#prop:<jti>: la referencia, no el token, en el fragmento) y la pantalla /aprobar exige la misma persona y entidad. El asiento lleva canal=mcp y confirmado_en=sesion. Aplica a los actos con acta y a VER (aprobar anulación, desclasificación, versión pública, firma simple, ejecutar y avalar lote, TRD/TVD, PINAR…). Fijar retención legal y reactivar (4 operaciones) quedan en peldaño 3 por fricción asimétrica. Los actos que ensanchan el propio alcance (grupos, permisos, clearance, membresía) no entran por ningún peldaño. Queda sin decidir (D2 del plan): las operaciones REG (reglas de la entidad; el censo cuenta 18 tras ADR-068, no 25) y el guardián test_el_flujo_de_administracion_no_ofrece_ninguna_escritura no se tocan; PUT /config/asistente sigue fuera siempre.
D7. Propuesta por referencia y un solo resultado. El token firmado se guarda en Redis (mcp:prop:{ten}:{sub}:{jti}, TTL 600 s; peldaño 4, 900 s) y viaja prop:<jti>; el consumo es SET NX (cierra el «por proceso, best-effort»). El resultado de toda herramienta es {efecto: si|no|parcial|desconocido, ejecutado, status, error, detail, campos, que_hacer} con isError honesto; desconocido (5xx, timeout tras consumir) manda a no reintentar sin consultar. Anotaciones: proponer es readOnlyHint=true; solo confirmar_accion es destructiveHint.
D8. Usabilidad por un modelo. instructions del servidor, recurso orfeo://indice (≤ 4.000 caracteres), guia_flujo (≤ 12.000 por página), explicar_limite, y un punto de entrada compacto POST /api/v1/mcp/compacto (buscar, describir, invocar) que pasa por el mismo authorize y el mismo gate; /stream sigue dando todo. Elicitación por sesión con estado en /api/v1/mcp/sesion (mcp-session-id, mcp-protocol-version, last-event-id en las listas del gateway).
D9. Canal en toda la cadena. Los clientes internos que propagan una identidad firmada propagan también X-Canal y X-Techo mediante un helper común; un guardián censa los identidad.firmar de clients/. Subida y actos encadenados (WORM, AIP) se asientan mcp.
D10. Se retiran las cuentas .asistente (en este repo, sus referencias y documentación; la inactivación en el despliegue la hace Infraestructura).
Enmienda: segundo factor obligatorio para emitir (2026-11-06)¶
Antes se exigía TOTP solo a quien lo tuviera activo, así que en una cuenta sin él bastaba el token de sesión para emitir una credencial de 90 días. Decisión del dueño: se exige siempre, 403 totp_requerido. Las credenciales ya emitidas siguen vigentes.
Ronda de corrección de auditoría (2026-11-06)¶
Hallazgos de la auditoría de seguridad sobre lo implementado, cerrados en una sola ronda. Lo que sigue en pie sin cambio por decisión del dueño: reactivar_expediente_inactivado y fijar retención legal en peldaño 3 (el auditor propuso 4; se le preguntará); las REG (18 según el censo tras ADR-068) y PUT /config/asistente siguen sin abrirse.
R1. El techo se mide por el VERBO del acto, no solo por min_crud. techo_crud=1 dice «solo consultar» pero 40 de 163 escrituras declaran min_crud<=1 (radicar_documento, trasladar_radicado, crear_serie_trd) y pasaban. Ahora, solo para una credencial con techo, el nivel exigido de una escritura es max(min_crud, 2 si PATCH/PUT, 3 si POST/DELETE) (catalog.nivel_para_el_techo). Se aplica en puede_usar/visible_tools (lo que se ofrece), en dispatcher.authorize (al proponer) y en write_gate.ejecutar (al ejecutar, reautoriza), también al ROOT; en un procedimiento, a cada paso. Una sesión de persona no cambia. La subida directa POST /storage/upload* NO pasa por el catálogo: la gobierna el interruptor propio permite_subida de la credencial (un techo 1 con permite_subida puede subir bytes, y solo eso: sin una escritura MCP el archivo no se vincula a nada).
R2. Baja de la persona. Ninguna API escribía auth_users.is_active, así que la guarda de la validación de la credencial era inerte. POST /api/v1/auth/usuarios/{id}/baja (USUA_PERM_ADMIN ≥ 3, motivo 10..500, no sobre uno mismo, una cuenta ROOT solo la baja un ROOT) pone is_active=false, revoca TODAS las credenciales de asistente de la persona y asienta, en una transacción y bajo el candado de la persona; POST …/alta (misma regla) es su reversa y NO revive credenciales. No borra nada y no toca Keycloak (la sesión interactiva se corta en el proveedor de identidad). Propuesta pendiente de aprobación del dueño: son dos rutas, un método del servicio y un método del repositorio, fáciles de retirar.
R3. Step-up antes de la transacción. El código TOTP de la emisión se valida y su fallo se registra (record_failure: contador y bloqueo) ANTES de abrir la transacción; dentro solo avanza el timestep. Antes el 401 revertía el conteo y la sesión podía probar códigos sin límite.
R4. reasignar_en_cascada rechaza con 422 destinatario_es_quien_llama (sin asiento) lo que el catálogo ya prometía. R5. editar_ficha_persona pierde responsable_gestion_documental (autoriza decidir anulaciones: es una designación de autoridad, no una ficha); el endpoint REST no cambia.
R6. Ningún acto de peldaño 4 se aprueba viendo un UUID. Los 13 que quedaban sin tarjeta leen el objeto con el token de la persona (fail-closed) y la proposal_card lleva enlace (ruta relativa de la app, Tool.enlace) para que /aprobar ofrezca ver el objeto. Guardián: todo requiere_sesion declara tarjeta. R7. aprobar_desclasificacion sale otra vez del catálogo (NO_OFRECIDAS, motivo VER): /aprobar muestra la tarjeta, no el documento. firmar_anexo_simple y aprobar_version_publica tienen el mismo defecto de fondo (su tarjeta enseña el número del radicado y un UUID, no el anexo ni la versión); se reportan sin cambiarlos.
R8. Traza. audit.append copia a payload.correlacion el credencial_id (si el acto lo originó una credencial, en cualquier canal) y confirmado_en (sesion si la última firma la dio la persona en /aprobar), por ContextVar junto al techo. X-Credencial-Id ya viajaba firmado; X-Confirmado-En es un campo firmado OPCIONAL (entra a la base de la firma solo si trae valor: lo demás firma igual que antes). Lo emite el gateway al verificar la marca X-Confirmacion-Origen del mcp-server (secreto de origen, ligada a tenant y token; nunca con una credencial). firmar_propagando lleva ambos al siguiente salto. batch_service deja de fijar canal="api" y la cascada de inactivación hereda el canal de la petición.
R9. Sesión MCP con estado con session_idle_timeout (1800 s) y atada a sha256(Authorization): otro bearer con ese mcp-session-id recibe 404. Consecuencia: un token interactivo que se renueva a mitad de sesión obliga a reiniciarla (la credencial opc_ no rota). R10. scope["path"] en vez de request.url.path en el gateway; contar_vivas bajo candado por persona.
R11-R13. Usabilidad. describir_herramienta lista obligatorios_condicionales (p. ej. doc_class y dest_dept_code entero en radicar_documento); confirmacion_requerida dice que la propuesta sigue viva y que la frase es de la persona; decidir_disposicion_vencida pasa a peldaño 3 (se teclea el código del expediente); decidir_eliminacion_radicado ya no promete que «se ejecuta por lote y lo saca de la vista» (solo registra la disposición; un radicado suelto no se inactiva, ADR-065 D3); retencion_no_vencida y lote_con_retencion_no_vencida entran a ERRORES_CONOCIDOS.
Enmienda 2026-11: el documento que se aprueba (D6, R6, R7)¶
R6 dejó de aprobar viendo un UUID; faltaba que en tres actos de «lo he leído y lo apruebo» (firmar_anexo_simple, dar_visto_bueno, aprobar_version_publica) la tarjeta seguía mostrando solo el número de radicado: alguien firmaba sin ver el documento, y un modelo podía prepararle el acto sobre el anexo equivocado. Decisión del dueño del producto: /aprobar muestra el documento.
- Se declara en el catálogo (
Tool.visor: tipo, argumento del documento y del anexo o versión), no lo decide el modelo. Guardiántest_visor_de_actos_de_lectura.py: la lista explícita de actos debe declararlo. - Atadura al proponer. El servidor lee, con el token de la persona (clearance y RBAC), qué archivo y qué huella SHA-256 (la que storage calculó al subir) tiene el documento (
GET /documents/{id}/visor/...), y los ata dentro del token firmado (ata). Un anexo sinanexo_idse resuelve al principal en el servidor y queda en los argumentos del token. Sin documento legible, o si no es PDF, no hay propuesta. - Verificación al ejecutar, antes de consumir: se vuelve a leer; si el archivo o la huella difieren,
409 archivo_cambiadoy no se ejecuta. Una propuesta con visor sin atadura se rechaza (409 atadura_ausente): el servidor no depende del botón. En la firma simple la huella viaja además al servicio (huella_esperada), que la compara con la que resuelve en el mismo acto y cierra la ventana entre lectura y escritura. En el visto bueno y la versión pública esa ventana queda abierta (milisegundos): la versión pública es inmutable porversion_id; el visto bueno no ancla huella. - Mismo gate, mismos bytes. La credencial del asistente nunca recibe el descriptor (
file_id, huella):consultarlo entrega solo a una sesión interactiva. Los bytes los pide el navegador con la sesión, por la descarga mediada de anexos de siempre; la versión pública sin aprobar tiene su propio proxy (/aprobar/api/visor/...->GET /documents/{id}/visor/version-publica/{vid}/contenido,PERM_RECLASIFICARnivel 4, clearance en elWHERE, asiento de lectura). No hay URL firmadas. - Falla cerrado. Sin permiso, no disponible, no PDF o archivo cambiado: la pantalla lo dice y deshabilita «Aprobar». El visor pinta el PDF en un
<canvas>conpdfjs-dist(versión exacta 6.4.299, publicada el 2026-10-03, compilaciónlegacy: la normal no pinta en navegadores singetOrInsertComputed; sin ejecutar el JavaScript del PDF; worker servido por la propia app; CSPworker-src 'self', sin marcosblob:), con paginación simple y un enlace «abrir aparte» por la descarga mediada. «Aprobar» se habilita solo cuando el primer render termina bien. Límite: las imágenes JPEG 2000 y las fuentes estándar sin incrustar pueden verse degradadas (no se sirven sus recursos). aprobar_version_publicamuestra también el ORIGINAL (decisión del dueño, 2026-11): el tachado y el original (anexo principal del radicado), uno tras otro, más qué se tachó y por qué. Se ata también la huella del original; si la persona no alcanza a verlo (permiso, clearance, sin principal, no PDF) no hay propuesta ni se aprueba; si cambia,409 archivo_cambiado.- El asistente web y el transporte MCP no completan un peldaño 4.
POST /assistant/confirmaryconfirmar_accionresponden403 sesion_requeridaconurl_aprobacionpara todo actorequiere_sesionaunque la sesión sea interactiva: solo/aprobarlo completa. Toda propuesta de peldaño 4 se guarda por referencia (prop:), yProposalCardofrece el enlace en vez de «Confirmar». La credencial del asistente nunca recibe el token completo. - Qué comprueba el SERVIDOR y qué el NAVEGADOR. Servidor: la atadura (
file_idy huella del documento y del original) sale de storage con el token de la persona, viaja firmada en el token, se vuelve a leer al ejecutar (archivo_cambiado), un acto con visor sin atadura se rechaza (atadura_ausente), la firma simple comparahuella_esperadaen el mismo acto, y el visto bueno asientadocumento_visto(file_id, huella) envobo.decidido. Navegador: que los bytes recibidos tengan la huella atada (crypto.subtle; sin él no se aprueba), que empiecen por%PDF-y que el primer render termine bien. El visor protege del descuido y del error del modelo; no protege de quien manipula su propia pantalla (el servidor no sabe qué se pintó). Ventana residual: en el visto bueno y la versión pública pasan milisegundos entre la relectura y la escritura. elaborar_version_publicacon el documento delante (decisión del dueño, 2026-11). Al elaborar todavía no hay versión, así que el visor es de tipotachado: se ata el PDF subido y, como en la aprobación, el original.GET /documents/{id}/visor/tachado/{file_id}y…/contenido(PERM_RECLASIFICARnivel 3, clearance en elWHERE) aplican el cotejo deelaborar: solo quien subió el archivo lo recibe, y ajeno o inexistente es el mismo404; así nadie lee un archivo de otra persona poniéndolo en una propuesta suya. Ya usado en otro documento,409. No deja asiento de lectura porque es un archivo propio y aún sin documento; el acto queda asentado endocument.version_publica_elaborada. Quien no alcance a ver el original (la lectura exigePERM_RADI,PERM_FIRMAoPERM_VOBO) no elabora por esta vía.aprobar_borradorqueda sin visor ni peldaño 4 (decisión del dueño, 2026-11). Es una marca reversible: el borrador aprobado se sigue editando, de modo que ver el texto al aprobar no garantiza lo que se radique. Exigir/aprobaren cada borrador costaría más de lo que protege. Si hace falta más control, va enradicar_borrador, el acto que consume el número.dar_vobo_cadena_firmacon el documento delante (2026-11, cerrado). El visor muestra lo que/firmasmuestra para ese turno segúnobjeto_tipo: un borrador, su TEXTO (texto plano en<pre>, nunca{@html}; la pantalla recalcula el SHA-256 del texto UTF-8); un radicado, su PDF principal con el visor PDF. La huella atada es la que la cadena YA ancló (firma_solicitud.payload_sha256), leída porGET /signature/requests/{id}/visor(parte de la cadena + no-read-up con el nivel vivo) y comparada con la vigente que resuelve el mismo resolutor del gatedocumento_modificado; el texto sale deGET /borradores/{id}/visor(PERM_RADI,PERM_FIRMAoPERM_VOBO, clearance en elWHERE). Si cambió,409 archivo_cambiadosin consumir; sin atadura (tipo,objeto_tipo, huella) se rechaza; cualquier otro objeto (expediente, índice…) no se muestra y no se prepara (falla cerrado). El modelo no recibe descriptor, texto ni huella; las dos rutas nuevas son[BIN]enEXCLUIDAS_A_PROPOSITOyNO_OFRECIDAS.
Enmienda a ADR-019¶
El MCP sigue siendo cliente del gateway y propaga el bearer que le llega; cambia solo cuál bearer (JWT o credencial). Redis efímero para propuestas (D7): ningún dato de dominio; los argumentos ya viajaban por el cliente. mcp-server exige REDIS_URL.
Enmienda a ADR-053¶
/stream no cambia. Se añade el peldaño 4 (requiere_sesion), compacto y sesion. §1 ya no cuenta «decidir eliminar» como irreversible (ADR-065: inactiva y se revierte). §8 (descartar filtrar por flujo por recorte silencioso) se mantiene: compacto no recorta, todo sigue alcanzable y el índice dice cuánto hay. Los códigos de exclusión no cambian; se abren las ya justificadas por el plan (19 sin motivo real y las ACT/VER de D6).
Consecuencias¶
- Un secreto de larga duración vive en el cliente: acotado con vencimiento, techo, CIDR, bandera de entidad y revocación.
- El servidor sigue sin poder saber quién tecleó
CONFIRMO; el peldaño 4 es lo que lo prueba para los actos graves. - Despliegue: migraciones, luego gateway, auth y los nueve servicios en el mismo
up, luego mcp-server (Redis) y frontend. Secreto nuevo:ASISTENTE_PEPPER.