# 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: - `handleListUsers` → `s.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) 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`: ```json [ {"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: ```json {"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) ```go 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í.