Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b379730225 | |||
| 450ca01baf |
@@ -2,7 +2,7 @@
|
|||||||
name: unibus
|
name: unibus
|
||||||
lang: go
|
lang: go
|
||||||
domain: infra
|
domain: infra
|
||||||
version: 0.9.0
|
version: 0.10.0
|
||||||
description: "Bus de mensajería unificado sobre NATS+JetStream con cifrado E2E por room (megolm/olm reducido): service de membresía/claves, librería cliente y peers demo."
|
description: "Bus de mensajería unificado sobre NATS+JetStream con cifrado E2E por room (megolm/olm reducido): service de membresía/claves, librería cliente y peers demo."
|
||||||
tags: [service, messaging, nats, e2e]
|
tags: [service, messaging, nats, e2e]
|
||||||
uses_functions:
|
uses_functions:
|
||||||
@@ -122,6 +122,21 @@ Para apuntar a un NATS externo en producción: `--nats-url nats://host:4222` en
|
|||||||
las rutas GET de lectura. Confía en la red interna. Las rutas mutantes
|
las rutas GET de lectura. Confía en la red interna. Las rutas mutantes
|
||||||
(`/rooms`, `/invite`, `/rekey`) sí exigen firma Ed25519 del owner sobre los
|
(`/rooms`, `/invite`, `/rekey`) sí exigen firma Ed25519 del owner sobre los
|
||||||
bytes canónicos de la request. Endurecer es fase posterior.
|
bytes canónicos de la request. Endurecer es fase posterior.
|
||||||
|
- **Gestión de usuarios: storage unificado, alta por dos vías.** El allowlist de
|
||||||
|
usuarios vive en el MISMO store que las rooms (`pkg/membership.Store`): SQLite en
|
||||||
|
single-node, JetStream KV replicado (`UNIBUS_users`) en cluster. El `Server` ya
|
||||||
|
tiene ese store privilegiado abierto (es quien sirve el KV en cada nodo), así que
|
||||||
|
expone `GET/POST /users` y `POST /users/{signpub}/revoke` como API HTTP admin-only,
|
||||||
|
simétrica con las rutas de rooms: el panel de administración firma como admin y el
|
||||||
|
server ejecuta la mutación contra el mismo store. El panel NO necesita `--db`, ni la
|
||||||
|
identidad interna, ni correr en un nodo del cluster; funciona idéntico en single-node
|
||||||
|
y cluster. La autorización es default-deny: solo un firmante que el store confirma como
|
||||||
|
`role == "admin"` activo pasa, cualquier otro recibe 403 (encima de la firma+nonce+TLS
|
||||||
|
ya existentes). La CLI `membershipd user add --store kv` sigue existiendo SOLO para
|
||||||
|
sembrar el admin #0 (bootstrap del huevo-gallina: 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. El
|
||||||
|
alta es idempotente igual que la CLI: re-alta de una clave ya registrada = 409, sin
|
||||||
|
sobrescribir ni elevar rol; el revoke es un flip de status (sin hard-delete), auditable.
|
||||||
- **Identidad = secreto crítico.** El archivo de identidad (`worker.id`,
|
- **Identidad = secreto crítico.** El archivo de identidad (`worker.id`,
|
||||||
`chat.id`) contiene las claves privadas (Ed25519 + X25519). Se escribe 0600.
|
`chat.id`) contiene las claves privadas (Ed25519 + X25519). Se escribe 0600.
|
||||||
Perderlo = mensajes ilegibles, sin recuperación. Trátalo como una clave SSH.
|
Perderlo = mensajes ilegibles, sin recuperación. Trátalo como una clave SSH.
|
||||||
@@ -154,6 +169,28 @@ agent.<nombre>.{in,out} inbox/outbox de agente LLM (agent.scout.in)
|
|||||||
|
|
||||||
## Capability growth log
|
## Capability growth log
|
||||||
|
|
||||||
|
- v0.10.0 (2026-06-07) — API HTTP admin-only de gestión de usuarios, cerrando la
|
||||||
|
última asimetría del control plane: las rooms tenían superficie HTTP firmada
|
||||||
|
(`POST /rooms`, etc.) pero los users solo se gestionaban por CLI local o acceso
|
||||||
|
directo al store. Se añaden `GET /users` (lista completa, incluidos revocados),
|
||||||
|
`POST /users` (alta `{sign_pub, handle, role}`: valida hex de 64 chars + role en
|
||||||
|
`{admin, member}`, 409 idempotente que no sobrescribe ni eleva rol) y
|
||||||
|
`POST /users/{signpub}/revoke` (flip de status, sin hard-delete). Los tres pasan por
|
||||||
|
un helper `requireAdmin` default-deny que confirma contra el store que el firmante
|
||||||
|
autenticado es un user `role == "admin"` activo (el endpoint id es un hash one-way de
|
||||||
|
la clave, así que el contexto lleva ahora también el `sign_pub` hex del firmante para
|
||||||
|
resolver `GetUser`); cualquier otro firmante recibe 403, encima de la firma+nonce+TLS+
|
||||||
|
enforce ya heredadas del middleware. NO se abre conexión KV nueva ni se usa la identidad
|
||||||
|
interna: el server escribe vía su `s.store` privilegiado, el MISMO que las rooms (SQLite
|
||||||
|
single-node, KV `UNIBUS_users` en cluster). `pkg/client` gana `ListUsers/AddUser/RevokeUser`
|
||||||
|
(tipo plano `UserInfo`) firmando como admin, así la pestaña Users del panel deja de
|
||||||
|
necesitar `--db`/acceso KV directo. La CLI `membershipd user add --store kv` queda SOLO
|
||||||
|
para sembrar el admin #0 (bootstrap). La validación de `sign_pub` se unifica en
|
||||||
|
`membership.ValidateSignPubHex`, reusada por la CLI y los handlers. Tests nuevos:
|
||||||
|
no-admin → 403 en los tres endpoints, roundtrip admin add→list→revoke, y validación
|
||||||
|
(hex inválido → 400, role inválido → 400, re-alta → 409), más un test de cliente contra
|
||||||
|
un membershipd embebido. Cambios 100% aditivos: el comportamiento single-node y de las
|
||||||
|
rutas de rooms no cambia; vet/build/test verdes.
|
||||||
- v0.9.0 (2026-06-07) — cierre de los gaps que el despliegue del cluster (report
|
- v0.9.0 (2026-06-07) — cierre de los gaps que el despliegue del cluster (report
|
||||||
0011) dejó abiertos (report 0012). (GAP A) Nueva capability `membershipd user
|
0011) dejó abiertos (report 0012). (GAP A) Nueva capability `membershipd user
|
||||||
add|list|revoke --store kv`: alta/baja de usuarios contra el KV replicado del
|
add|list|revoke --store kv`: alta/baja de usuarios contra el KV replicado del
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -90,16 +89,10 @@ func openStore(path string) membership.Store {
|
|||||||
|
|
||||||
// validateSignPubHex ensures the key is exactly a 32-byte Ed25519 public key in
|
// validateSignPubHex ensures the key is exactly a 32-byte Ed25519 public key in
|
||||||
// hex (64 hex chars). Catching this here turns a silent "authorized nobody" into
|
// hex (64 hex chars). Catching this here turns a silent "authorized nobody" into
|
||||||
// an explicit error at seed time.
|
// an explicit error at seed time. It delegates to membership.ValidateSignPubHex
|
||||||
|
// so the CLI and the HTTP user-management handlers share one rule.
|
||||||
func validateSignPubHex(signPub string) error {
|
func validateSignPubHex(signPub string) error {
|
||||||
b, err := hex.DecodeString(signPub)
|
return membership.ValidateSignPubHex(signPub)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("sign-pub is not valid hex: %w", err)
|
|
||||||
}
|
|
||||||
if len(b) != 32 {
|
|
||||||
return fmt.Errorf("sign-pub must be a 32-byte Ed25519 public key (64 hex chars), got %d bytes", len(b))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// kvFlags holds the connection flags shared by the --store kv path of the user
|
// kvFlags holds the connection flags shared by the --store kv path of the user
|
||||||
|
|||||||
@@ -456,6 +456,23 @@ type memberRoomJSON struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userJSON mirrors the server's wire type on the admin user-management endpoints.
|
||||||
|
type userJSON struct {
|
||||||
|
SignPub string `json:"sign_pub"`
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
RevokedAt string `json:"revoked_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// addUserReq is the POST /users body (mirror of the server type).
|
||||||
|
type addUserReq struct {
|
||||||
|
SignPub string `json:"sign_pub"`
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
// ---- room operations ------------------------------------------------------
|
// ---- room operations ------------------------------------------------------
|
||||||
|
|
||||||
// RoomRef is a room this peer belongs to, returned by ListMyRooms. It is the
|
// RoomRef is a room this peer belongs to, returned by ListMyRooms. It is the
|
||||||
@@ -490,6 +507,59 @@ func (c *Client) ListMyRooms() ([]RoomRef, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- user administration (admin-only) ------------------------------------
|
||||||
|
|
||||||
|
// UserInfo is a bus user as returned by the admin user-management endpoints. It
|
||||||
|
// is a flat view (no nested types) for the admin panel: the signing key
|
||||||
|
// (lowercase hex), handle, role ("admin"|"member"), status ("active"|"revoked"),
|
||||||
|
// and timestamps. RevokedAt is empty for an active user.
|
||||||
|
type UserInfo struct {
|
||||||
|
SignPub string
|
||||||
|
Handle string
|
||||||
|
Role string
|
||||||
|
Status string
|
||||||
|
CreatedAt string
|
||||||
|
RevokedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns the full bus allowlist, including revoked users. The caller
|
||||||
|
// must be signing as an admin: a non-admin signer is rejected by the server with
|
||||||
|
// 403, surfaced here as an error.
|
||||||
|
func (c *Client) ListUsers() ([]UserInfo, error) {
|
||||||
|
var resp []userJSON
|
||||||
|
if err := c.doJSON("GET", "/users", nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]UserInfo, 0, len(resp))
|
||||||
|
for _, u := range resp {
|
||||||
|
out = append(out, UserInfo{
|
||||||
|
SignPub: u.SignPub,
|
||||||
|
Handle: u.Handle,
|
||||||
|
Role: u.Role,
|
||||||
|
Status: u.Status,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
RevokedAt: u.RevokedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddUser registers a bus user from their Ed25519 signing public key (64-hex).
|
||||||
|
// role is "admin" or "member" (empty defaults to member, matching the server).
|
||||||
|
// The caller must be signing as an admin. Re-adding an already-registered key
|
||||||
|
// returns an error (the server replies 409 and leaves the existing row
|
||||||
|
// untouched — no silent role/status change).
|
||||||
|
func (c *Client) AddUser(signPub, handle, role string) error {
|
||||||
|
return c.doJSON("POST", "/users", addUserReq{SignPub: signPub, Handle: handle, Role: role}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeUser revokes a bus user by their signing public key (64-hex). Revocation
|
||||||
|
// is a status flip (no hard delete): the identity stays auditable and is denied
|
||||||
|
// on both planes immediately. The caller must be signing as an admin.
|
||||||
|
func (c *Client) RevokeUser(signPub string) error {
|
||||||
|
return c.doJSON("POST", "/users/"+signPub+"/revoke", nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// newRoomKey returns 32 random bytes for a symmetric room key.
|
// newRoomKey returns 32 random bytes for a symmetric room key.
|
||||||
func newRoomKey() ([]byte, error) {
|
func newRoomKey() ([]byte, error) {
|
||||||
k := make([]byte, 32)
|
k := make([]byte, 32)
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package client_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/enmanuel/unibus/pkg/client"
|
||||||
|
"github.com/enmanuel/unibus/pkg/membership"
|
||||||
|
)
|
||||||
|
|
||||||
|
// findUserInfo returns the row with the given signing key (case-insensitive).
|
||||||
|
func findUserInfo(users []client.UserInfo, signPub string) (client.UserInfo, bool) {
|
||||||
|
want := strings.ToLower(signPub)
|
||||||
|
for _, u := range users {
|
||||||
|
if strings.ToLower(u.SignPub) == want {
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return client.UserInfo{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClientUsersAdminAPI drives the admin user-management API through the real
|
||||||
|
// pkg/client methods against an in-process membershipd under enforce: an admin
|
||||||
|
// client adds a user, lists it, revokes it, and sees the status flip — and a
|
||||||
|
// non-admin client is denied. This is the path the admin panel uses, so it locks
|
||||||
|
// the client/server contract the panel depends on.
|
||||||
|
func TestClientUsersAdminAPI(t *testing.T) {
|
||||||
|
h := newHarnessMode(t, membership.AuthEnforce)
|
||||||
|
waitHealth(t, h.ctrlURL)
|
||||||
|
|
||||||
|
admin, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect admin: %v", err)
|
||||||
|
}
|
||||||
|
defer admin.Close()
|
||||||
|
registerClient(t, h, admin, "admin", membership.RoleAdmin)
|
||||||
|
|
||||||
|
member, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect member: %v", err)
|
||||||
|
}
|
||||||
|
defer member.Close()
|
||||||
|
registerClient(t, h, member, "member", membership.RoleMember)
|
||||||
|
|
||||||
|
// A brand-new identity the admin will register over HTTP.
|
||||||
|
carol := mustIdentity(t)
|
||||||
|
carolPub := hex.EncodeToString(carol.SignPub)
|
||||||
|
|
||||||
|
// Admin adds carol as a member.
|
||||||
|
if err := admin.AddUser(carolPub, "carol", membership.RoleMember); err != nil {
|
||||||
|
t.Fatalf("admin AddUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin lists: carol present and active.
|
||||||
|
users, err := admin.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin ListUsers: %v", err)
|
||||||
|
}
|
||||||
|
row, ok := findUserInfo(users, carolPub)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("carol missing from list after add: %+v", users)
|
||||||
|
}
|
||||||
|
if row.Status != membership.StatusActive || row.Role != membership.RoleMember {
|
||||||
|
t.Fatalf("carol row wrong after add: %+v", row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-adding the same key is a conflict surfaced as an error (no silent upsert).
|
||||||
|
if err := admin.AddUser(carolPub, "carol-again", membership.RoleAdmin); err == nil {
|
||||||
|
t.Fatalf("re-adding carol should error (409), got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin revokes carol; list shows the status flip (no hard delete).
|
||||||
|
if err := admin.RevokeUser(carolPub); err != nil {
|
||||||
|
t.Fatalf("admin RevokeUser: %v", err)
|
||||||
|
}
|
||||||
|
users, err = admin.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin ListUsers after revoke: %v", err)
|
||||||
|
}
|
||||||
|
row, ok = findUserInfo(users, carolPub)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("carol vanished after revoke (should be a status flip): %+v", users)
|
||||||
|
}
|
||||||
|
if row.Status != membership.StatusRevoked {
|
||||||
|
t.Fatalf("carol should be revoked, got status %q", row.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-admin (member) is denied on every user-management method.
|
||||||
|
if _, err := member.ListUsers(); err == nil {
|
||||||
|
t.Fatalf("non-admin ListUsers should error (403), got nil")
|
||||||
|
}
|
||||||
|
if err := member.AddUser(carolPub, "x", membership.RoleMember); err == nil {
|
||||||
|
t.Fatalf("non-admin AddUser should error (403), got nil")
|
||||||
|
}
|
||||||
|
if err := member.RevokeUser(carolPub); err == nil {
|
||||||
|
t.Fatalf("non-admin RevokeUser should error (403), got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
+173
-7
@@ -213,9 +213,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusUnauthorized, "unauthorized: "+err.Error())
|
writeErr(w, http.StatusUnauthorized, "unauthorized: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Carry the authenticated signer's endpoint into the handler so room handlers
|
// Carry the authenticated signer's endpoint AND signing key into the handler.
|
||||||
// can authorize by membership (audit H3). Only set on a verified identity.
|
// Room handlers authorize by membership via the endpoint (audit H3); the
|
||||||
s.mux.ServeHTTP(w, r.WithContext(withSigner(r.Context(), res.endpoint)))
|
// user-management handlers authorize by role via the signing key (the endpoint
|
||||||
|
// id is a one-way hash of the key, so it cannot be reversed to look the signer
|
||||||
|
// up in the user allowlist). Both are set only on a verified identity.
|
||||||
|
s.mux.ServeHTTP(w, r.WithContext(withSigner(r.Context(), res.endpoint, res.pubHex)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// isBodyTooLarge reports whether err is the sentinel returned by MaxBytesReader
|
// isBodyTooLarge reports whether err is the sentinel returned by MaxBytesReader
|
||||||
@@ -229,11 +232,19 @@ func isBodyTooLarge(err error) bool {
|
|||||||
// values cannot collide with keys set by other packages.
|
// values cannot collide with keys set by other packages.
|
||||||
type ctxKey int
|
type ctxKey int
|
||||||
|
|
||||||
const ctxSignerEndpoint ctxKey = iota
|
const (
|
||||||
|
ctxSignerEndpoint ctxKey = iota
|
||||||
|
ctxSignerPub
|
||||||
|
)
|
||||||
|
|
||||||
// withSigner returns a context carrying the authenticated signer's endpoint id.
|
// withSigner returns a context carrying the authenticated signer's endpoint id
|
||||||
func withSigner(ctx context.Context, endpoint string) context.Context {
|
// and signing public key (lowercase hex). The endpoint authorizes room
|
||||||
return context.WithValue(ctx, ctxSignerEndpoint, endpoint)
|
// membership; the signing key authorizes user-management by role, because the
|
||||||
|
// endpoint id is a one-way hash of the key (base64url(sha256(signPub))) and so
|
||||||
|
// cannot be reversed to look the signer up in the user allowlist.
|
||||||
|
func withSigner(ctx context.Context, endpoint, pubHex string) context.Context {
|
||||||
|
ctx = context.WithValue(ctx, ctxSignerEndpoint, endpoint)
|
||||||
|
return context.WithValue(ctx, ctxSignerPub, pubHex)
|
||||||
}
|
}
|
||||||
|
|
||||||
// signerEndpoint returns the authenticated signer's endpoint id and whether one
|
// signerEndpoint returns the authenticated signer's endpoint id and whether one
|
||||||
@@ -245,6 +256,16 @@ func signerEndpoint(r *http.Request) (string, bool) {
|
|||||||
return v, ok && v != ""
|
return v, ok && v != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// signerPubHex returns the authenticated signer's signing public key (lowercase
|
||||||
|
// hex) and whether one is present. Like signerEndpoint it is absent under
|
||||||
|
// AuthOff and on a soft-mode pass-through; the user-management handlers treat
|
||||||
|
// that absence as "no admin identity" and deny (default-deny), since a
|
||||||
|
// privilege-granting operation must never run without a verified admin.
|
||||||
|
func signerPubHex(r *http.Request) (string, bool) {
|
||||||
|
v, ok := r.Context().Value(ctxSignerPub).(string)
|
||||||
|
return v, ok && v != ""
|
||||||
|
}
|
||||||
|
|
||||||
// requireMember authorizes a room request by membership (audit H3): it returns
|
// requireMember authorizes a room request by membership (audit H3): it returns
|
||||||
// the signer endpoint and true when the request may proceed, or writes 403 and
|
// the signer endpoint and true when the request may proceed, or writes 403 and
|
||||||
// returns false when an authenticated signer is not a member of roomID. When no
|
// returns false when an authenticated signer is not a member of roomID. When no
|
||||||
@@ -262,6 +283,31 @@ func (s *Server) requireMember(w http.ResponseWriter, r *http.Request, roomID st
|
|||||||
return signer, true
|
return signer, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireAdmin authorizes a user-management request: it returns the signer's
|
||||||
|
// signing-key hex and true ONLY when the authenticated signer is a user with
|
||||||
|
// role admin and active status; otherwise it writes 403 and returns false.
|
||||||
|
//
|
||||||
|
// Default-deny, with no dev relaxation: unlike requireMember (which allows a
|
||||||
|
// request when no authenticated signer is present, preserving AuthOff/dev
|
||||||
|
// behavior for room reads), this denies whenever the signer is absent or is not
|
||||||
|
// a verified active admin. The user-management endpoints grant and revoke bus
|
||||||
|
// access, so they must never be reachable without a verified admin identity —
|
||||||
|
// the store is consulted on every call so a just-revoked admin is denied
|
||||||
|
// immediately, and any store error fails closed.
|
||||||
|
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||||
|
pubHex, ok := signerPubHex(r)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusForbidden, "forbidden: admin role required")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
u, err := s.store.GetUser(pubHex)
|
||||||
|
if err != nil || u.Role != RoleAdmin || u.Status != StatusActive {
|
||||||
|
writeErr(w, http.StatusForbidden, "forbidden: admin role required")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return pubHex, true
|
||||||
|
}
|
||||||
|
|
||||||
// isAuthExempt lists requests that bypass control-plane auth even under enforce.
|
// isAuthExempt lists requests that bypass control-plane auth even under enforce.
|
||||||
// Only the unauthenticated health probe qualifies: it carries no data and is
|
// Only the unauthenticated health probe qualifies: it carries no data and is
|
||||||
// needed by load balancers / smoke checks / systemd before any identity exists.
|
// needed by load balancers / smoke checks / systemd before any identity exists.
|
||||||
@@ -280,6 +326,13 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("GET /rooms/{id}", s.handleGetRoom)
|
s.mux.HandleFunc("GET /rooms/{id}", s.handleGetRoom)
|
||||||
s.mux.HandleFunc("POST /blobs", s.handlePutBlob)
|
s.mux.HandleFunc("POST /blobs", s.handlePutBlob)
|
||||||
s.mux.HandleFunc("GET /blobs/{hash}", s.handleGetBlob)
|
s.mux.HandleFunc("GET /blobs/{hash}", s.handleGetBlob)
|
||||||
|
// User-management (admin-only) — the HTTP-signed equivalent of the local
|
||||||
|
// `membershipd user` CLI, so the admin panel manages the bus allowlist by
|
||||||
|
// signing as an admin instead of needing direct store/KV access. All three
|
||||||
|
// pass through requireAdmin; they hit the same store the room handlers do.
|
||||||
|
s.mux.HandleFunc("GET /users", s.handleListUsers)
|
||||||
|
s.mux.HandleFunc("POST /users", s.handleAddUser)
|
||||||
|
s.mux.HandleFunc("POST /users/{signpub}/revoke", s.handleRevokeUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- wire types -----------------------------------------------------------
|
// ---- wire types -----------------------------------------------------------
|
||||||
@@ -357,6 +410,27 @@ type blobResp struct {
|
|||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userJSON is the wire representation of a bus user on the admin endpoints. It
|
||||||
|
// carries the full record the panel needs to render the allowlist, including
|
||||||
|
// status (so revoked users are visible) and the timestamps. revoked_at is
|
||||||
|
// omitted for an active user.
|
||||||
|
type userJSON struct {
|
||||||
|
SignPub string `json:"sign_pub"`
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
RevokedAt string `json:"revoked_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// addUserReq is the POST /users body: the new user's Ed25519 signing key
|
||||||
|
// (64-hex), human handle, and role. role is optional and defaults to member.
|
||||||
|
type addUserReq struct {
|
||||||
|
SignPub string `json:"sign_pub"`
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
// ---- helpers --------------------------------------------------------------
|
// ---- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
@@ -674,3 +748,95 @@ func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write(data)
|
_, _ = w.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- user-management handlers (admin-only) --------------------------------
|
||||||
|
|
||||||
|
// handleListUsers returns the full bus allowlist, including revoked users, so an
|
||||||
|
// admin sees the complete picture (a revoked identity stays auditable). Admin-only.
|
||||||
|
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := s.requireAdmin(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, err := s.store.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]userJSON, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
out = append(out, userJSON{
|
||||||
|
SignPub: u.SignPub,
|
||||||
|
Handle: u.Handle,
|
||||||
|
Role: u.Role,
|
||||||
|
Status: u.Status,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
RevokedAt: u.RevokedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAddUser registers a new bus user from an admin-supplied Ed25519 signing
|
||||||
|
// key. It mirrors the `membershipd user add` CLI: the key must be 64-hex, the
|
||||||
|
// role must be admin or member (empty defaults to member), and re-adding an
|
||||||
|
// already-registered key is a 409 that leaves the existing row untouched — no
|
||||||
|
// silent upsert that could flip a role or clobber status. Admin-only.
|
||||||
|
func (s *Server) handleAddUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := s.requireAdmin(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req addUserReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.SignPub == "" || req.Handle == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "sign_pub and handle required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ValidateSignPubHex(req.SignPub); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
role := req.Role
|
||||||
|
if role == "" {
|
||||||
|
role = RoleMember
|
||||||
|
}
|
||||||
|
if role != RoleAdmin && role != RoleMember {
|
||||||
|
writeErr(w, http.StatusBadRequest,
|
||||||
|
fmt.Sprintf("invalid role %q (want %q or %q)", role, RoleAdmin, RoleMember))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.AddUser(req.SignPub, req.Handle, role); err != nil {
|
||||||
|
if errors.Is(err, ErrUserExists) {
|
||||||
|
// Idempotency contract (mirrors the CLI): re-adding a key is an explicit,
|
||||||
|
// non-destructive conflict. To replace a user, revoke then add again.
|
||||||
|
writeErr(w, http.StatusConflict,
|
||||||
|
"user already registered (unchanged); revoke it first to replace")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]string{"status": "added"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRevokeUser revokes a bus user by signing key. Revocation is a status
|
||||||
|
// flip (no hard delete) so the identity stays auditable and IsAuthorized denies
|
||||||
|
// it on both planes immediately. Revoking an unknown or already-revoked key is a
|
||||||
|
// 404. Admin-only.
|
||||||
|
func (s *Server) handleRevokeUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := s.requireAdmin(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signPub := r.PathValue("signpub")
|
||||||
|
if err := ValidateSignPubHex(signPub); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.RevokeUser(signPub); err != nil {
|
||||||
|
writeServerErr(w, r, http.StatusNotFound, "no active user with that key", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package membership
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -35,6 +36,23 @@ type User struct {
|
|||||||
RevokedAt string // empty unless revoked
|
RevokedAt string // empty unless revoked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateSignPubHex ensures signPub is exactly a 32-byte Ed25519 public key in
|
||||||
|
// hex (64 hex chars). It is the single source of truth for that check, shared by
|
||||||
|
// the local admin CLI (which validates before seeding the first admin) and the
|
||||||
|
// HTTP user-management handlers (which validate an admin-supplied key before it
|
||||||
|
// reaches the store). Catching a malformed key here turns a silent "authorized
|
||||||
|
// nobody" into an explicit error at the boundary.
|
||||||
|
func ValidateSignPubHex(signPub string) error {
|
||||||
|
b, err := hex.DecodeString(signPub)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sign-pub is not valid hex: %w", err)
|
||||||
|
}
|
||||||
|
if len(b) != 32 {
|
||||||
|
return fmt.Errorf("sign-pub must be a 32-byte Ed25519 public key (64 hex chars), got %d bytes", len(b))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// normalizeSignPub lowercases the hex key so lookups are case-insensitive: the
|
// normalizeSignPub lowercases the hex key so lookups are case-insensitive: the
|
||||||
// primary key is stored lowercase and every query normalizes its input the same
|
// primary key is stored lowercase and every query normalizes its input the same
|
||||||
// way, so a caller passing uppercase hex still matches.
|
// way, so a caller passing uppercase hex still matches.
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package membership
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cs "fn-registry/functions/cybersecurity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// signedJSON is signedReq for a JSON body: it marshals v and signs the request
|
||||||
|
// as id with a distinct nonce. It returns the response status and body, reusing
|
||||||
|
// the auth_test harness so these tests exercise the real signed wire contract.
|
||||||
|
func signedJSON(t *testing.T, h *authHarness, method, path string, v any, id cs.Identity, n int) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
var body []byte
|
||||||
|
if v != nil {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal body: %v", err)
|
||||||
|
}
|
||||||
|
body = b
|
||||||
|
}
|
||||||
|
return do(t, signedReq(t, h.ts.URL, method, path, body, id, time.Now().Unix(), nonceN(n)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUsersHTTP_NonAdminForbidden is the security spine: a REGISTERED but
|
||||||
|
// non-admin signer (bob, role member) is denied on every user-management
|
||||||
|
// endpoint. His signature clears auth (he is in the allowlist), so each request
|
||||||
|
// reaches the handler, where requireAdmin returns 403 — default-deny by role.
|
||||||
|
func TestUsersHTTP_NonAdminForbidden(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
|
|
||||||
|
bob, _ := cs.GenerateIdentity()
|
||||||
|
register(t, h, bob, "bob") // role member (see register in authz_test.go)
|
||||||
|
bobPub := hex.EncodeToString(bob.SignPub)
|
||||||
|
|
||||||
|
victim, _ := cs.GenerateIdentity()
|
||||||
|
victimPub := hex.EncodeToString(victim.SignPub)
|
||||||
|
|
||||||
|
checks := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body any
|
||||||
|
}{
|
||||||
|
{"list users", "GET", "/users", nil},
|
||||||
|
{"add user", "POST", "/users", addUserReq{SignPub: victimPub, Handle: "mallory", Role: RoleMember}},
|
||||||
|
{"revoke user", "POST", "/users/" + bobPub + "/revoke", nil},
|
||||||
|
}
|
||||||
|
for i, c := range checks {
|
||||||
|
code, body := signedJSON(t, h, c.method, c.path, c.body, bob, i+1)
|
||||||
|
if code != http.StatusForbidden {
|
||||||
|
t.Fatalf("non-admin %s should be 403, got %d (%s)", c.name, code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUsersHTTP_AdminRoundtrip exercises the golden path end to end: alice (the
|
||||||
|
// seeded admin) adds carol, sees her in the list as active, revokes her, then
|
||||||
|
// sees her status flip to revoked (no hard delete — she stays in the list).
|
||||||
|
func TestUsersHTTP_AdminRoundtrip(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
|
|
||||||
|
carol, _ := cs.GenerateIdentity()
|
||||||
|
carolPub := hex.EncodeToString(carol.SignPub)
|
||||||
|
|
||||||
|
// Add carol as a member.
|
||||||
|
if code, body := signedJSON(t, h, "POST", "/users",
|
||||||
|
addUserReq{SignPub: carolPub, Handle: "carol", Role: RoleMember}, h.alice, 1); code != http.StatusCreated {
|
||||||
|
t.Fatalf("admin add carol should be 201, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List: carol present and active; alice (the seed admin) also present.
|
||||||
|
users := listUsers(t, h, 2)
|
||||||
|
carolRow, ok := findUser(users, carolPub)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("carol missing from list after add: %+v", users)
|
||||||
|
}
|
||||||
|
if carolRow.Status != StatusActive || carolRow.Role != RoleMember || carolRow.Handle != "carol" {
|
||||||
|
t.Fatalf("carol row wrong after add: %+v", carolRow)
|
||||||
|
}
|
||||||
|
if _, ok := findUser(users, h.alicePub); !ok {
|
||||||
|
t.Fatalf("seeded admin alice missing from list: %+v", users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke carol.
|
||||||
|
if code, body := signedJSON(t, h, "POST", "/users/"+carolPub+"/revoke", nil, h.alice, 3); code != http.StatusOK {
|
||||||
|
t.Fatalf("admin revoke carol should be 200, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List again: carol still present, now revoked (status flip, not delete).
|
||||||
|
users = listUsers(t, h, 4)
|
||||||
|
carolRow, ok = findUser(users, carolPub)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("carol vanished from list after revoke (should be a status flip): %+v", users)
|
||||||
|
}
|
||||||
|
if carolRow.Status != StatusRevoked {
|
||||||
|
t.Fatalf("carol should be revoked, got status %q", carolRow.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUsersHTTP_Validation covers the input-validation contract: a malformed hex
|
||||||
|
// key is 400, an unknown role is 400, and re-adding an already-registered key is
|
||||||
|
// 409 (the existing row is left untouched — no silent upsert).
|
||||||
|
func TestUsersHTTP_Validation(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
|
|
||||||
|
good, _ := cs.GenerateIdentity()
|
||||||
|
goodPub := hex.EncodeToString(good.SignPub)
|
||||||
|
|
||||||
|
// Invalid hex (too short) -> 400.
|
||||||
|
if code, body := signedJSON(t, h, "POST", "/users",
|
||||||
|
addUserReq{SignPub: "abcd", Handle: "shorty", Role: RoleMember}, h.alice, 1); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("malformed sign_pub should be 400, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid role -> 400.
|
||||||
|
if code, body := signedJSON(t, h, "POST", "/users",
|
||||||
|
addUserReq{SignPub: goodPub, Handle: "weirdrole", Role: "superuser"}, h.alice, 2); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid role should be 400, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-adding the seeded admin's own key -> 409 (idempotency, no overwrite).
|
||||||
|
if code, body := signedJSON(t, h, "POST", "/users",
|
||||||
|
addUserReq{SignPub: h.alicePub, Handle: "alice-again", Role: RoleMember}, h.alice, 3); code != http.StatusConflict {
|
||||||
|
t.Fatalf("re-adding an existing key should be 409, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
// And the existing row is untouched: alice is still an active admin.
|
||||||
|
u, err := h.store.GetUser(h.alicePub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get alice after conflicting re-add: %v", err)
|
||||||
|
}
|
||||||
|
if u.Role != RoleAdmin || u.Status != StatusActive || u.Handle != "alice" {
|
||||||
|
t.Fatalf("conflicting re-add mutated the existing row: %+v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listUsers signs a GET /users as alice and decodes the response.
|
||||||
|
func listUsers(t *testing.T, h *authHarness, n int) []userJSON {
|
||||||
|
t.Helper()
|
||||||
|
code, body := signedJSON(t, h, "GET", "/users", nil, h.alice, n)
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("admin list users should be 200, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
var users []userJSON
|
||||||
|
if err := json.Unmarshal([]byte(body), &users); err != nil {
|
||||||
|
t.Fatalf("decode users: %v (%s)", err, body)
|
||||||
|
}
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
|
||||||
|
// findUser returns the row with the given signing key (case-insensitive).
|
||||||
|
func findUser(users []userJSON, signPub string) (userJSON, bool) {
|
||||||
|
want := normalizeSignPub(signPub)
|
||||||
|
for _, u := range users {
|
||||||
|
if normalizeSignPub(u.SignPub) == want {
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return userJSON{}, false
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user