From fd9a99a6d8ff7fb4d9da680facd65efbaa425287 Mon Sep 17 00:00:00 2001 From: Philipp Date: Thu, 11 Jun 2026 09:35:25 +0200 Subject: [PATCH] feat: add internal cluster admin endpoints --- .env.example | 2 + CHANGELOG.md | 1 + README.md | 5 + TODO.md | 9 +- backend/cmd/api/main.go | 11 +- backend/internal/cluster/repository.go | 45 +++++ backend/internal/cluster/repository_test.go | 41 +++++ backend/internal/clusteradmin/handler.go | 160 ++++++++++++++++++ backend/internal/clusteradmin/handler_test.go | 157 +++++++++++++++++ backend/internal/operator/middleware.go | 46 +++++ backend/internal/operator/middleware_test.go | 68 ++++++++ backend/internal/rbac/policy.go | 12 +- backend/internal/rbac/policy_test.go | 4 +- platform/config/config.go | 2 + platform/config/config_test.go | 5 + 15 files changed, 561 insertions(+), 7 deletions(-) create mode 100644 backend/internal/clusteradmin/handler.go create mode 100644 backend/internal/clusteradmin/handler_test.go create mode 100644 backend/internal/operator/middleware.go create mode 100644 backend/internal/operator/middleware_test.go diff --git a/.env.example b/.env.example index 5195a02..1ca768c 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,8 @@ REDIS_ADDR=localhost:6379 # Secrets # 32-byte key, base64 encoded. Replace locally. MASTER_KEY_BASE64= +# Internal operator API token for /internal/* routes. Replace locally. +OPERATOR_TOKEN= # Frontend VITE_API_BASE_URL=http://localhost:8080 diff --git a/CHANGELOG.md b/CHANGELOG.md index d7c5f53..6ad64df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 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. - 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. diff --git a/README.md b/README.md index 0473a2a..ab84717 100644 --- a/README.md +++ b/README.md @@ -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==` 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: - Supabase API Gateway: `http://localhost:8000` @@ -63,6 +65,9 @@ Backend-Endpunkte: - `GET /healthz`: oeffentlicher Healthcheck - `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 +- `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. diff --git a/TODO.md b/TODO.md index fad9ca9..0e47df8 100644 --- a/TODO.md +++ b/TODO.md @@ -116,12 +116,18 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - [x] `PVEAPIToken==` Auth-Header gesetzt - [x] `Get`, `Post`, `Put`, `Delete` mit Kontext, Timeout und Retry 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 - [ ] E1: Supabase Auth, JWT-Middleware und Profil-Sync - [ ] 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 - [ ] E6: Worker-Grundgeruest und UPID-Polling - [ ] 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: 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: Interne Operator-Endpunkte fuer Cluster-Anlage, Token-Rotation und Status-Updates angelegt. diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 1531386..cf283b0 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -19,8 +19,10 @@ import ( "proxui/backend/internal/auth" "proxui/backend/internal/authorization" "proxui/backend/internal/cluster" + "proxui/backend/internal/clusteradmin" "proxui/backend/internal/encryption" "proxui/backend/internal/membership" + "proxui/backend/internal/operator" "proxui/backend/internal/profile" "proxui/backend/internal/rbac" ) @@ -62,7 +64,8 @@ func main() { membershipMiddleware := membership.NewMiddleware(membership.NewRepository(db), logger) authorizationMiddleware := authorization.NewMiddleware() clusterRepository := cluster.NewRepository(db, tokenCipher) - _ = clusterRepository + clusterAdminHandler := clusteradmin.NewHandler(clusterRepository) + operatorMiddleware := operator.NewMiddleware(cfg.OperatorToken) mux := http.NewServeMux() 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{ Addr: cfg.BackendAddr, diff --git a/backend/internal/cluster/repository.go b/backend/internal/cluster/repository.go index 22fa333..1411739 100644 --- a/backend/internal/cluster/repository.go +++ b/backend/internal/cluster/repository.go @@ -36,6 +36,7 @@ type StoredCluster struct { type Storage interface { Get(ctx context.Context, id string) (StoredCluster, bool, error) Upsert(ctx context.Context, cluster StoredCluster) (StoredCluster, error) + SetStatus(ctx context.Context, id string, status string) (StoredCluster, bool, error) } type Repository struct { @@ -120,6 +121,23 @@ func (r Repository) UpsertCluster(ctx context.Context, cluster Cluster) (Cluster }, 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 { db *sql.DB } @@ -203,3 +221,30 @@ func (s SQLStorage) Upsert(ctx context.Context, cluster StoredCluster) (StoredCl 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 +} diff --git a/backend/internal/cluster/repository_test.go b/backend/internal/cluster/repository_test.go index 8cd128e..0b8f2fb 100644 --- a/backend/internal/cluster/repository_test.go +++ b/backend/internal/cluster/repository_test.go @@ -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 { t.Helper() @@ -124,3 +155,13 @@ func (s *memoryStorage) Upsert(_ context.Context, cluster StoredCluster) (Stored s.records[cluster.ID] = cluster 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 +} diff --git a/backend/internal/clusteradmin/handler.go b/backend/internal/clusteradmin/handler.go new file mode 100644 index 0000000..349201f --- /dev/null +++ b/backend/internal/clusteradmin/handler.go @@ -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}) +} diff --git a/backend/internal/clusteradmin/handler_test.go b/backend/internal/clusteradmin/handler_test.go new file mode 100644 index 0000000..a2e63cb --- /dev/null +++ b/backend/internal/clusteradmin/handler_test.go @@ -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 +} diff --git a/backend/internal/operator/middleware.go b/backend/internal/operator/middleware.go new file mode 100644 index 0000000..7779566 --- /dev/null +++ b/backend/internal/operator/middleware.go @@ -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}) +} diff --git a/backend/internal/operator/middleware_test.go b/backend/internal/operator/middleware_test.go new file mode 100644 index 0000000..c4773b5 --- /dev/null +++ b/backend/internal/operator/middleware_test.go @@ -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) + } +} diff --git a/backend/internal/rbac/policy.go b/backend/internal/rbac/policy.go index 8812dde..33ca221 100644 --- a/backend/internal/rbac/policy.go +++ b/backend/internal/rbac/policy.go @@ -3,10 +3,11 @@ package rbac type Role string const ( - RoleOwner Role = "owner" - RoleAdmin Role = "admin" - RoleMember Role = "member" - RoleViewer Role = "viewer" + RoleOwner Role = "owner" + RoleAdmin Role = "admin" + RoleMember Role = "member" + RoleViewer Role = "viewer" + RoleOperator Role = "operator" ) type Action string @@ -72,6 +73,9 @@ var permissions = map[Role]map[Action]bool{ ActionProjectRead, ActionSSHKeyRead, ), + RoleOperator: allow( + ActionClusterManage, + ), } func allow(actions ...Action) map[Action]bool { diff --git a/backend/internal/rbac/policy_test.go b/backend/internal/rbac/policy_test.go index 8ca4abb..641ddd5 100644 --- a/backend/internal/rbac/policy_test.go +++ b/backend/internal/rbac/policy_test.go @@ -21,6 +21,8 @@ func TestCan(t *testing.T) { {name: "viewer cannot power vm", role: RoleViewer, action: ActionVMPower, 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: "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 action denied", role: RoleOwner, action: Action("unknown.action"), want: false}, } @@ -35,7 +37,7 @@ func TestCan(t *testing.T) { } func TestPolicyMatrix(t *testing.T) { - roles := []Role{RoleOwner, RoleAdmin, RoleMember, RoleViewer} + roles := []Role{RoleOwner, RoleAdmin, RoleMember, RoleViewer, RoleOperator} actions := []Action{ ActionVMRead, ActionVMPower, diff --git a/platform/config/config.go b/platform/config/config.go index 0db8cef..91712ee 100644 --- a/platform/config/config.go +++ b/platform/config/config.go @@ -23,6 +23,7 @@ type Config struct { RedisAddr string AppSiteURL string MasterKeyBase64 string + OperatorToken string } func Load() (Config, error) { @@ -54,6 +55,7 @@ func Load() (Config, error) { RedisAddr: getenv("REDIS_ADDR", "localhost:6379"), AppSiteURL: getenv("APP_SITE_URL", "http://localhost:5173"), MasterKeyBase64: os.Getenv("MASTER_KEY_BASE64"), + OperatorToken: os.Getenv("OPERATOR_TOKEN"), }, nil } diff --git a/platform/config/config_test.go b/platform/config/config_test.go index 13830cf..2539e36 100644 --- a/platform/config/config_test.go +++ b/platform/config/config_test.go @@ -43,6 +43,7 @@ func TestLoadOverrides(t *testing.T) { t.Setenv("WORKER_CONCURRENCY", "12") t.Setenv("REDIS_ADDR", "redis:6379") t.Setenv("MASTER_KEY_BASE64", "test-key") + t.Setenv("OPERATOR_TOKEN", "operator-secret") cfg, err := Load() if err != nil { @@ -70,6 +71,9 @@ func TestLoadOverrides(t *testing.T) { if cfg.MasterKeyBase64 != "test-key" { 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) { @@ -110,6 +114,7 @@ func clearConfigEnv(t *testing.T) { "REDIS_ADDR", "APP_SITE_URL", "MASTER_KEY_BASE64", + "OPERATOR_TOKEN", } { t.Setenv(key, "") }