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

204 lines
9.7 KiB
Markdown

# 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í.