- 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>
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 enquick/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.godefine UNA interfaceStoreque abstrae rooms + members + sealed-keys + users, con dos backends elegidos por--store:sqlite(single-node) ykv(cluster, JetStream KV). ElServerya tiene el store privilegiado abierto y escribe ahí para todo. Este cambio solo expones.store.AddUser/ListUsers/RevokeUserpor 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 internainternal.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 primerPOST /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
endpointdel firmante, susign_pubhex. 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. Devuelvetruesolo si hay firmante autenticado Ys.store.GetUser(pubHex)confirmarole == adminystatus == active; cualquier otro caso (sin firmante, no-admin, revocado, error de store) → 403, fail-closed. A diferencia derequireMember, 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:handleListUsers→s.store.ListUsers()→[]userJSON(incluye status).handleAddUser→ validasign_pub(hex 64 víaValidateSignPubHex) 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.
/healthzsigue 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
validateSignPubHexdelega ahora enmembership.ValidateSignPubHex(se elimina el importencoding/hexque quedaba huérfano). Misma validación, una sola implementación.
pkg/client/client.go
UserInfo(tipo plano para el panel) + métodos firmados víadoJSON:ListUsers() ([]UserInfo, error)→GET /users.AddUser(signPub, handle, role string) error→POST /users.RevokeUser(signPub string) error→POST /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":"..."}
]
403si el firmante no es admin activo.
POST /users (admin-only)
Body:
{"sign_pub":"<64-hex>","handle":"carol","role":"member"}
roleopcional (vacío =member).201{"status":"added"}en éxito.400sisign_pub/handlevací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).403si 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;IsAuthorizedla deniega en ambos planos al instante).400si{signpub}no es hex de 64 chars.404si no hay user activo con esa clave.403si 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 deTestClientUsersAdminAPI). - 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 enapp.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.
revokemapea desconocido y ya-revocado a 404 indistintamente.RevokeUserdel 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.mddescribe 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í.