El despliegue en sí¶
La mayor parte de este producto trata sobre agents. Esta página trata sobre aquello dentro de lo que se ejecutan.
Una instalación, con un nombre, una marca, una regla sobre quién puede unirse y
un interruptor que la cierra. Todo ello vive en una única fila de la base de
datos y se edita desde /admin/settings por quien tenga is_app_admin: sin
redespliegue, sin variable de entorno, sin reconstrucción.
Por qué esa autoridad y no un permiso
Un permiso está acotado a una organización. Esta fila no está en ninguna.
Es la misma autoridad que ya administra usuarios y tenants en toda la instalación.
Identidad¶
| Campo | Dónde aparece |
|---|---|
| Name | La barra lateral, la cabecera de inicio de sesión, la pestaña del navegador, la tarjeta OpenGraph y cada correo que envía este despliegue |
| Tagline | Junto al nombre en el título de la pestaña y en un enlace compartido |
| Description | La descripción de la página y las vistas previas de enlaces |
| Logo | Dondequiera que aparezca el nombre: el enlace de marca, la cabecera de inicio de sesión, las páginas legales |
| Favicon | La pestaña del navegador |
| Footer text | Debajo del formulario de inicio de sesión |
| Terms URL, Privacy URL | Cada enlace que si no ofrecería las páginas /legal/* integradas |
Una columna nula significa lo integrado, no vacío
Un operador que borra un campo pide que vuelva el valor por defecto, no una cabecera de inicio de sesión sin nombre. La API responde overrides y cada renderizador resuelve un nulo contra su propio valor integrado.
Un operador que nunca ha abierto la página no tiene fila alguna. La consola
resuelve un nulo contra APP_NAME y SITE en frontend/src/lib/; el backend lo
resuelve contra settings.PROJECT_NAME para el correo que envía él mismo.
Dos constantes para un mismo nombre de producto pueden divergir, así que
backend/tests/test_deployment_settings.py las fija iguales. Lee el
constants.ts del frontend y lo compara con el valor por defecto de clase de
Settings.PROJECT_NAME: el mismo trato que TestFrontendToolCatalog hace con el
catálogo de tools.
Las dos imágenes¶
Se suben mediante POST /api/v1/admin/settings/{logo,favicon} y se guardan como
cualquier otra imagen de esta plataforma: los bytes van al almacenamiento de
archivos configurado y la clave va a una columna. La clave nunca se toma del
cuerpo de una petición —quien pudiera nombrarla podría apuntar el logo público
de este despliegue a lo que sea que guarde el backend de almacenamiento— y el
nombre de archivo almacenado se acuña a partir del tipo de contenido validado y
no del nombre de la subida, porque estos archivos se sirven desde el mismo origen
en el que corren las páginas de la app y ahí logo.html es un script.
JPEG, PNG, WebP y GIF, hasta 2MB, que es la única definición de «una imagen que esta plataforma acepta».
SVG está deliberadamente ausente
Es un documento que puede llevar script, y estos archivos se sirven desde el mismo origen en el que corren las páginas de la app. ICO no aporta nada que un favicon PNG no dé.
La respuesta de branding lleva una versión, no una URL. La dirección es
constante (GET /api/v1/branding/{logo,favicon}) y los bytes se sirven como
immutable durante un año, así que lo que un cliente necesita de la fila es si
existe una imagen y cuándo cambió por última vez; el ?v= construido a partir de
eso es la única razón por la que aparece un reemplazo. Una URL sería además algo
que cada cliente tendría que reescribir, porque en cualquier despliegue real la
API no está en el mismo origen que las páginas.
Cabeceras de seguridad¶
Cada página de la consola lleva una Content-Security-Policy y las cabeceras de
endurecimiento habituales, definidas en frontend/src/lib/csp.ts y
frontend/src/lib/security-headers.ts, ambas verificadas por tests. La política
es default-src 'self' con un connect-src que nombra exactamente este origen,
PUBLIC_API_URL y PUBLIC_WS_URL, un img-src que permite data: para los
glifos de marca y los avatares, frame-src 'self' blob: para las vistas previas
de documentos, object-src 'none', base-uri 'self' y frame-ancestors 'none'.
La política se estampa por petición desde el middleware del frontend, porque las
dos URLs públicas se leen del entorno del servidor en tiempo de ejecución y una
cabecera fijada en el build solo podría nombrar localhost. Las demás cabeceras
son constantes y las fija la configuración de Next. Cambia las URLs públicas y la
política las sigue en la siguiente petición; no se reconstruye nada.
Junto a ella están X-Content-Type-Options: nosniff, X-Frame-Options: DENY,
Referrer-Policy: strict-origin-when-cross-origin y una Permissions-Policy que
deniega la cámara y la geolocalización y permite el micrófono solo en este
origen, para el dictado por voz del chat.
Un proxy inverso no debe añadir sus propias copias de estas
Nginx, Traefik o un ALB delante de la app las pasan sin cambios en lugar de
fijar las suyas. Dos cabeceras Content-Security-Policy en una misma
respuesta las combina el navegador en su intersección, así que un proxy que
añade una segunda —incluso una más laxa— solo endurece la política hasta
convertirla en algo que bloquea un panel que nadie quería bloquear; y con un
segundo X-Frame-Options el navegador se queda con cualquiera de los dos
valores. El nginx/nginx.conf incluido fija únicamente
Strict-Transport-Security, que pertenece a lo que termine TLS; una
configuración de proxy ya existente que añada las demás debería quitarlas.
Quién puede registrarse¶
signup_mode, aplicado en app/services/signup_policy.py —el único lugar— y
controla ambos caminos que acuñan una cuenta.
| Modo | Efecto |
|---|---|
open |
Cualquiera puede registrarse. El valor por defecto, y lo que era todo despliegue antes de esta funcionalidad. |
invite_only |
Solo una dirección que alguna organización haya invitado de verdad. |
closed |
Nadie se registra, por ninguna vía: una invitación no lo anula. |
En los tres, un allowed_email_domains no vacío restringe quién puede
registrarse siquiera. Una invitación anula esa lista: alguien con
members:invite nombró la dirección a propósito, y una lista de dominios es
política del despliegue frente a desconocidos, no un veto sobre un acto
deliberado. A closed no lo anula nada, porque un «cerrado» que deja pasar
algunos registros no está cerrado.
closed significa cerrado, y no hay ningún camino en el que un administrador
cree una cuenta. Deliberadamente: una cuenta necesita una contraseña elegida
por su dueño, así que añadir a alguien significa abrirle el registro a esa
persona, que es para lo que está invite_only. Un modo que permitiera a un
administrador acuñar cuentas sería un tercer camino que acuña una, y los dos que
ya existen son toda la razón por la que signup_policy es un módulo y no una
comprobación dentro de register. Así que un despliegue que tiene que admitir a
una persona más cambia a invite_only y la invita.
Tres cosas más sobre esto que es fácil equivocar, y que se equivocaron:
El primer usuario siempre se admite. Una instalación recién hecha no tiene
cuentas, así que su administrador todavía no existe; un despliegue cerrado que
además rechaza a la persona que lo abriría es uno en el que nadie puede entrar,
sin consola desde la que arreglarlo. register ya promueve esa primera cuenta a
is_app_admin, y la política se remite al mismo hecho.
invite_only existe porque cerrar el registro rompería si no las
invitaciones. InvitationService.accept exige un usuario ya autenticado, así
que una persona invitada tiene que registrarse primero. La política pregunta a
invitation_repo.first_pending_admitting, que es cross-tenant por construcción:
el registro ocurre antes de que se elija una organización. Lo que mantiene eso
seguro es adónde va la respuesta: la política la convierte en un rechazo
booleano, así que un desconocido que sondee el formulario de registro aprende que
alguien invitó a esa dirección y nunca qué organización lo hizo.
Cómo se reconoce una invitación depende de si el registro lleva su token, y las dos respuestas cubren formas distintas:
| Llega con | Reconocida por | Qué formas admite |
|---|---|---|
| Un token (resuelto desde la invitación preparada, nunca en el cuerpo del registro) | invitation_admission.admits |
Cualquier invitación viva que admita la dirección, incluido un enlace que no restringe ninguna dirección, que es la forma que nada más puede ver |
| Sin token | invitation_repo.first_pending_admitting |
Una invitación por correo para esa dirección, o un enlace acotado a su dominio |
El token es la única prueba disponible para un enlace compartible que no lleva ni dirección ni dominio. Una consulta sobre la dirección enviada no puede reconocerlo, así que honrarlo sin prueba de posesión convertiría un único enlace abierto en cualquier parte del despliegue en registro abierto para todo internet. Tener el token es esa prueba.
Un token que no nombra nada vivo cae a la pregunta por la dirección en lugar de rechazar: un enlace caducado guardado en marcadores no debería convertir un registro que por lo demás estaría permitido en un error sobre algo que la persona no puede arreglar.
Registrarse con un token no acepta la invitación. Admite la cuenta y nada
más; unirse a la organización sigue siendo InvitationService.accept, que el
cliente llama cuando ya tiene sesión. Un token en el cuerpo de un registro no
autenticado que además concediera membresía sería una concesión de membresía en
una ruta pública.
La consola nunca lleva el token a través del rodeo por el inicio de sesión. Un
invitado sin cuenta abre /invitations/<token>; AuthGuard canjea el token en
el servidor por un identificador opaco que guarda en una cookie httpOnly que el
navegador no puede leer, y lo envía a
/login?returnTo=/invitations/pending?flow=…: un destino sin credencial, así que
el token no está ni en el returnTo, ni en el historial del navegador, ni en el
almacenamiento de sesión. Si el canje falla —servidor inalcanzable, un rate
limit—, el guard se queda en el enlace de invitación y ofrece reintentar en lugar
de marcharse sin nada preparado, porque el enlace es la única credencial del
invitado.
El flow es un id aleatorio que el canje acuña por cada preparación, y la cookie
lleva su nombre. No es una credencial: sin la cookie no nombra nada. Está ahí
porque un único nombre fijo de cookie es una sola ranura: dos enlaces de
invitación abiertos en paralelo sin sesión se pisaban el uno al otro, y las dos
pestañas pendientes canjeaban luego el segundo. Ahora cada pestaña canjea
exactamente la cookie que nombra su propio flow.
«Create an account» continúa desde ese destino sin credencial, y el proxy de registro reenvía como cabecera el handle preparado del flow nombrado, así que la admisión del registro sigue teniendo el token que necesita sin que este pise nunca una URL ni el cuerpo. Tras iniciar sesión, la página pendiente canjea el handle y acepta: la misma forma que el intercambio del código OAuth, un sustituto opaco, de un solo uso y caducable, para que la credencial nunca viaje por URL. La cookie se borra tras ejecutarse el canje; un 401, un 429 o un fallo del servidor la dejan, porque el handle puede seguir sin gastar y un reintento lo necesita.
Un enlace con max_uses limita cuentas, no solo adhesiones.
used_count cuenta aceptaciones, y aceptar necesita una sesión, así que un techo
leído solo de ahí no limitaba nada de lo que hacía un registro. Un enlace de un
solo uso publicado en un canal admitía tantas cuentas como alguien quisiera
crear, en el despliegue que acababa de cerrar el registro.
Así que un uso se reserva primero para la dirección que se registra:
reserved_emails en la fila, y used_count + reserved_emails es lo que
significa «gastado». La reserva es un único UPDATE condicional, porque dos
registros compitiendo por el último uso leerían si no el mismo recuento.
Aceptar saca la dirección de la lista mientras incrementa el recuento, lo que lo conserva: quien se registró con un enlace de un solo uso todavía puede unirse.
Una reserva que nadie acepta sigue gastada (max_uses es a cuántas personas
admite un enlace, y una cuenta creada con él fue admitida) y muere con la
invitación.
Iniciar sesión con un provider también lleva la invitación, como su handle.
El login del provider empieza en el mismo origen, en
/api/oauth/<provider>/login, para que el handle httpOnly preparado pueda
adjuntarse al salto cross-origin que el navegador hace a continuación:
/oauth/google/login?invitation_handle=…, que el backend resuelve al token que
guarda en la sesión durante todo el viaje de ida y vuelta. El token nunca está en
esa URL. Sin esto, invite_only rechazaba el botón de Google precisamente para
los enlaces que necesitan un token —uno que no restringe ni dirección ni
dominio— mientras el formulario de contraseña de al lado aceptaba a la misma
persona.
Iniciar sesión con un provider también es un registro.
get_or_create_oauth_user es el segundo camino que crea una cuenta, y nada en un
callback de Google parece un registro, así que un despliegue con closed y un
botón de Google estaba de par en par hasta que se controlaron los dos. A quien
ya tiene una cuenta no se le vuelve a controlar: cerrar el registro cierra el
registro, y dejar fuera a un miembro de un despliegue al que pertenece no es lo
que dice el ajuste.
El formulario de registro lee la política del endpoint público de branding y enuncia la regla antes de que nadie escriba nada. Un formulario que acepta una dirección y luego informa de que «ese dominio de correo no puede registrarse» es un formulario que miente; quien lo visita no tiene forma de saber que la regla existe y lee el rechazo como que el producto está roto. Por eso también se publican los dominios permitidos: no son un secreto, y el despliegue está en el host de la propia empresa.
Encontrar un tenant entre todos ellos¶
GET /admin/organizations es la única superficie que responde a qué tenants
existen, y es solo para el app admin por la razón que la hace útil: es
cross-tenant por construcción. Responde con una página de organizaciones, cada
una con su número de miembros y de agents y su owner más antiguo: a quién
preguntar por ella. Todos los campos de owner son nulos a la vez en una
organización cuyo último owner se marchó, un estado que solo el administrador del
despliegue puede arreglar y que por tanto hay que mostrarle.
| Parámetro | |
|---|---|
search |
Nombre, slug o la dirección del owner. El término es texto, no un patrón: 100% encuentra al tenant que se llama así en lugar de a todos |
sort_by |
name, slug, members, agents, created_at. Cualquier otra cosa es un 422 |
sort_dir |
asc / desc, con los más nuevos primero por defecto |
kind |
personal, team o all. A cada cuenta se le da una organización personal al registrarse, así que en la mayoría de despliegues son casi toda la lista |
skip, limit |
Una página del servidor, hasta 100 |
Todo ello ocurre en SQL, antes de OFFSET/LIMIT, y total cuenta aquello
a lo que se acotó, no el despliegue entero. Esa es la diferencia entre ordenar y
aparentarlo: una página ordenada después de llegar afirma un orden sobre toda la
colección que cincuenta filas no pueden dar, y por eso la lista de tenants del
administrador no llevaba ningún control mientras la ruta no respondía a ninguno
(#921). El orden desempata por el id, así que paginar una columna donde las filas
comparten valor lista cada una de ellas una sola vez.
Una columna fuera del conjunto se rechaza por su nombre en lugar de compararse
contra nada, por las dos razones por las que GET /runs rechaza una: una página
vacía se lee como este despliegue no tiene tenants, y un ORDER BY ensamblado
a partir de una query string es una superficie de inyección.
Un app admin no puede dejar al despliegue sin acceso desde la consola¶
El bloqueo autoinfligido que esto evita
En la instalación de un solo administrador que produce
make platform-bootstrap, un clic despistado en tu propia fila terminaba con
la administración hasta que alguien llegara a un terminal. La recuperación es
agenticos cmd create-app-admin <email> desde una shell: el correo es un
argumento obligatorio.
is_active se aplica en la siguiente petición y is_app_admin es lo que leen las
páginas de administración, así que un app admin actuando sobre su propia fila
desde /admin/users podía cerrarse la sesión, perder /admin o borrar la
cuenta. UserService.admin_update y admin_delete rechazan la autosuspensión y
el autoborrado, y el panel no ofrece Suspend, Demote ni Impersonate en tu propia
fila (Delete se queda, visible y rechazado, porque «por qué no puedo borrarme»
tiene una respuesta que vale la pena enseñar). La API también rechaza actuar como
uno mismo: nadie actuando como nadie no es una impersonación.
El despliegue no puede quedarse sin ningún app admin a través de la API: el único
privilegio global se concede solo por CLI (agenticos cmd create-app-admin) y no
hay petición que lo quite, así que el conjunto de app admins solo mengua por
borrado, y borrar al último es borrarte a ti, lo cual se rechaza. Quitar a un
administrador que se marcha de verdad es la acción de otro administrador, que es
también lo que mantiene legible el rastro de auditoría. La recuperación, si
alguna vez hace falta, sigue siendo create-app-admin desde una shell en el
despliegue.
Ese argumento va sobre el conjunto, y durante un tiempo el código iba sobre una fila.
Dos administradores borrándose el uno al otro no estaban borrándose a sí mismos. Bloquearon filas objetivo distintas, nunca compitieron y ambos hicieron commit: cero app admins, recuperable solo escribiendo en la base de datos (#1208).
Así que el borrado de un administrador toma SELECT ... FOR UPDATE sobre el
conjunto de app admins, ordenado por id, antes de decidir. La segunda petición
espera, vuelve a leer el conjunto una vez que la primera ha hecho commit, y se
rechaza por vaciarlo.
Ordenado, porque dos peticiones tomando las mismas filas en distinto orden son un deadlock y no una cola. Y tomado en cada borrado de usuario y no solo en el de un administrador: borrar a un usuario es la acción de un administrador, no una ruta caliente, y un orden total vale más que la contención que cuesta.
Actuar como otra cuenta¶
Impersonate en /admin/users empieza a actuar como esa persona desde tu
propio navegador. No se copia nada a ninguna parte: la consola cambia la cookie
de acceso de la sesión por una que nombra al objetivo, cada página se renderiza
como esa persona la vería, y una franja en la parte superior dice de quién es
esta cuenta y quién está actuando de verdad, con el único botón que lo termina.
Una impersonación es una sesión, no una credencial suelta. El token nombra una
fila en sessions con impersonator_user_id puesto, y la API lo rechaza en
cuanto esa fila se termina o ha caducado, o el administrador que hay detrás ya no
es un app admin activo; así que se detiene cuando pulsas End impersonation,
cuando la persona cierra sesión en todas partes o cambia su contraseña, cuando se
cumple la hora, o cuando el administrador es suspendido, degradado o borrado, lo
que ocurra primero. No se puede refrescar: la ventana es la del propio access
token, y la hora es el techo, no un arrendamiento renovable.
Una conversación de chat abierta termina con ella
Una conversación de chat corre sobre un WebSocket que se autentica una vez, en el handshake. Ahora vuelve a ejecutar esa comprobación en cada mensaje, así que terminar una impersonación —o suspender la cuenta— cierra también un chat abierto, en lugar de solo rechazar la siguiente petición HTTP mientras el socket sigue respondiendo.
La lista de dispositivos de la persona no la muestra
Una impersonación es una fila bajo su id que tiene un administrador, no un dispositivo desde el que ella inició sesión, así que no está en su lista de dispositivos, ni en el recuento de sesiones abiertas del panel, ni en su «última vez visto». Si se le dice o no es el ajuste de más abajo, y una fila en esa lista lo decidiría por ella.
Si se avisa a la persona es una política, fijada en esta fila.
notify_impersonated_users está desactivado por defecto; activado, se envía un
correo a la persona una vez, al empezar la impersonación, nombrando al
administrador. El rastro de auditoría registra la impersonación en cualquier caso
—tanto el inicio como el fin, con la sesión a la que pertenecen—, que es lo que
describe Gobernanza.
Avisos, y cerrar el despliegue¶
El anuncio es una frase con uno de tres estilos, mostrada encima de cada
página a los usuarios con sesión hasta que la descartan. Es el único campo de esta
fila que no está en el endpoint público: un anuncio es un operador hablando con
la gente que usa el despliegue —una ventana de actualización, a quién escribir—,
así que tiene su propia ruta, GET /api/v1/branding/notice, detrás de una
sesión.
El descarte se indexa por el mensaje mismo, en el almacenamiento del propio navegador. Una bandera haría invisible el siguiente anuncio para todos los que descartaron el anterior; la marca de tiempo de la fila de ajustes desharía el descarte de un aviso cada vez que se renombrara el despliegue. Lo que cambió es el texto, así que el texto es la clave. Un almacenamiento que se niega a leerse o escribirse —un modo privado, un webview embebido— significa «nada descartado» y no una excepción: lanzada durante el render tiraría el dashboard para todo usuario con sesión, y la franja se sigue cerrando mientras la página esté abierta.
El modo de mantenimiento mantiene cerrada la API, no solo la consola.
app/core/maintenance.py es un middleware ASGI puro por encima de las rutas, así
que una página que alguien ya tuviera abierta deja de funcionar, que es toda la
diferencia entre un modo de mantenimiento y una franja. Su lista de permitidos es
corta y se prueba entrada por entrada:
/health*— una sonda de disponibilidad que falla durante una ventana es un orquestador reiniciando el contenedor en el que el operador está trabajando./api/v1/branding— la página cerrada tiene que poder decir cómo se llama este despliegue y por qué está cerrado./api/v1/auth/*— un administrador tiene que poder iniciar sesión mientras la ventana está abierta./api/v1/admin/*— y llegar entonces al interruptor.- La documentación y el esquema OpenAPI, que no sirven datos.
Todo lo demás es un 503 con Retry-After. No lee ninguna sesión —eso significaría
verificar un token por encima del grafo de dependencias—, así que ampliar la ruta
a /api/v1/admin/* no amplía la autoridad: CurrentAppAdmin rechaza allí a quien
no sea administrador exactamente igual que siempre.
Falla abriendo. Una puerta que no puede leer su propio interruptor —un parpadeo de Redis, una migración que no se ha ejecutado— deja pasar el tráfico, porque la alternativa convierte un hipo de infraestructura en una caída total que nadie programó.
El veredicto se cachea en el Redis que todo worker ya comparte: escrito después del commit, para que el interruptor sea inmediato y la caché nunca pueda anunciar un estado que la base de datos revirtió; publicado de forma anticipada, una petición que luego fallaba dejaba una ventana desactivada reabriendo el despliegue durante hasta el TTL. Lleva además un TTL de 30 segundos, así que una escritura que nunca llegó a Redis se cura sola en lugar de dejar el despliegue abierto durante una ventana que alguien programó.
Y una página ya abierta se entera. El contexto de branding lo resuelve una
sola vez el layout raíz del servidor y no cambia en toda la vida de una página,
así que una ventana abierta después dejaba cada pestaña abierta en un dashboard
cuyas peticiones habían empezado todas a responder 503, sin nada en pantalla que
dijera por qué; y cerrarla dejaba una pestaña en la pantalla de mantenimiento
hasta que alguien recargaba. GET /api/v1/branding/notice lleva el veredicto de
mantenimiento junto al anuncio y se consulta una vez por minuto, que es una
petición para las dos respuestas en lugar de dos que pueden discrepar sobre una
misma fila.
En la consola, el administrador ve una franja en lugar de la página cerrada. Es la única persona que puede terminar la ventana, y un modo de mantenimiento que además esconde el interruptor es una caída.
Cuánto puede ocupar una sola cuenta¶
Dos techos, ambos en la misma fila y ambos nulos por defecto, y nulo significa sin límite en lugar de «sin configurar». Un despliegue autoalojado para una sola empresa no quiere ninguno; un despliegue abierto a registros quiere los dos, porque si no una cuenta puede acuñar tenants sin límite.
| Ajuste | Cuenta | No cuenta |
|---|---|---|
| Organizations per account | Las organizaciones que una cuenta posee, la personal incluida | Aquellas a las que otro la invitó |
| Agents per organization | Los agents que tiene la organización | Los agents archivados |
Cada transición hacia el estado contado se comprueba, no solo una creación. Un
techo aplicado solo a filas nuevas es uno que se esquiva de lado: una organización
en su límite de agents archiva uno, crea un reemplazo y restaura el que archivó, y
a una cuenta en su límite de organizaciones se le entrega la de otro mediante
transfer_ownership. Así que unarchive y transfer_ownership hacen la misma
pregunta que create.
Y el recuento se toma bajo un lock. Leer count(...) >= limit y luego
escribir son dos sentencias, así que dos peticiones pasan ambas el recuento y
ambas insertan: el techo superado de forma determinista, con dos clics. Ninguna
restricción puede expresar «como mucho N filas así», por lo que app/db/locks.py
toma un advisory lock con alcance de transacción sobre el sujeto del techo: dos
peticiones sobre una misma cuenta hacen cola, peticiones sobre cuentas distintas
no se encuentran nunca, y el lock lo libera el commit o el rollback. Solo donde
hay un límite fijado, así que un despliegue sin topes no paga nada.
Ambas exclusiones son el punto del diseño y no un detalle de él. Que te inviten a diez organizaciones es decisión de otro, y un techo que una persona no puede controlar es un techo que le impide crear las suyas. Y archivar es como se retira un agent: un techo que un agent retirado siguiera ocupando dejaría como única vuelta por debajo de él un borrado, que se lleva por delante el historial de versiones y la atribución de los runs.
El rechazo nombra el techo y el recuento contra el que se midió
({"limit": 5, "held": 5}), así que «por qué no puedo» lo responde la respuesta y
no la memoria de un administrador. Se lanza en el servicio que crea la cosa, no en
la ruta, porque la ruta no es la única entrada.
El schema rechaza el cero: una cuenta que no puede poseer ninguna organización es una cuenta que no se puede crear, ya que el registro da a cada una su propia organización personal.
La fila¶
Una sola fila para toda la instalación, custodiada por la base de datos y no por
una convención que nadie ve: singleton es único y está restringido a verdadero,
así que una segunda identidad es un IntegrityError en lugar de un despliegue que
calladamente tiene dos y sirve aquella que una consulta ordenó primero. La
escritura es un único INSERT ... ON CONFLICT DO UPDATE, porque un
leer-luego-insertar compite consigo mismo en cuanto dos administradores guardan
desde dos pestañas.
No se siembra nada. Sin fila significa todos los valores por defecto, que es
exactamente el estado de un despliegue que nadie ha configurado; e importa porque
el endpoint público de branding no está autenticado y se alcanza en cada carga de
página en frío, así que una lectura que creara la fila permitiría a un desconocido
provocar un INSERT.
Cada escritura se audita en app_admin_audit_logs, nombrando los campos y
nunca sus valores: un anuncio y una lista de dominios son ambos texto del
operador, y una fila de auditoría sobrevive al cuerpo de la petición del que vino.
Un rechazo de este despliegue siempre tiene el mismo aspecto¶
Vale la pena decirlo aquí porque cerrar un despliegue es la funcionalidad con más
probabilidades de producir uno que una persona no haya visto nunca.
app/api/exception_handlers.py pone cada rechazo en
{"error": {"code", "message", "details"}}:
Eso cubre las excepciones de dominio, la validación de schema y, desde #917,
también HTTPException, lo que cubre un 405, una ruta sin coincidencia y las
veintidós rutas que lanzan una directamente. Dos formas en el cable significan que
cada llamante o maneja las dos o maneja mal una en silencio.
Una petición con el método equivocado respondía antes 500 en lugar de 405, en
todas las rutas. La instrumentación de FastAPI de OpenTelemetry deriva el nombre
de un span recorriendo app.routes, y su rama Match.PARTIAL —que es exactamente
«la ruta coincide y el método no»— lee .path sin protección; FastAPI 0.141 pone
objetos _IncludedRouter en esa lista y no lo tienen. app/core/otel_compat.py
proporciona, para esa rama, el mismo valor de reserva que upstream ya usa en la
rama que sí protegió. Sigue sin arreglarse en upstream a fecha de 0.65b0, y
tests/test_otel_route_details.py falla cuando se arregle, que es cuando el
módulo desaparece.
Resumen¶
- La identidad del despliegue es una fila, editada desde
/admin/settings, y una columna nula significa lo integrado y no vacío. signup_modese aplica en un solo lugar y controla ambos caminos que acuñan una cuenta. Una invitación anula una lista de dominios; nada anulaclosed.- Un app admin no puede dejarse a sí mismo sin acceso desde la consola.
- La impersonación es una sesión: iniciada desde la consola sin ningún token
en el portapapeles, nombrada en una franja, terminada por el administrador, por
la persona al cerrar sesión en todas partes, o por la hora. Si se avisa a la
persona es
notify_impersonated_users, desactivado por defecto. - Cada rechazo de este despliegue tiene el mismo aspecto, sea cual sea la capa que lo produjo.