d43ffae3ae
- 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>
204 lines
9.7 KiB
Markdown
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í.
|