feat: add internal cluster admin endpoints
This commit is contained in:
@@ -25,6 +25,8 @@ REDIS_ADDR=localhost:6379
|
|||||||
# Secrets
|
# Secrets
|
||||||
# 32-byte key, base64 encoded. Replace locally.
|
# 32-byte key, base64 encoded. Replace locally.
|
||||||
MASTER_KEY_BASE64=
|
MASTER_KEY_BASE64=
|
||||||
|
# Internal operator API token for /internal/* routes. Replace locally.
|
||||||
|
OPERATOR_TOKEN=
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
VITE_API_BASE_URL=http://localhost:8080
|
VITE_API_BASE_URL=http://localhost:8080
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Interne Operator-Endpunkte fuer Cluster-Anlage, Token-Rotation und Status-Updates ohne Token-Leak angelegt.
|
||||||
- Proxmox-HTTP-Client mit TLS-Fingerprint-Pinning, Token-Auth, Timeout und Retry angelegt.
|
- Proxmox-HTTP-Client mit TLS-Fingerprint-Pinning, Token-Auth, Timeout und Retry angelegt.
|
||||||
- Cluster-Repository zum verschluesselten Speichern und entschluesselten Laden von Proxmox-Tokens angelegt.
|
- Cluster-Repository zum verschluesselten Speichern und entschluesselten Laden von Proxmox-Tokens angelegt.
|
||||||
- AES-GCM-Krypto-Layer fuer verschluesselte Cluster-Tokens mit Master-Key-Konfiguration angelegt.
|
- AES-GCM-Krypto-Layer fuer verschluesselte Cluster-Tokens mit Master-Key-Konfiguration angelegt.
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ Das Cluster-Repository speichert Proxmox-Token-Secrets nur als Ciphertext in `pu
|
|||||||
|
|
||||||
Der Proxmox-Client nutzt HTTPS mit normaler Zertifikatsvalidierung plus SHA-256-Fingerprint-Pinning gegen `clusters.tls_fingerprint`. Proxmox-Token werden nur als `PVEAPIToken=<token_id>=<secret>` Header an Proxmox gesendet.
|
Der Proxmox-Client nutzt HTTPS mit normaler Zertifikatsvalidierung plus SHA-256-Fingerprint-Pinning gegen `clusters.tls_fingerprint`. Proxmox-Token werden nur als `PVEAPIToken=<token_id>=<secret>` Header an Proxmox gesendet.
|
||||||
|
|
||||||
|
Interne Cluster-Admin-Routen unter `/internal/clusters` sind nicht tenant-gebunden. Sie erfordern den Header `X-ProxUI-Operator-Token` mit `OPERATOR_TOKEN` und die isolierte Rolle `operator`; Responses enthalten nie `token_secret`.
|
||||||
|
|
||||||
Lokale Dienste:
|
Lokale Dienste:
|
||||||
|
|
||||||
- Supabase API Gateway: `http://localhost:8000`
|
- Supabase API Gateway: `http://localhost:8000`
|
||||||
@@ -63,6 +65,9 @@ Backend-Endpunkte:
|
|||||||
- `GET /healthz`: oeffentlicher Healthcheck
|
- `GET /healthz`: oeffentlicher Healthcheck
|
||||||
- `GET /me`: geschuetzt, synchronisiert `profiles` und gibt den authentifizierten Principal aus dem JWT zurueck
|
- `GET /me`: geschuetzt, synchronisiert `profiles` und gibt den authentifizierten Principal aus dem JWT zurueck
|
||||||
- `GET /tenants/{tenantID}/membership`: geschuetzt, synchronisiert `profiles`, prueft Tenant-Mitgliedschaft und gibt Rolle/Tenant zurueck
|
- `GET /tenants/{tenantID}/membership`: geschuetzt, synchronisiert `profiles`, prueft Tenant-Mitgliedschaft und gibt Rolle/Tenant zurueck
|
||||||
|
- `POST /internal/clusters`: intern, legt Cluster an und speichert Token verschluesselt
|
||||||
|
- `PUT /internal/clusters/{clusterID}`: intern, aktualisiert Cluster und rotiert Token
|
||||||
|
- `PATCH /internal/clusters/{clusterID}/status`: intern, setzt Cluster-Status
|
||||||
|
|
||||||
Tenant-gescoped Backend-Routen laufen ueber JWT-Auth, Profil-Sync, Membership-Resolver und deklarative RBAC-Pruefung.
|
Tenant-gescoped Backend-Routen laufen ueber JWT-Auth, Profil-Sync, Membership-Resolver und deklarative RBAC-Pruefung.
|
||||||
|
|
||||||
|
|||||||
@@ -116,12 +116,18 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda
|
|||||||
- [x] `PVEAPIToken=<token_id>=<secret>` Auth-Header gesetzt
|
- [x] `PVEAPIToken=<token_id>=<secret>` Auth-Header gesetzt
|
||||||
- [x] `Get`, `Post`, `Put`, `Delete` mit Kontext, Timeout und Retry angelegt
|
- [x] `Get`, `Post`, `Put`, `Delete` mit Kontext, Timeout und Retry angelegt
|
||||||
- [x] Mock-Server-Tests fuer passenden und falschen Fingerprint angelegt
|
- [x] Mock-Server-Tests fuer passenden und falschen Fingerprint angelegt
|
||||||
|
- [x] E3-T04: Admin-Endpunkt: Cluster verwalten
|
||||||
|
- [x] Interne Operator-Token-Middleware fuer `/internal/*` angelegt
|
||||||
|
- [x] `POST /internal/clusters` fuer Cluster-Anlage angelegt
|
||||||
|
- [x] `PUT /internal/clusters/{clusterID}` fuer Token-Rotation und Cluster-Update angelegt
|
||||||
|
- [x] `PATCH /internal/clusters/{clusterID}/status` fuer Status-Updates angelegt
|
||||||
|
- [x] Responses enthalten kein `token_secret`
|
||||||
|
|
||||||
## MVP-Backlog
|
## MVP-Backlog
|
||||||
|
|
||||||
- [ ] E1: Supabase Auth, JWT-Middleware und Profil-Sync
|
- [ ] E1: Supabase Auth, JWT-Middleware und Profil-Sync
|
||||||
- [ ] E2: Datenmodell, Migrationen, RLS und VMID-Allokator
|
- [ ] E2: Datenmodell, Migrationen, RLS und VMID-Allokator
|
||||||
- [ ] E3: Envelope-Encryption, Cluster-Repository und Proxmox-Client
|
- [x] E3: Envelope-Encryption, Cluster-Repository, Proxmox-Client und interne Cluster-Verwaltung
|
||||||
- [ ] E4: RBAC, Membership-Resolver, Policy-Funktion und Autorisierungs-Middleware
|
- [ ] E4: RBAC, Membership-Resolver, Policy-Funktion und Autorisierungs-Middleware
|
||||||
- [ ] E6: Worker-Grundgeruest und UPID-Polling
|
- [ ] E6: Worker-Grundgeruest und UPID-Polling
|
||||||
- [ ] E5: VM-Liste, Detail, Power-Aktionen und Reconciliation
|
- [ ] E5: VM-Liste, Detail, Power-Aktionen und Reconciliation
|
||||||
@@ -172,3 +178,4 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda
|
|||||||
- 2026-06-11: AES-GCM-Krypto-Layer fuer Cluster-Tokens mit Master-Key-Validierung und Unit-Tests angelegt.
|
- 2026-06-11: AES-GCM-Krypto-Layer fuer Cluster-Tokens mit Master-Key-Validierung und Unit-Tests angelegt.
|
||||||
- 2026-06-11: Cluster-Repository mit verschluesseltem Token-Speicher und Entschluesselung beim Laden angelegt.
|
- 2026-06-11: Cluster-Repository mit verschluesseltem Token-Speicher und Entschluesselung beim Laden angelegt.
|
||||||
- 2026-06-11: Proxmox-HTTP-Client mit TLS-Fingerprint-Pinning, Token-Auth, Timeout und Retry angelegt.
|
- 2026-06-11: Proxmox-HTTP-Client mit TLS-Fingerprint-Pinning, Token-Auth, Timeout und Retry angelegt.
|
||||||
|
- 2026-06-11: Interne Operator-Endpunkte fuer Cluster-Anlage, Token-Rotation und Status-Updates angelegt.
|
||||||
|
|||||||
+10
-1
@@ -19,8 +19,10 @@ import (
|
|||||||
"proxui/backend/internal/auth"
|
"proxui/backend/internal/auth"
|
||||||
"proxui/backend/internal/authorization"
|
"proxui/backend/internal/authorization"
|
||||||
"proxui/backend/internal/cluster"
|
"proxui/backend/internal/cluster"
|
||||||
|
"proxui/backend/internal/clusteradmin"
|
||||||
"proxui/backend/internal/encryption"
|
"proxui/backend/internal/encryption"
|
||||||
"proxui/backend/internal/membership"
|
"proxui/backend/internal/membership"
|
||||||
|
"proxui/backend/internal/operator"
|
||||||
"proxui/backend/internal/profile"
|
"proxui/backend/internal/profile"
|
||||||
"proxui/backend/internal/rbac"
|
"proxui/backend/internal/rbac"
|
||||||
)
|
)
|
||||||
@@ -62,7 +64,8 @@ func main() {
|
|||||||
membershipMiddleware := membership.NewMiddleware(membership.NewRepository(db), logger)
|
membershipMiddleware := membership.NewMiddleware(membership.NewRepository(db), logger)
|
||||||
authorizationMiddleware := authorization.NewMiddleware()
|
authorizationMiddleware := authorization.NewMiddleware()
|
||||||
clusterRepository := cluster.NewRepository(db, tokenCipher)
|
clusterRepository := cluster.NewRepository(db, tokenCipher)
|
||||||
_ = clusterRepository
|
clusterAdminHandler := clusteradmin.NewHandler(clusterRepository)
|
||||||
|
operatorMiddleware := operator.NewMiddleware(cfg.OperatorToken)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -91,6 +94,12 @@ func main() {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
clusterManageChain := func(handler http.HandlerFunc) http.Handler {
|
||||||
|
return operatorMiddleware.RequireOperator(authorizationMiddleware.Require(rbac.ActionClusterManage, handler))
|
||||||
|
}
|
||||||
|
mux.Handle("POST /internal/clusters", clusterManageChain(clusterAdminHandler.CreateCluster))
|
||||||
|
mux.Handle("PUT /internal/clusters/{clusterID}", clusterManageChain(clusterAdminHandler.UpdateCluster))
|
||||||
|
mux.Handle("PATCH /internal/clusters/{clusterID}/status", clusterManageChain(clusterAdminHandler.SetClusterStatus))
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: cfg.BackendAddr,
|
Addr: cfg.BackendAddr,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type StoredCluster struct {
|
|||||||
type Storage interface {
|
type Storage interface {
|
||||||
Get(ctx context.Context, id string) (StoredCluster, bool, error)
|
Get(ctx context.Context, id string) (StoredCluster, bool, error)
|
||||||
Upsert(ctx context.Context, cluster StoredCluster) (StoredCluster, error)
|
Upsert(ctx context.Context, cluster StoredCluster) (StoredCluster, error)
|
||||||
|
SetStatus(ctx context.Context, id string, status string) (StoredCluster, bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
@@ -120,6 +121,23 @@ func (r Repository) UpsertCluster(ctx context.Context, cluster Cluster) (Cluster
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r Repository) SetClusterStatus(ctx context.Context, id string, status string) (Cluster, bool, error) {
|
||||||
|
stored, found, err := r.storage.SetStatus(ctx, strings.TrimSpace(id), strings.TrimSpace(status))
|
||||||
|
if err != nil || !found {
|
||||||
|
return Cluster{}, found, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cluster{
|
||||||
|
ID: stored.ID,
|
||||||
|
Name: stored.Name,
|
||||||
|
APIEndpoint: stored.APIEndpoint,
|
||||||
|
TLSFingerprint: stored.TLSFingerprint,
|
||||||
|
TokenID: stored.TokenID,
|
||||||
|
Status: stored.Status,
|
||||||
|
CreatedAt: stored.CreatedAt,
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
type SQLStorage struct {
|
type SQLStorage struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
@@ -203,3 +221,30 @@ func (s SQLStorage) Upsert(ctx context.Context, cluster StoredCluster) (StoredCl
|
|||||||
|
|
||||||
return stored, nil
|
return stored, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s SQLStorage) SetStatus(ctx context.Context, id string, status string) (StoredCluster, bool, error) {
|
||||||
|
var stored StoredCluster
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
update public.clusters
|
||||||
|
set status = $2
|
||||||
|
where id = $1
|
||||||
|
returning id::text, name, api_endpoint, tls_fingerprint, encrypted_token, token_id, status, created_at
|
||||||
|
`, id, status).Scan(
|
||||||
|
&stored.ID,
|
||||||
|
&stored.Name,
|
||||||
|
&stored.APIEndpoint,
|
||||||
|
&stored.TLSFingerprint,
|
||||||
|
&stored.EncryptedToken,
|
||||||
|
&stored.TokenID,
|
||||||
|
&stored.Status,
|
||||||
|
&stored.CreatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return StoredCluster{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return StoredCluster{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return stored, true, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,6 +83,37 @@ func TestRepositoryReturnsErrorWhenStoredTokenUsesWrongKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRepositorySetsClusterStatusWithoutDecryptingToken(t *testing.T) {
|
||||||
|
storage := newMemoryStorage()
|
||||||
|
repository := NewRepositoryWithStorage(storage, testCipher(t, 1))
|
||||||
|
|
||||||
|
_, err := repository.UpsertCluster(context.Background(), Cluster{
|
||||||
|
ID: "cluster-1",
|
||||||
|
Name: "Lab",
|
||||||
|
APIEndpoint: "https://pve.example.test:8006",
|
||||||
|
TLSFingerprint: "AA:BB",
|
||||||
|
TokenID: "root@pam!proxui",
|
||||||
|
TokenSecret: "secret-token",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertCluster() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, found, err := repository.SetClusterStatus(context.Background(), "cluster-1", "disabled")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetClusterStatus() error = %v", err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("SetClusterStatus() found = false, want true")
|
||||||
|
}
|
||||||
|
if updated.Status != "disabled" {
|
||||||
|
t.Fatalf("Status = %q, want disabled", updated.Status)
|
||||||
|
}
|
||||||
|
if updated.TokenSecret != "" {
|
||||||
|
t.Fatal("SetClusterStatus() returned token secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testCipher(t *testing.T, value byte) encryption.Cipher {
|
func testCipher(t *testing.T, value byte) encryption.Cipher {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -124,3 +155,13 @@ func (s *memoryStorage) Upsert(_ context.Context, cluster StoredCluster) (Stored
|
|||||||
s.records[cluster.ID] = cluster
|
s.records[cluster.ID] = cluster
|
||||||
return cluster, nil
|
return cluster, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *memoryStorage) SetStatus(_ context.Context, id string, status string) (StoredCluster, bool, error) {
|
||||||
|
cluster, ok := s.records[id]
|
||||||
|
if !ok {
|
||||||
|
return StoredCluster{}, false, nil
|
||||||
|
}
|
||||||
|
cluster.Status = status
|
||||||
|
s.records[id] = cluster
|
||||||
|
return cluster, true, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package clusteradmin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"proxui/backend/internal/cluster"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
UpsertCluster(ctx context.Context, cluster cluster.Cluster) (cluster.Cluster, error)
|
||||||
|
SetClusterStatus(ctx context.Context, id string, status string) (cluster.Cluster, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
repository Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(repository Repository) Handler {
|
||||||
|
return Handler{repository: repository}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) CreateCluster(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request upsertClusterRequest
|
||||||
|
if !decodeJSON(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, err := h.repository.UpsertCluster(r.Context(), cluster.Cluster{
|
||||||
|
Name: request.Name,
|
||||||
|
APIEndpoint: request.APIEndpoint,
|
||||||
|
TLSFingerprint: request.TLSFingerprint,
|
||||||
|
TokenID: request.TokenID,
|
||||||
|
TokenSecret: request.TokenSecret,
|
||||||
|
Status: request.Status,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "cluster_upsert_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, clusterResponseFromCluster(saved))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) UpdateCluster(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clusterID := r.PathValue("clusterID")
|
||||||
|
if clusterID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "cluster_id_required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request upsertClusterRequest
|
||||||
|
if !decodeJSON(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, err := h.repository.UpsertCluster(r.Context(), cluster.Cluster{
|
||||||
|
ID: clusterID,
|
||||||
|
Name: request.Name,
|
||||||
|
APIEndpoint: request.APIEndpoint,
|
||||||
|
TLSFingerprint: request.TLSFingerprint,
|
||||||
|
TokenID: request.TokenID,
|
||||||
|
TokenSecret: request.TokenSecret,
|
||||||
|
Status: request.Status,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "cluster_upsert_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, clusterResponseFromCluster(saved))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) SetClusterStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clusterID := r.PathValue("clusterID")
|
||||||
|
if clusterID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "cluster_id_required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request setStatusRequest
|
||||||
|
if !decodeJSON(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(request.Status) == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "status_required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, found, err := h.repository.SetClusterStatus(r.Context(), clusterID, request.Status)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "cluster_status_update_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
writeError(w, http.StatusNotFound, "cluster_not_found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, clusterResponseFromCluster(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
type upsertClusterRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
APIEndpoint string `json:"api_endpoint"`
|
||||||
|
TLSFingerprint string `json:"tls_fingerprint"`
|
||||||
|
TokenID string `json:"token_id"`
|
||||||
|
TokenSecret string `json:"token_secret"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type setStatusRequest struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type clusterResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
APIEndpoint string `json:"api_endpoint"`
|
||||||
|
TLSFingerprint string `json:"tls_fingerprint"`
|
||||||
|
TokenID string `json:"token_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func clusterResponseFromCluster(cluster cluster.Cluster) clusterResponse {
|
||||||
|
return clusterResponse{
|
||||||
|
ID: cluster.ID,
|
||||||
|
Name: cluster.Name,
|
||||||
|
APIEndpoint: cluster.APIEndpoint,
|
||||||
|
TLSFingerprint: cluster.TLSFingerprint,
|
||||||
|
TokenID: cluster.TokenID,
|
||||||
|
Status: cluster.Status,
|
||||||
|
CreatedAt: cluster.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||||
|
defer r.Body.Close()
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(target); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, message string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": message})
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package clusteradmin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"proxui/backend/internal/cluster"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateClusterDoesNotReturnTokenSecret(t *testing.T) {
|
||||||
|
repository := newStubRepository()
|
||||||
|
handler := NewHandler(repository)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/internal/clusters", strings.NewReader(`{
|
||||||
|
"name": "Lab",
|
||||||
|
"api_endpoint": "https://pve.example.test:8006/api2/json",
|
||||||
|
"tls_fingerprint": "AA:BB",
|
||||||
|
"token_id": "root@pam!proxui",
|
||||||
|
"token_secret": "secret-token"
|
||||||
|
}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.CreateCluster(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated)
|
||||||
|
}
|
||||||
|
if repository.saved.TokenSecret != "secret-token" {
|
||||||
|
t.Fatalf("saved token secret = %q", repository.saved.TokenSecret)
|
||||||
|
}
|
||||||
|
if bytes.Contains(rec.Body.Bytes(), []byte("secret-token")) {
|
||||||
|
t.Fatal("response contains token secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
var response clusterResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
if response.TokenID != "root@pam!proxui" {
|
||||||
|
t.Fatalf("TokenID = %q, want root@pam!proxui", response.TokenID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateClusterUsesPathIDAndRotatesToken(t *testing.T) {
|
||||||
|
repository := newStubRepository()
|
||||||
|
handler := NewHandler(repository)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/internal/clusters/cluster-1", strings.NewReader(`{
|
||||||
|
"name": "Lab",
|
||||||
|
"api_endpoint": "https://pve.example.test:8006/api2/json",
|
||||||
|
"tls_fingerprint": "AA:BB",
|
||||||
|
"token_id": "root@pam!proxui",
|
||||||
|
"token_secret": "rotated-token",
|
||||||
|
"status": "active"
|
||||||
|
}`))
|
||||||
|
req.SetPathValue("clusterID", "cluster-1")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.UpdateCluster(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if repository.saved.ID != "cluster-1" {
|
||||||
|
t.Fatalf("saved ID = %q, want cluster-1", repository.saved.ID)
|
||||||
|
}
|
||||||
|
if repository.saved.TokenSecret != "rotated-token" {
|
||||||
|
t.Fatalf("saved token secret = %q", repository.saved.TokenSecret)
|
||||||
|
}
|
||||||
|
if bytes.Contains(rec.Body.Bytes(), []byte("rotated-token")) {
|
||||||
|
t.Fatal("response contains rotated token secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetClusterStatus(t *testing.T) {
|
||||||
|
repository := newStubRepository()
|
||||||
|
handler := NewHandler(repository)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/internal/clusters/cluster-1/status", strings.NewReader(`{"status":"disabled"}`))
|
||||||
|
req.SetPathValue("clusterID", "cluster-1")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.SetClusterStatus(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if repository.statusID != "cluster-1" {
|
||||||
|
t.Fatalf("status ID = %q, want cluster-1", repository.statusID)
|
||||||
|
}
|
||||||
|
if repository.status != "disabled" {
|
||||||
|
t.Fatalf("status = %q, want disabled", repository.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetClusterStatusReturnsNotFound(t *testing.T) {
|
||||||
|
repository := newStubRepository()
|
||||||
|
repository.statusFound = false
|
||||||
|
handler := NewHandler(repository)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/internal/clusters/missing/status", strings.NewReader(`{"status":"disabled"}`))
|
||||||
|
req.SetPathValue("clusterID", "missing")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.SetClusterStatus(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubRepository struct {
|
||||||
|
saved cluster.Cluster
|
||||||
|
statusID string
|
||||||
|
status string
|
||||||
|
statusFound bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStubRepository() *stubRepository {
|
||||||
|
return &stubRepository{statusFound: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubRepository) UpsertCluster(_ context.Context, cluster cluster.Cluster) (cluster.Cluster, error) {
|
||||||
|
s.saved = cluster
|
||||||
|
if cluster.ID == "" {
|
||||||
|
cluster.ID = "cluster-1"
|
||||||
|
}
|
||||||
|
if cluster.Status == "" {
|
||||||
|
cluster.Status = "active"
|
||||||
|
}
|
||||||
|
cluster.CreatedAt = time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||||
|
return cluster, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubRepository) SetClusterStatus(_ context.Context, id string, status string) (cluster.Cluster, bool, error) {
|
||||||
|
s.statusID = id
|
||||||
|
s.status = status
|
||||||
|
if !s.statusFound {
|
||||||
|
return cluster.Cluster{}, false, nil
|
||||||
|
}
|
||||||
|
return cluster.Cluster{
|
||||||
|
ID: id,
|
||||||
|
Name: "Lab",
|
||||||
|
APIEndpoint: "https://pve.example.test:8006/api2/json",
|
||||||
|
TLSFingerprint: "AA:BB",
|
||||||
|
TokenID: "root@pam!proxui",
|
||||||
|
Status: status,
|
||||||
|
CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package operator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"proxui/backend/internal/membership"
|
||||||
|
"proxui/backend/internal/rbac"
|
||||||
|
)
|
||||||
|
|
||||||
|
const HeaderName = "X-ProxUI-Operator-Token"
|
||||||
|
|
||||||
|
type Middleware struct {
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMiddleware(token string) Middleware {
|
||||||
|
return Middleware{token: token}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Middleware) RequireOperator(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if m.token == "" {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "operator_auth_not_configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provided := r.Header.Get(HeaderName)
|
||||||
|
if subtle.ConstantTimeCompare([]byte(provided), []byte(m.token)) != 1 {
|
||||||
|
writeError(w, http.StatusForbidden, "operator_forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
operatorContext := membership.ContextWithMembership(r.Context(), membership.Membership{
|
||||||
|
Role: rbac.RoleOperator,
|
||||||
|
})
|
||||||
|
next.ServeHTTP(w, r.WithContext(operatorContext))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package operator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"proxui/backend/internal/membership"
|
||||||
|
"proxui/backend/internal/rbac"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequireOperatorAcceptsConfiguredToken(t *testing.T) {
|
||||||
|
middleware := NewMiddleware("operator-secret")
|
||||||
|
handler := middleware.RequireOperator(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
operator, ok := membership.FromRequest(r)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("operator role missing from request")
|
||||||
|
}
|
||||||
|
if operator.Role != rbac.RoleOperator {
|
||||||
|
t.Fatalf("role = %q, want %q", operator.Role, rbac.RoleOperator)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/internal/clusters", nil)
|
||||||
|
req.Header.Set(HeaderName, "operator-secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireOperatorRejectsWrongToken(t *testing.T) {
|
||||||
|
middleware := NewMiddleware("operator-secret")
|
||||||
|
handler := middleware.RequireOperator(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/internal/clusters", nil)
|
||||||
|
req.Header.Set(HeaderName, "wrong")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireOperatorRejectsMissingConfiguredToken(t *testing.T) {
|
||||||
|
middleware := NewMiddleware("")
|
||||||
|
handler := middleware.RequireOperator(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/internal/clusters", nil)
|
||||||
|
req.Header.Set(HeaderName, "operator-secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ const (
|
|||||||
RoleAdmin Role = "admin"
|
RoleAdmin Role = "admin"
|
||||||
RoleMember Role = "member"
|
RoleMember Role = "member"
|
||||||
RoleViewer Role = "viewer"
|
RoleViewer Role = "viewer"
|
||||||
|
RoleOperator Role = "operator"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Action string
|
type Action string
|
||||||
@@ -72,6 +73,9 @@ var permissions = map[Role]map[Action]bool{
|
|||||||
ActionProjectRead,
|
ActionProjectRead,
|
||||||
ActionSSHKeyRead,
|
ActionSSHKeyRead,
|
||||||
),
|
),
|
||||||
|
RoleOperator: allow(
|
||||||
|
ActionClusterManage,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
func allow(actions ...Action) map[Action]bool {
|
func allow(actions ...Action) map[Action]bool {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ func TestCan(t *testing.T) {
|
|||||||
{name: "viewer cannot power vm", role: RoleViewer, action: ActionVMPower, want: false},
|
{name: "viewer cannot power vm", role: RoleViewer, action: ActionVMPower, want: false},
|
||||||
{name: "viewer cannot create vm", role: RoleViewer, action: ActionVMCreate, want: false},
|
{name: "viewer cannot create vm", role: RoleViewer, action: ActionVMCreate, want: false},
|
||||||
{name: "tenant roles cannot manage cluster", role: RoleOwner, action: ActionClusterManage, want: false},
|
{name: "tenant roles cannot manage cluster", role: RoleOwner, action: ActionClusterManage, want: false},
|
||||||
|
{name: "operator can manage cluster", role: RoleOperator, action: ActionClusterManage, want: true},
|
||||||
|
{name: "operator cannot read tenant vms", role: RoleOperator, action: ActionVMRead, want: false},
|
||||||
{name: "unknown role denied", role: Role("unknown"), action: ActionVMRead, want: false},
|
{name: "unknown role denied", role: Role("unknown"), action: ActionVMRead, want: false},
|
||||||
{name: "unknown action denied", role: RoleOwner, action: Action("unknown.action"), want: false},
|
{name: "unknown action denied", role: RoleOwner, action: Action("unknown.action"), want: false},
|
||||||
}
|
}
|
||||||
@@ -35,7 +37,7 @@ func TestCan(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPolicyMatrix(t *testing.T) {
|
func TestPolicyMatrix(t *testing.T) {
|
||||||
roles := []Role{RoleOwner, RoleAdmin, RoleMember, RoleViewer}
|
roles := []Role{RoleOwner, RoleAdmin, RoleMember, RoleViewer, RoleOperator}
|
||||||
actions := []Action{
|
actions := []Action{
|
||||||
ActionVMRead,
|
ActionVMRead,
|
||||||
ActionVMPower,
|
ActionVMPower,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Config struct {
|
|||||||
RedisAddr string
|
RedisAddr string
|
||||||
AppSiteURL string
|
AppSiteURL string
|
||||||
MasterKeyBase64 string
|
MasterKeyBase64 string
|
||||||
|
OperatorToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -54,6 +55,7 @@ func Load() (Config, error) {
|
|||||||
RedisAddr: getenv("REDIS_ADDR", "localhost:6379"),
|
RedisAddr: getenv("REDIS_ADDR", "localhost:6379"),
|
||||||
AppSiteURL: getenv("APP_SITE_URL", "http://localhost:5173"),
|
AppSiteURL: getenv("APP_SITE_URL", "http://localhost:5173"),
|
||||||
MasterKeyBase64: os.Getenv("MASTER_KEY_BASE64"),
|
MasterKeyBase64: os.Getenv("MASTER_KEY_BASE64"),
|
||||||
|
OperatorToken: os.Getenv("OPERATOR_TOKEN"),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ func TestLoadOverrides(t *testing.T) {
|
|||||||
t.Setenv("WORKER_CONCURRENCY", "12")
|
t.Setenv("WORKER_CONCURRENCY", "12")
|
||||||
t.Setenv("REDIS_ADDR", "redis:6379")
|
t.Setenv("REDIS_ADDR", "redis:6379")
|
||||||
t.Setenv("MASTER_KEY_BASE64", "test-key")
|
t.Setenv("MASTER_KEY_BASE64", "test-key")
|
||||||
|
t.Setenv("OPERATOR_TOKEN", "operator-secret")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,6 +71,9 @@ func TestLoadOverrides(t *testing.T) {
|
|||||||
if cfg.MasterKeyBase64 != "test-key" {
|
if cfg.MasterKeyBase64 != "test-key" {
|
||||||
t.Fatalf("MasterKeyBase64 = %q, want test-key", cfg.MasterKeyBase64)
|
t.Fatalf("MasterKeyBase64 = %q, want test-key", cfg.MasterKeyBase64)
|
||||||
}
|
}
|
||||||
|
if cfg.OperatorToken != "operator-secret" {
|
||||||
|
t.Fatalf("OperatorToken = %q, want operator-secret", cfg.OperatorToken)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadRejectsInvalidLogLevel(t *testing.T) {
|
func TestLoadRejectsInvalidLogLevel(t *testing.T) {
|
||||||
@@ -110,6 +114,7 @@ func clearConfigEnv(t *testing.T) {
|
|||||||
"REDIS_ADDR",
|
"REDIS_ADDR",
|
||||||
"APP_SITE_URL",
|
"APP_SITE_URL",
|
||||||
"MASTER_KEY_BASE64",
|
"MASTER_KEY_BASE64",
|
||||||
|
"OPERATOR_TOKEN",
|
||||||
} {
|
} {
|
||||||
t.Setenv(key, "")
|
t.Setenv(key, "")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user