Compare commits
3 Commits
93acc059f1
...
1b19f8e60f
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b19f8e60f | |||
| c7631074cb | |||
| c412941e4c |
@@ -58,9 +58,10 @@ navegador (SPA Mantine)
|
||||
▼
|
||||
unibus_admin (gateway Go, identidad ADMIN del operador)
|
||||
├── pkg/client (unibus) → CreateRoom / Invite / Kick / ListMyRooms (firma + cripto E2E)
|
||||
├── pkg/client (unibus) → ListUsers / AddUser / RevokeUser (API admin firmada; funciona en cluster)
|
||||
├── GET firmado → /rooms/{id}/members (CanonicalRequest + SignEd25519, reusa la construcción del bus)
|
||||
├── GET /healthz (CA-pinned)→ estado + posture de los 3 nodos del cluster
|
||||
└── membership.Store (opc.) → users (allowlist) cuando hay acceso directo al store
|
||||
└── membership.Store (opc.) → users (allowlist) como fallback single-node con --db
|
||||
▼
|
||||
cluster unibus (magnus + homer + datardos, enforce + ACL + TLS + KV)
|
||||
```
|
||||
@@ -77,7 +78,7 @@ no expone. Nunca reimplementa firma ni cripto.
|
||||
|---|---|---|
|
||||
| **Cluster** | up/down + posture (enforce/acl/tls/cluster/store) + latencia de cada nodo | `GET /healthz` (auth-exempt) de los nodos en `--nodes`, TLS pin a la CA del bus |
|
||||
| **Rooms** | listar (rooms del admin), crear (subject + E2E/persist/firmado), ver miembros, invitar, expulsar+rekey | `pkg/client` (mutaciones) + GET firmado (miembros) |
|
||||
| **Users** | listar/añadir/revocar la allowlist del bus | `membership.Store` directo — sólo con `--db` (single-node) o acceso KV admin |
|
||||
| **Users** | listar/añadir/revocar la allowlist del bus | `pkg/client` (`ListUsers`/`AddUser`/`RevokeUser`) contra la API admin-only del plano de control, firmando como el operador. Funciona en cluster (los nodos escriben al mismo store que las rooms) sin acceso directo al store. `--db` queda como fallback single-node opcional |
|
||||
|
||||
## Cómo arrancar
|
||||
|
||||
@@ -85,13 +86,14 @@ no expone. Nunca reimplementa firma ni cripto.
|
||||
# Mock (iterar la SPA sin bus):
|
||||
./unibus_admin --mock --port 8480
|
||||
|
||||
# Real contra un membershipd local (dev, sqlite, sin TLS):
|
||||
# Real contra un membershipd local (dev, sin TLS). Users vía la API del plano de
|
||||
# control; añade --db sólo si quieres gestionar users contra un SQLite local:
|
||||
./unibus_admin --port 8480 \
|
||||
--ctrl-url http://127.0.0.1:8470 --nats-url nats://127.0.0.1:4250 \
|
||||
--db ./local_files/unibus.db \
|
||||
--identity-pass unibus/operator-identity
|
||||
|
||||
# Producción (cluster magnus, enforce + TLS + nkey):
|
||||
# Producción (cluster magnus, enforce + TLS + nkey). Sin --db: la pestaña Users
|
||||
# gestiona la allowlist por la API admin firmada del plano de control:
|
||||
./unibus_admin --port 8480 --bind 127.0.0.1 \
|
||||
--ctrl-url https://127.0.0.1:8470 --nats-url tls://127.0.0.1:4250 \
|
||||
--ca /opt/unibus/tls/ca.crt \
|
||||
@@ -127,11 +129,17 @@ ofuscado (`admin-<hash>.organic-machine.com`). Credenciales en `pass`
|
||||
|
||||
## Gaps conocidos
|
||||
|
||||
- **Users en el cluster (KV)**: el plano de control no expone endpoint HTTP de users
|
||||
— viven sólo en el store. Con el cluster en `--store kv`, el gateway no abre el KV
|
||||
todavía, así que la pestaña Users queda degradada (estado informativo). Se habilita
|
||||
con la vía de alta KV que añade la rama `quick/0011-deploy-gaps` del repo unibus, o
|
||||
con `--db` en single-node.
|
||||
- **Users contra el cluster desplegado**: el código del plano de control (unibus
|
||||
master, v0.10.0) ya expone la API admin-only de users (`GET/POST /users`,
|
||||
`POST /users/{signpub}/revoke`) y el gateway la consume firmando como el operador.
|
||||
La cadena completa (list/add/revoke + idempotencia 409) está verificada end-to-end
|
||||
contra un `membershipd` master local. El gap restante es de **despliegue**: los
|
||||
binarios `membershipd` que corren hoy en el cluster (magnus/homer/datardos) son
|
||||
anteriores al merge de esta ruta y devuelven `404` en `/users`, así que la pestaña
|
||||
Users sólo será funcional en producción cuando el bus se actualice a v0.10.0. El
|
||||
gateway ya conecta y firma correctamente contra esos nodos (verificado: `/api/me`
|
||||
responde con el endpoint real del operador y pasa el `enforce` de magnus). Para
|
||||
gestión single-node sin esperar al cluster, `--db` sigue disponible.
|
||||
- **meta-leader / tamaño de quórum** del cluster: `/healthz` no los expone; requieren
|
||||
el endpoint de monitoreo de NATS (varz/jsz). La pestaña Cluster muestra up/posture.
|
||||
- **Invite a room E2E**: requiere las claves públicas (sign_pub + kex_pub) del
|
||||
|
||||
+7
-13
@@ -7,17 +7,8 @@ package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrUsersUnavailable is returned by the users operations when the gateway was
|
||||
// started without a membership store (no --db / no KV access). The bus control
|
||||
// plane exposes no user-management HTTP endpoint — users live only in the store
|
||||
// — so the Users tab is read-and-write only when the gateway can reach that
|
||||
// store directly. Without it the tab degrades to an explanatory empty state
|
||||
// rather than failing opaquely.
|
||||
var ErrUsersUnavailable = errors.New("admin: user management requires direct store access (start with --db or a KV-backed store)")
|
||||
|
||||
// Posture is the security posture a membershipd node publishes on /healthz. It
|
||||
// mirrors membership.Posture but is duplicated here so the wire shape the SPA
|
||||
// consumes is owned by the gateway, not coupled to the bus package's struct tags.
|
||||
@@ -94,11 +85,11 @@ type AddUserReq struct {
|
||||
}
|
||||
|
||||
// MeInfo describes the gateway's own identity and which capabilities are wired,
|
||||
// so the SPA can render the operator endpoint and gate the Users tab.
|
||||
// so the SPA can render the operator endpoint and label the Users tab's backend.
|
||||
type MeInfo struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
SignPub string `json:"sign_pub"`
|
||||
UsersBackend string `json:"users_backend"` // "sqlite" | "kv" | "none"
|
||||
UsersBackend string `json:"users_backend"` // "control-plane" (signed HTTP) | "sqlite" (single-node fallback)
|
||||
Mock bool `json:"mock"`
|
||||
}
|
||||
|
||||
@@ -121,8 +112,11 @@ type Repo interface {
|
||||
// (forward secrecy). This is the rekey-on-kick primitive the bus exposes.
|
||||
KickMember(ctx context.Context, roomID, endpoint string) error
|
||||
|
||||
// Users (the bus allowlist). Available only with direct store access;
|
||||
// otherwise these return ErrUsersUnavailable.
|
||||
// Users (the bus allowlist). The live gateway manages these against the bus
|
||||
// control plane's admin-only user endpoints, signing each request as the
|
||||
// operator's admin identity — so user management works in cluster without
|
||||
// direct store/KV access. A single-node deployment may instead point the
|
||||
// gateway at the SQLite store directly (--db) as an explicit fallback.
|
||||
UsersWritable() bool
|
||||
ListUsers(ctx context.Context) ([]UserView, error)
|
||||
AddUser(ctx context.Context, req AddUserReq) error
|
||||
|
||||
@@ -40,8 +40,11 @@ type busRepo struct {
|
||||
cli *client.Client
|
||||
nodes []NodeTarget
|
||||
|
||||
store membership.Store // optional; nil => Users tab degraded
|
||||
storeBackend string // "sqlite" | "kv" | "none"
|
||||
// store is an OPTIONAL direct membership store for single-node user
|
||||
// management. When nil (the cluster default), user operations go through the
|
||||
// signed control-plane API on r.cli instead — see ListUsers/AddUser/RevokeUser.
|
||||
store membership.Store
|
||||
storeBackend string // "control-plane" (cli) | "sqlite" (direct store fallback)
|
||||
}
|
||||
|
||||
// BusConfig wires a live gateway.
|
||||
@@ -90,9 +93,11 @@ func NewBusRepo(cfg BusConfig) (*busRepo, error) {
|
||||
}
|
||||
|
||||
ctrlURLs := append([]string{cfg.CtrlURL}, cfg.CtrlURLs...)
|
||||
backend := cfg.StoreBackend
|
||||
if cfg.Store == nil {
|
||||
backend = "none"
|
||||
// With no direct store, user management rides the signed control-plane API
|
||||
// (works in cluster). A direct store is an explicit single-node fallback.
|
||||
backend := "control-plane"
|
||||
if cfg.Store != nil {
|
||||
backend = cfg.StoreBackend
|
||||
}
|
||||
return &busRepo{
|
||||
id: cfg.Identity,
|
||||
@@ -326,12 +331,32 @@ func (r *busRepo) signedGET(path string) ([]byte, error) {
|
||||
}
|
||||
|
||||
// ---- users ----------------------------------------------------------------
|
||||
//
|
||||
// User management has two backends. The cluster default has no direct store
|
||||
// (r.store == nil): every operation goes through the unibus client's admin-only
|
||||
// HTTP endpoints (GET/POST /users, POST /users/{signpub}/revoke), each request
|
||||
// signed as the operator's admin identity and verified by the bus's requireAdmin
|
||||
// against the same store the room handlers use — so it works in cluster without
|
||||
// KV/SQLite access. A single-node deployment may instead pass --db to manage the
|
||||
// SQLite store directly; that path is kept as an explicit fallback.
|
||||
|
||||
func (r *busRepo) UsersWritable() bool { return r.store != nil }
|
||||
// UsersWritable reports whether the Users tab can mutate the allowlist. The live
|
||||
// gateway always can: either it holds a direct store, or it signs as an admin
|
||||
// against the control plane. (A non-admin signer is rejected at request time by
|
||||
// the bus with 403; that is an authorization outcome, not a missing capability.)
|
||||
func (r *busRepo) UsersWritable() bool { return true }
|
||||
|
||||
func (r *busRepo) ListUsers(context.Context) ([]UserView, error) {
|
||||
if r.store == nil {
|
||||
return nil, ErrUsersUnavailable
|
||||
users, err := r.cli.ListUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]UserView, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, UserView(u))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
users, err := r.store.ListUsers()
|
||||
if err != nil {
|
||||
@@ -353,14 +378,14 @@ func (r *busRepo) ListUsers(context.Context) ([]UserView, error) {
|
||||
|
||||
func (r *busRepo) AddUser(_ context.Context, req AddUserReq) error {
|
||||
if r.store == nil {
|
||||
return ErrUsersUnavailable
|
||||
return r.cli.AddUser(req.SignPub, req.Handle, req.Role)
|
||||
}
|
||||
return r.store.AddUser(req.SignPub, req.Handle, req.Role)
|
||||
}
|
||||
|
||||
func (r *busRepo) RevokeUser(_ context.Context, signPub string) error {
|
||||
if r.store == nil {
|
||||
return ErrUsersUnavailable
|
||||
return r.cli.RevokeUser(signPub)
|
||||
}
|
||||
return r.store.RevokeUser(signPub)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package admin
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -140,10 +139,6 @@ func (s *Server) handleKick(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := s.repo.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUsersUnavailable) {
|
||||
writeErr(w, http.StatusServiceUnavailable, err.Error())
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -160,11 +155,7 @@ func (s *Server) handleAddUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.repo.AddUser(r.Context(), req); err != nil {
|
||||
code := http.StatusBadGateway
|
||||
if errors.Is(err, ErrUsersUnavailable) {
|
||||
code = http.StatusServiceUnavailable
|
||||
}
|
||||
writeErr(w, code, err.Error())
|
||||
writeErr(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "added"})
|
||||
@@ -182,11 +173,7 @@ func (s *Server) handleRevokeUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.repo.RevokeUser(r.Context(), req.SignPub); err != nil {
|
||||
code := http.StatusBadGateway
|
||||
if errors.Is(err, ErrUsersUnavailable) {
|
||||
code = http.StatusServiceUnavailable
|
||||
}
|
||||
writeErr(w, code, err.Error())
|
||||
writeErr(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
|
||||
@@ -36,7 +36,7 @@ func main() {
|
||||
nodesCSV = flag.String("nodes", "", "cluster nodes to probe for /healthz as name=url,name=url (default: derive one from --ctrl-url)")
|
||||
identityFile = flag.String("identity-file", "", "path to the admin identity JSON file (0600). Mutually exclusive with --identity-pass")
|
||||
identityPass = flag.String("identity-pass", "", "pass(1) entry holding the admin identity JSON, e.g. unibus/operator-identity")
|
||||
dbPath = flag.String("db", "", "membership SQLite path for the Users tab (single-node/dev). Empty = Users tab read-only-unavailable unless --mock")
|
||||
dbPath = flag.String("db", "", "OPTIONAL membership SQLite path for single-node user management. Empty (default) = manage users via the signed control-plane API, which works in cluster")
|
||||
mock = flag.Bool("mock", false, "serve sample data instead of talking to the bus (UI iteration)")
|
||||
)
|
||||
flag.Parse()
|
||||
@@ -59,7 +59,7 @@ func main() {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
var store membership.Store
|
||||
backend := "none"
|
||||
backend := "control-plane"
|
||||
if *dbPath != "" {
|
||||
store, err = membership.Open(*dbPath)
|
||||
if err != nil {
|
||||
@@ -67,9 +67,9 @@ func main() {
|
||||
}
|
||||
defer store.Close()
|
||||
backend = "sqlite"
|
||||
log.Printf("users backend: sqlite %s", *dbPath)
|
||||
log.Printf("users backend: sqlite %s (single-node direct store)", *dbPath)
|
||||
} else {
|
||||
log.Printf("users backend: none (Users tab degraded; pass --db for single-node user management)")
|
||||
log.Printf("users backend: control-plane (signed admin HTTP to the bus; works in cluster)")
|
||||
}
|
||||
|
||||
nodes := parseNodes(*nodesCSV, *ctrlURL)
|
||||
|
||||
Vendored
+158
File diff suppressed because one or more lines are too long
Vendored
-163
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>unibus · admin</title>
|
||||
<script type="module" crossorigin src="/assets/index-D7Qf15Sh.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CGRScjCy.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ndvieWwa.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+10
-18
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconPlus, IconRefresh, IconUserOff, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { IconPlus, IconRefresh, IconUserOff } from "@tabler/icons-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import type { UserView } from "../types";
|
||||
import { fmtTime, trunc } from "../util";
|
||||
@@ -32,7 +31,6 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [addOpen, addCtl] = useDisclosure(false);
|
||||
const writable = usersBackend !== "none";
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -62,9 +60,11 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
|
||||
<Group gap="sm">
|
||||
<Title order={3}>Users</Title>
|
||||
{users && <Badge color="brand" variant="light">{users.length}</Badge>}
|
||||
<Badge variant="outline" color={writable ? "teal" : "gray"} style={{ textTransform: "none" }}>
|
||||
store: {usersBackend}
|
||||
</Badge>
|
||||
<Tooltip label="Vía de gestión de la allowlist del bus">
|
||||
<Badge variant="outline" color="teal" style={{ textTransform: "none" }}>
|
||||
backend: {usersBackend}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Tooltip label="Refrescar">
|
||||
@@ -72,22 +72,14 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
|
||||
<IconRefresh size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={addCtl.open} disabled={!writable}>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={addCtl.open}>
|
||||
Añadir user
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!writable && (
|
||||
<Alert icon={<IconInfoCircle size={18} />} color="yellow" variant="light" title="Gestión de users no disponible">
|
||||
El plano de control no expone endpoint de users; viven solo en el store. Arranca el gateway con <code>--db</code>
|
||||
(single-node) o con acceso KV admin del cluster para listar/dar de alta/revocar. Coordinar con la vía KV que
|
||||
añade <code>quick/0011-deploy-gaps</code>.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{err && writable && <Text c="red">{err}</Text>}
|
||||
{!users && !err && writable && <Loader color="brand" />}
|
||||
{err && <Text c="red">{err}</Text>}
|
||||
{!users && !err && <Loader color="brand" />}
|
||||
|
||||
{users && (
|
||||
<Card withBorder bg="dark.7" p={0} radius="md">
|
||||
@@ -117,7 +109,7 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
|
||||
</Table.Td>
|
||||
<Table.Td><Text size="xs" c="dimmed">{fmtTime(u.created_at)}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
{writable && u.status === "active" && (
|
||||
{u.status === "active" && (
|
||||
<Tooltip label="Revocar acceso">
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => revoke(u)}>
|
||||
<IconUserOff size={16} />
|
||||
|
||||
Reference in New Issue
Block a user