feat: add internal cluster admin endpoints

This commit is contained in:
Philipp
2026-06-11 09:35:25 +02:00
parent 376f6249b2
commit fd9a99a6d8
15 changed files with 561 additions and 7 deletions
+2
View File
@@ -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
+1
View File
@@ -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.
+5
View File
@@ -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.
+8 -1
View File
@@ -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
View File
@@ -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,
+45
View File
@@ -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
}
+160
View File
@@ -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
}
+46
View File
@@ -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)
}
}
+4
View File
@@ -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 {
+3 -1
View File
@@ -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,
+2
View File
@@ -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
} }
+5
View File
@@ -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, "")
} }