You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
El alias colisionante con una service key se puede seguir creando, y las métricas lo resuelven case-insensitive #30
Seguimiento de nan#29, cerrado por cloud-api#120. Aquel PR impide operar con un alias colisionante, pero deja dos cabos.
1. El alias colisionante se sigue pudiendo crear
Ni el link de Discord (internal/handlers/account_discord.go:198) ni el trigger members_auto_handle (migración 018) consultan el allowlist. Así que el alias se crea igual y el miembro solo se entera al pulsar el botón de su key, recibiendo un 409 que no puede resolver por sí mismo.
El caso del handle es el peor de los dos: se autogenera del local-part del email, así que un alta con ccordova@… produce el alias reservado sin que el miembro haga nada, y UpdateMemberProfile (internal/db/queries/members.sql:282) excluye explícitamente handle, o sea que no hay forma de cambiarlo desde el portal.
Arreglo propuesto: consultar el allowlist en el punto donde el alias se elige (trigger de handle y link de Discord), en vez de solo donde se usa. El bucle de colisiones del trigger ya evita chocar con otros handles; le falta chocar con la lista de service keys.
2. Las métricas resuelven el alias por su cuenta, y de forma más ancha
MetricsHandler.resolveKeyAlias (internal/handlers/metrics.go:100) resuelve el alias sin pasar por el guard de resolveUser, y internal/metrics/litellmdb.go:124 compara case-insensitive:
LOWER(v.key_alias) =LOWER($1)
Eso es más ancho que la igualdad exacta del guard. Un miembro con alias colisionante vería en su dashboard el consumo de tokens y el gasto de la key de servicio. Arrastra por la misma resolución a metrics_sse.go y a internal/sync/usage.go.
Es solo lectura y agregado, y es preexistente, pero es el hueco que queda tras el #120.
3. Detección proactiva, si se quiere
Sin recuperar el WARN (que se bajó a Debug porque resolveUser corre en cada poll del portal), la alerta barata es:
Hoy ese 409 en GET /api/keyssolo puede venir de este guard, así que aísla el caso con precisión y avisa del miembro encerrado sin ensuciar los logs. En POST comparte código con "API key already exists", así que ahí no discrimina.
Lo que se decidió NO arreglar
El guard corre antes de ensureEntitled, lo que lo convierte en un oráculo de enumeración del allowlist. Se deja así a propósito: explotarlo exige estar autenticado y cambiar el alias por intento, el premio es nulo (conocer el nombre de una service key no da acceso a nada, el allowlist la protege en todas las rutas mutantes), y el arreglo obvio es peor — mover el guard detrás de ensureEntitled haría que un miembro community viera el paywall en vez de la colisión, con la colisión esperándole intacta el día que pague.
Cerrada por cloud-api#123, desplegado en prod y dev como 0.8.104.
Puntos 1 y 2, resueltos. El punto 1 resultó ser más ancho de lo descrito: no era solo el link de Discord, eran tres flujos los que escriben discord_user — el link de cuenta, el onboarding y el alta de comunidad — y discord_user gana sobre handle en la resolución del alias, así que los otros dos anulaban la protección del trigger. Los tres reviewers lo detectaron por separado.
El remedio final no rechaza el link: descarta el username colisionante y deja que el alias caiga al handle, que la migración 049 mantiene libre. Rechazar habría dejado tirado a un miembro que ya pagó (onboarding y comunidad son post-pago) y habría necesitado un texto en cloud-ui que no existe. Se pierde un dato de visualización; no se pierde ningún eje de identidad, revocación ni entitlement — verificado uno a uno.
El punto 2 se cierra donde se sirve, no en una rama concreta de la resolución: cubre también metrics_sse.go, que comparte resolveKeyAlias.
Estado en prod: esquema 49, 1896 miembros, 0 colisionan. Nada cambió de comportamiento al desplegar; los guards son preventivos. Comprobado en vivo: un alta con classifier-teacher@… recibe classifier-teacher-2.
Hallazgos colaterales:
La migración up original cambiaba en silencio suffix INT := 2 a 1, renumerando cómo se sufijan las colisiones entre miembros — nada que ver con este fix. Ahora el up es la 018 literal más una cláusula OR, y el down es la 018 literal.
LITELLM_SERVICE_KEY_ALIASESreemplaza los defaults y solo llega al lado Go. Un nombre añadido por esa palanca protege la key pero no reserva el handle: exige migración compañera. Documentado en NewServiceKeyAllowlist.
En dev la función members_auto_handle pertenecía a postgres mientras las tablas son de nan_api_dev, así que la migración no podía reemplazarla. Desviación previa a este cambio (en prod ambos son nan_api). Alineada.
Queda abierto:
Punto 3 de esta issue — la alerta HTTPRequestsTotal{route="/api/keys", status="409"} > 0. Vive en platform/devops, no en cloud-api. Con los tres flujos cerrados y el trigger reservando, ese 409 ya no es alcanzable para ningún miembro nuevo, así que la alerta pasa de "avísame del miembro encerrado" a "avísame de que una vía que creemos cerrada no lo está" — sigue teniendo valor, con otra lectura.
cloud-ui Admin.tsx filtra la columna de Discord por discordUsername, así que un miembro cuyo username se descartó aparecería como no conectado pese a estar vinculado. Afecta a 0 miembros hoy. El arreglo es usar discordID para filtrar y discordUsername solo para mostrar.
La PromQL de esta issue no corre tal cual. La métrica es nan_http_requests_total, no HTTPRequestsTotal, y la etiqueta es path, no route. Además faltaban dos rutas: GET /api/metrics/usage (que añadió el #123) y GET /api/metrics/usage/sse, que es la que más 409s produce para un miembro bloqueado porque el EventSource reconecta con backoff. Las tres son exclusivas de este guard, verificado en el árbol mergeado.
Con las tres vías cerradas por el #123, la alerta no es "avísame del miembro encerrado" sino "avísame de que un camino que creemos cerrado no lo está". Las dos formas concretas en que puede reabrirse — un cuarto flujo que escriba discord_user sin el guard, o LITELLM_SERVICE_KEY_ALIASES usado sin migración compañera — están en el runbook, con la consulta que encuentra al miembro en cada caso.
Lo que encontraron los reviewers y no estaba en el plan:
El caso espejo existe en prod. Dije "0 miembros afectados" y era falso: dos filas (ids 83 y 2421) llevan discord_user igual a su handle con discord_id NULL — nunca vinculadas, sin rol ni guild. El filtro viejo las llamaba conectadas, así que un operador auditando membresía del guild las habría buscado en Discord sin encontrarlas. Ahora filtran como no conectadas, que es lo correcto, y es el único cambio de comportamiento al desplegar.
La celda tampoco podía enseñar el handle a secas. En esa columna la fila de arriba muestra un username real de Discord, así que un token sin etiquetar se lee como uno: el operador lo busca en el guild, no lo encuentra y concluye que el miembro se fue — el mismo error que esta issue viene a cerrar, un escalón más abajo. Ahora muestra linked · id <snowflake> enlazado al perfil, con el handle en el tooltip.
Dos superficies del miembro tenían el mismo fallo.SettingsModal le decía "Linked as @1738…" (su ID crudo disfrazado de username) y Onboarding escondía la confirmación del link cuando no había username, dejando sin confirmación a quien acababa de pagar y vincular. Ambas corregidas y con tests.
La primera versión del runbook documentaba dos rutas cuando la regla ya casaba tres. Como el conteo es lo que el on-call juzga, y la ruta SSE suele ser la mayoría, la query documentada habría devuelto un número que se lee como "lo intentó una vez y se fue" mientras el navegador del miembro martillea el guard. Hay un test que lee la lista de rutas del expr y exige que cada una aparezca en el runbook.
Tres rondas de QA/UX/Security sobre las tres PRs: 6 blockers en total, todos corregidos, los tres aprobaron. Todo lo nuevo está mutation-tested — incluidas las dos trampas que la propia issue habría inducido (route= y HTTPRequestsTotal), que ahora fallan en CI.
Queda anotado, sin acción: la paridad del conjunto buscable entre admin.go y Admin.tsx no se puede forzar entre repos; cada lado pinea el suyo campo a campo, así que quitar uno falla en su CI.
Activity
sre-helmcode commentedon Aug 20, 2026
Cerrada por cloud-api#123, desplegado en prod y dev como
0.8.104.Puntos 1 y 2, resueltos. El punto 1 resultó ser más ancho de lo descrito: no era solo el link de Discord, eran tres flujos los que escriben
discord_user— el link de cuenta, el onboarding y el alta de comunidad — ydiscord_usergana sobrehandleen la resolución del alias, así que los otros dos anulaban la protección del trigger. Los tres reviewers lo detectaron por separado.El remedio final no rechaza el link: descarta el username colisionante y deja que el alias caiga al
handle, que la migración 049 mantiene libre. Rechazar habría dejado tirado a un miembro que ya pagó (onboarding y comunidad son post-pago) y habría necesitado un texto en cloud-ui que no existe. Se pierde un dato de visualización; no se pierde ningún eje de identidad, revocación ni entitlement — verificado uno a uno.El punto 2 se cierra donde se sirve, no en una rama concreta de la resolución: cubre también
metrics_sse.go, que comparteresolveKeyAlias.Estado en prod: esquema 49, 1896 miembros, 0 colisionan. Nada cambió de comportamiento al desplegar; los guards son preventivos. Comprobado en vivo: un alta con
classifier-teacher@…recibeclassifier-teacher-2.Hallazgos colaterales:
uporiginal cambiaba en silenciosuffix INT := 2a1, renumerando cómo se sufijan las colisiones entre miembros — nada que ver con este fix. Ahora elupes la 018 literal más una cláusulaOR, y eldownes la 018 literal.LITELLM_SERVICE_KEY_ALIASESreemplaza los defaults y solo llega al lado Go. Un nombre añadido por esa palanca protege la key pero no reserva el handle: exige migración compañera. Documentado enNewServiceKeyAllowlist.members_auto_handlepertenecía apostgresmientras las tablas son denan_api_dev, así que la migración no podía reemplazarla. Desviación previa a este cambio (en prod ambos sonnan_api). Alineada.Queda abierto:
HTTPRequestsTotal{route="/api/keys", status="409"} > 0. Vive enplatform/devops, no en cloud-api. Con los tres flujos cerrados y el trigger reservando, ese 409 ya no es alcanzable para ningún miembro nuevo, así que la alerta pasa de "avísame del miembro encerrado" a "avísame de que una vía que creemos cerrada no lo está" — sigue teniendo valor, con otra lectura.Admin.tsxfiltra la columna de Discord pordiscordUsername, así que un miembro cuyo username se descartó aparecería como no conectado pese a estar vinculado. Afecta a 0 miembros hoy. El arreglo es usardiscordIDpara filtrar ydiscordUsernamesolo para mostrar.sre-helmcode commentedon Aug 20, 2026
Los dos pendientes, cerrados y desplegados.
Normal (NoData), health ok0.8.105en prod, 3/3 pods0.8.34, bundle vivo verificadoLa PromQL de esta issue no corre tal cual. La métrica es
nan_http_requests_total, noHTTPRequestsTotal, y la etiqueta espath, noroute. Además faltaban dos rutas:GET /api/metrics/usage(que añadió el #123) yGET /api/metrics/usage/sse, que es la que más 409s produce para un miembro bloqueado porque elEventSourcereconecta con backoff. Las tres son exclusivas de este guard, verificado en el árbol mergeado.Con las tres vías cerradas por el #123, la alerta no es "avísame del miembro encerrado" sino "avísame de que un camino que creemos cerrado no lo está". Las dos formas concretas en que puede reabrirse — un cuarto flujo que escriba
discord_usersin el guard, oLITELLM_SERVICE_KEY_ALIASESusado sin migración compañera — están en el runbook, con la consulta que encuentra al miembro en cada caso.Lo que encontraron los reviewers y no estaba en el plan:
discord_userigual a su handle condiscord_idNULL — nunca vinculadas, sin rol ni guild. El filtro viejo las llamaba conectadas, así que un operador auditando membresía del guild las habría buscado en Discord sin encontrarlas. Ahora filtran como no conectadas, que es lo correcto, y es el único cambio de comportamiento al desplegar.linked · id <snowflake>enlazado al perfil, con el handle en el tooltip.SettingsModalle decía "Linked as @1738…" (su ID crudo disfrazado de username) yOnboardingescondía la confirmación del link cuando no había username, dejando sin confirmación a quien acababa de pagar y vincular. Ambas corregidas y con tests.expry exige que cada una aparezca en el runbook.Tres rondas de QA/UX/Security sobre las tres PRs: 6 blockers en total, todos corregidos, los tres aprobaron. Todo lo nuevo está mutation-tested — incluidas las dos trampas que la propia issue habría inducido (
route=yHTTPRequestsTotal), que ahora fallan en CI.Queda anotado, sin acción: la paridad del conjunto buscable entre
admin.goyAdmin.tsxno se puede forzar entre repos; cada lado pinea el suyo campo a campo, así que quitar uno falla en su CI.