Files
message_bus/reports/0014-2026-06-07-unibus-users-http-admin-api.md
T
egutierrez d43ffae3ae chore: auto-commit (17 archivos)
- reports/0001-2026-06-07-unibus-grafana-monitoring.md
- reports/0008-2026-06-07-unibus-admin-users-wired.md
- reports/0008-2026-06-07-unibus-decentralization-audit.md
- reports/0009-2026-06-07-unibus-cluster-hardening.md
- reports/0010-2026-06-07-unibus-android-native.md
- reports/0011-2026-06-07-unibus-cluster-deploy.md
- reports/0012-2026-06-07-unibus-deploy-gaps-closed.md
- reports/0013-2026-06-07-unibus-admin-panel.md
- reports/0014-2026-06-07-unibus-users-http-admin-api.md
- reports/0015-2026-06-07-unibus-web-wired.md
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 01:57:00 +02:00

9.7 KiB

unibus — API HTTP admin-only de gestión de usuarios

  • Slug: unibus-users-http-admin-api
  • Fecha: 2026-06-07
  • Rama: quick/users-http-admin (basada en quick/0011-deploy-gaps)
  • Worktree: /tmp/unibus_usersapi
  • Versión: 0.9.0 → 0.10.0

Resumen

Se cierra la última asimetría del control plane de unibus. Antes, las rooms tenían una superficie HTTP firmada (POST /rooms, POST /rooms/{id}/invite, etc.) pero los usuarios solo se gestionaban por CLI local (membershipd user add|list|revoke) o por acceso directo al store. Esto obligaba al panel de administración a tener --db o acceso directo al KV del cluster, rompiendo el modelo de "el panel habla firmado como un cliente más".

Ahora los usuarios tienen API HTTP admin-only, simétrica con rooms:

  • GET /users — lista completa del allowlist (incluye revocados).
  • POST /users — alta {sign_pub, handle, role}.
  • POST /users/{signpub}/revoke — revocación (flip de status).

El server ejecuta las mutaciones contra el mismo store privilegiado que ya usa para las rooms (s.store): SQLite en single-node, JetStream KV replicado (UNIBUS_users) en cluster. No se abre ninguna conexión KV nueva ni se usa la identidad interna; el storage no cambia de sitio. El panel firma como admin y deja de necesitar --db/KV directo. Funciona idéntico single-node y cluster.

Modelo

  • Storage unificado (sin cambios). pkg/membership/store.go define UNA interface Store que abstrae rooms + members + sealed-keys + users, con dos backends elegidos por --store: sqlite (single-node) y kv (cluster, JetStream KV). El Server ya tiene el store privilegiado abierto y escribe ahí para todo. Este cambio solo expone s.store.AddUser/ListUsers/RevokeUser por HTTP; no altera dónde viven los datos.
  • HTTP admin-only y no acceso directo al KV. El panel (gateway Go) habla firmado como admin al control plane, igual que un cliente normal habla para rooms. El server (que sirve el KV con permisos plenos en cada nodo) ejecuta la mutación. El panel no necesita --db, ni la identidad interna internal.id, ni correr en un nodo del cluster.
  • Bootstrap (huevo-gallina). La CLI membershipd user add --store kv (de la rama base) sigue existiendo SOLO para sembrar el admin #0: sin un admin sembrado no hay quién firme el primer POST /users. A partir de ahí, toda la gestión es HTTP admin-only.

Cambios por archivo

pkg/membership/server.go

  • El contexto de request ahora lleva, además del endpoint del firmante, su sign_pub hex. Motivo: EndpointID(signPub) = base64url(sha256(signPub)) es one-way, así que el endpoint no se puede revertir a la clave para mirar al firmante en el allowlist. withSigner(ctx, endpoint, pubHex) + signerPubHex(r).
  • Helper requireAdmin(w, r) (string, bool): default-deny. Devuelve true solo si hay firmante autenticado Y s.store.GetUser(pubHex) confirma role == admin y status == active; cualquier otro caso (sin firmante, no-admin, revocado, error de store) → 403, fail-closed. A diferencia de requireMember, NO relaja en AuthOff/dev: una operación que concede/revoca acceso nunca corre sin admin verificado.
  • Tres handlers, todos tras requireAdmin, registrados en el mux:
    • handleListUserss.store.ListUsers()[]userJSON (incluye status).
    • handleAddUser → valida sign_pub (hex 64 vía ValidateSignPubHex) y role (admin|member, vacío = member); ErrUserExists → 409 (no sobrescribe); éxito → 201.
    • handleRevokeUser → valida el path {signpub}; s.store.RevokeUser(...); desconocido/ya-revocado → 404; éxito → 200.
  • /healthz sigue exento de auth; el resto hereda firma+nonce+TLS+enforce.

pkg/membership/users.go

  • Nueva ValidateSignPubHex(signPub) error (hex de 64 chars = Ed25519 de 32 bytes), single source of truth compartida por la CLI y los handlers.

cmd/membershipd/users_cli.go

  • validateSignPubHex delega ahora en membership.ValidateSignPubHex (se elimina el import encoding/hex que quedaba huérfano). Misma validación, una sola implementación.

pkg/client/client.go

  • UserInfo (tipo plano para el panel) + métodos firmados vía doJSON:
    • ListUsers() ([]UserInfo, error)GET /users.
    • AddUser(signPub, handle, role string) errorPOST /users.
    • RevokeUser(signPub string) errorPOST /users/{signpub}/revoke.
  • Mirrors de wire types userJSON / addUserReq.

Contrato JSON de los endpoints

Todos requieren las cabeceras de firma de transporte ya existentes (X-Unibus-Pub/Ts/Nonce/Sig, sobre CanonicalRequest) Y que el firmante sea un admin activo. Errores en el envoltorio estándar {"error": "..."}.

GET /users (admin-only)

Respuesta 200:

[
  {"sign_pub":"<64-hex>","handle":"alice","role":"admin","status":"active","created_at":"2026-06-07T..."},
  {"sign_pub":"<64-hex>","handle":"carol","role":"member","status":"revoked","created_at":"...","revoked_at":"..."}
]
  • 403 si el firmante no es admin activo.

POST /users (admin-only)

Body:

{"sign_pub":"<64-hex>","handle":"carol","role":"member"}
  • role opcional (vacío = member).
  • 201 {"status":"added"} en éxito.
  • 400 si sign_pub/handle vacíos, hex inválido, o role fuera de {admin,member}.
  • 409 {"error":"user already registered (unchanged); revoke it first to replace"} si la clave ya está registrada (idéntica semántica que la CLI: no sobrescribe ni eleva rol).
  • 403 si el firmante no es admin activo.

POST /users/{signpub}/revoke (admin-only)

  • Sin body.
  • 200 {"status":"revoked"} en éxito (flip de status, sin hard-delete: la identidad queda auditable; IsAuthorized la deniega en ambos planos al instante).
  • 400 si {signpub} no es hex de 64 chars.
  • 404 si no hay user activo con esa clave.
  • 403 si el firmante no es admin activo.

Authz admin (default-deny)

firma+nonce+TLS+enforce (middleware existente)  →  requireAdmin
                                                     │
   signerPubHex(r) ausente ───────────────────────► 403
   GetUser(pubHex) error / role!=admin / status!=active ► 403
   admin activo ──────────────────────────────────► handler

No se baja la posture: la firma anti-replay, el enforce, el TLS y la ACL por subject quedan intactos. La autorización admin se suma encima, consultando el store en cada request (un admin recién revocado es denegado de inmediato).

Métodos del cliente (para el panel)

type UserInfo struct {
    SignPub, Handle, Role, Status, CreatedAt, RevokedAt string
}

func (c *Client) ListUsers() ([]UserInfo, error)
func (c *Client) AddUser(signPub, handle, role string) error
func (c *Client) RevokeUser(signPub string) error

El panel (unibus_admin) construye un client.Client con la identidad del admin (la misma que firma para rooms) y llama estos tres métodos. Un 403 del server se propaga como error. La pestaña Users del panel deja de necesitar --db o acceso KV directo.

Evidencia ejecutable

Build sin CGO con go.work externo (el worktree vive en /tmp, donde el replace fn-registry => ../../../../ del go.mod no resuelve; no se commitea):

printf 'go 1.26.4\nuse /tmp/unibus_usersapi\nreplace fn-registry => /home/enmanuel/fn_registry\n' > /tmp/usersapi.work
GOWORK=/tmp/usersapi.work CGO_ENABLED=0 go vet ./...     # exit 0
GOWORK=/tmp/usersapi.work CGO_ENABLED=0 go build ./...   # exit 0
GOWORK=/tmp/usersapi.work CGO_ENABLED=0 go test ./pkg/membership/ ./pkg/client/ -count=1
#   ok  github.com/enmanuel/unibus/pkg/membership  8.399s
#   ok  github.com/enmanuel/unibus/pkg/client      6.166s

Tests nuevos (verbose):

=== RUN   TestUsersHTTP_NonAdminForbidden   --- PASS   (403 en GET/POST/revoke)
=== RUN   TestUsersHTTP_AdminRoundtrip      --- PASS   (add 201 → list activa → revoke 200 → list revocada)
=== RUN   TestUsersHTTP_Validation          --- PASS   (hex 400, role 400, re-alta 409, fila intacta)
=== RUN   TestClientUsersAdminAPI           --- PASS   (cliente admin add/list/revoke; member → 403 en los 3)

Cobertura de los casos pedidos:

  • 403 no-admin en los tres endpoints (TestUsersHTTP_NonAdminForbidden, y la mitad no-admin de TestClientUsersAdminAPI).
  • Roundtrip admin add → list (aparece, activa) → revoke (status revocado) (TestUsersHTTP_AdminRoundtrip, TestClientUsersAdminAPI).
  • Validación: hex inválido → 400, role inválido → 400, re-alta → 409, y verificación de que el 409 no muta la fila existente (TestUsersHTTP_Validation).
  • Test de cliente contra un membershipd embebido bajo enforce (TestClientUsersAdminAPI).

Gaps / notas para el integrador

  • Bootstrap sigue por CLL. El admin #0 se siembra con membershipd user add --store kv (rama base). La API HTTP no puede crear el primer admin (no habría quién firme). Documentado en app.md (gotcha + growth log).
  • Revoke de admins. Nada impide a un admin revocarse a sí mismo o al último admin. No se añadió un guard "no te quedes sin admins" (fuera de alcance de esta tarea; el flujo de bootstrap por CLI permite recuperarse). Candidato a endurecer si el panel lo necesita.
  • revoke mapea desconocido y ya-revocado a 404 indistintamente. RevokeUser del store no distingue ambos casos; se mapea a 404 con mensaje genérico. Si el panel quiere idempotencia en revoke, conviene un cambio en el store (fuera de alcance).
  • El gotcha histórico "ni auth en las rutas GET" de app.md describe la posture v1 y precede al enforce; no se tocó (es histórico, no de esta tarea).
  • Integración. El operador mergea esta rama junto con quick/0011-deploy-gaps. No se mergeó a master desde aquí.