Files
unibus_admin/internal/admin/repo.go
T
egutierrez c412941e4c feat: route Users management through the signed control-plane API
The gateway previously managed the bus allowlist only via a direct
membership store opened with --db, falling back to a "none" backend that
left the Users tab degraded in cluster (the control plane exposed no user
HTTP endpoint). The unibus control plane now exposes an admin-only user
API (GET/POST /users, POST /users/{signpub}/revoke), and pkg/client wraps
it with ListUsers/AddUser/RevokeUser that sign each request.

busRepo now drives those client methods whenever no direct store is
configured (the cluster default), so user management works in cluster
without KV/SQLite access — the bus verifies the operator's admin identity
with requireAdmin and writes to the same store the room handlers use. A
direct store (--db) is kept as an explicit single-node fallback. The
reported users_backend becomes "control-plane" (or "sqlite" with --db),
and ErrUsersUnavailable / the "none" path are removed since a connected
gateway can always reach the API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:10:27 +02:00

125 lines
4.7 KiB
Go

// Package admin is the gateway behind the unibus admin panel: it holds the
// operator's ADMIN identity, talks to the unibus control plane (signing every
// request), and exposes a small REST API the embedded SPA consumes. The browser
// never signs, never touches NATS, and never sees a private key — every
// privileged action is mediated here.
package admin
import (
"context"
)
// 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.
type Posture struct {
Enforce bool `json:"enforce"`
ACL bool `json:"acl"`
TLS bool `json:"tls"`
Cluster bool `json:"cluster"`
Store string `json:"store"`
}
// NodeHealth is one cluster node's liveness + posture as seen from the gateway.
type NodeHealth struct {
Name string `json:"name"`
URL string `json:"url"`
Up bool `json:"up"`
Posture Posture `json:"posture"`
LatencyMs int64 `json:"latency_ms"`
Error string `json:"error,omitempty"`
}
// RoomView is a room as the admin sees it (a room the admin owns or belongs to).
type RoomView struct {
RoomID string `json:"room_id"`
Subject string `json:"subject"`
Epoch int `json:"epoch"`
Encrypt bool `json:"encrypt"`
Persist bool `json:"persist"`
SignMsgs bool `json:"sign_msgs"`
Role string `json:"role"`
}
// MemberView is one member of a room with public keys rendered as hex (the
// browser never needs the raw bytes).
type MemberView struct {
Endpoint string `json:"endpoint"`
Role string `json:"role"`
SignPub string `json:"sign_pub"`
KexPub string `json:"kex_pub"`
}
// UserView is one bus allowlist entry.
type UserView 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"`
}
// CreateRoomReq is the room-creation payload from the SPA.
type CreateRoomReq struct {
Subject string `json:"subject"`
Encrypt bool `json:"encrypt"`
Persist bool `json:"persist"`
SignMsgs bool `json:"sign_msgs"`
}
// InviteReq is the invite payload. The invitee's public keys are supplied as hex
// because an encrypted room seals the room key to the invitee's X25519 key, and
// that key is not derivable from the endpoint id alone.
type InviteReq struct {
Endpoint string `json:"endpoint"`
SignPub string `json:"sign_pub"`
KexPub string `json:"kex_pub"`
}
// AddUserReq is the user-registration payload.
type AddUserReq struct {
SignPub string `json:"sign_pub"`
Handle string `json:"handle"`
Role string `json:"role"`
}
// MeInfo describes the gateway's own identity and which capabilities are wired,
// 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"` // "control-plane" (signed HTTP) | "sqlite" (single-node fallback)
Mock bool `json:"mock"`
}
// Repo is the data source behind the REST API. Two implementations exist:
// busRepo (the real control-plane + store gateway) and mockRepo (sample data for
// UI iteration). Keeping it an interface lets the SPA be developed and demoed
// against mock data with the exact same handlers the live bus uses.
type Repo interface {
Me(ctx context.Context) MeInfo
// Cluster liveness + posture of every configured node.
Cluster(ctx context.Context) []NodeHealth
// Rooms the admin owns / belongs to, plus mutations the control plane allows.
ListRooms(ctx context.Context) ([]RoomView, error)
CreateRoom(ctx context.Context, req CreateRoomReq) (RoomView, error)
ListMembers(ctx context.Context, roomID string) ([]MemberView, error)
Invite(ctx context.Context, roomID string, req InviteReq) error
// KickMember removes a member and rotates the room key to a new epoch
// (forward secrecy). This is the rekey-on-kick primitive the bus exposes.
KickMember(ctx context.Context, roomID, endpoint string) error
// 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
RevokeUser(ctx context.Context, signPub string) error
}