From 452d4c5f7132ef72604af9870b21b93b10c804a2 Mon Sep 17 00:00:00 2001 From: Philipp Date: Thu, 11 Jun 2026 08:14:22 +0200 Subject: [PATCH] feat: add tenant authorization middleware --- CHANGELOG.md | 2 + README.md | 3 + TODO.md | 14 +- backend/cmd/api/main.go | 26 +++ backend/internal/authorization/middleware.go | 33 ++++ .../internal/authorization/middleware_test.go | 66 ++++++++ backend/internal/membership/context.go | 28 +++ backend/internal/membership/middleware.go | 63 +++++++ .../internal/membership/middleware_test.go | 159 ++++++++++++++++++ backend/internal/membership/repository.go | 39 +++++ 10 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 backend/internal/authorization/middleware.go create mode 100644 backend/internal/authorization/middleware_test.go create mode 100644 backend/internal/membership/context.go create mode 100644 backend/internal/membership/middleware.go create mode 100644 backend/internal/membership/middleware_test.go create mode 100644 backend/internal/membership/repository.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 730fdf6..fe0d938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Autorisierungs-Middleware fuer deklarative Rollen-/Permission-Pruefung pro Route angelegt. +- Membership-Resolver fuer tenant-gescoped Backend-Routen mit 403 fuer Nicht-Mitglieder angelegt. - Frontend-Prototyp mit Supabase Auth, Backend-Profilcheck und Platzhalter-Views angelegt. - Zentrale RBAC-Policy-Funktion mit Rollen/Aktions-Matrix angelegt. - Lazy Profil-Sync vom Supabase-JWT nach `public.profiles` im Backend angelegt. diff --git a/README.md b/README.md index 461b868..a150310 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,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 + +Tenant-gescoped Backend-Routen laufen ueber JWT-Auth, Profil-Sync, Membership-Resolver und deklarative RBAC-Pruefung. Frontend-Prototyp: diff --git a/TODO.md b/TODO.md index d25c9aa..7d8a10b 100644 --- a/TODO.md +++ b/TODO.md @@ -82,11 +82,21 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - [x] `profiles` wird idempotent per `insert ... on conflict` erstellt/aktualisiert - [x] Profil-Sync-Middleware mit Unit-Tests angelegt - [x] `/me` fuehrt Profil-Sync vor Handler-Ausfuehrung aus +- [x] E4-T01: Mitgliedschafts-Resolver + - [x] Tenant-Mitgliedschaft aus `profiles`/`memberships` anhand JWT-`sub` geladen + - [x] Tenant-ID aus Request-Route ableitbar gemacht + - [x] Mitgliedschaft und Rolle im Request-Context bereitgestellt + - [x] Nicht-Mitglieder erhalten `403` - [x] E4-T02: Policy-/Permission-Funktion - [x] Rollen und Aktionen zentral definiert - [x] `Can(role, action)` angelegt - [x] Tabellengetriebene Unit-Tests fuer Rollen/Aktionen angelegt - [x] `cluster.manage` fuer Tenant-Rollen bewusst verweigert +- [x] E4-T03: Autorisierungs-Middleware + - [x] Deklarativen `Require(action, handler)` Helper angelegt + - [x] Rollenpruefung nutzt zentrale `rbac.Can` Policy-Funktion + - [x] Fehlende oder unzureichende Rechte liefern `403` + - [x] Tenant-Membership-Endpunkt exemplarisch mit `project.read` geschuetzt - [x] E10-Vorgriff: Frontend-Prototyp - [x] Supabase Auth Login/Registrierung angebunden - [x] Backend-`/me` Profil-Sync-Pruefung angebunden @@ -97,7 +107,7 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - [ ] E1: Supabase Auth, JWT-Middleware und Profil-Sync - [ ] E2: Datenmodell, Migrationen, RLS und VMID-Allokator - [ ] E3: Envelope-Encryption, Cluster-Repository und Proxmox-Client -- [ ] E4: RBAC, Policy-Funktion und Autorisierungs-Middleware +- [ ] E4: RBAC, Membership-Resolver, Policy-Funktion und Autorisierungs-Middleware - [ ] E6: Worker-Grundgeruest und UPID-Polling - [ ] E5: VM-Liste, Detail, Power-Aktionen und Reconciliation - [ ] E7: SSH-Keys, Templates und Provisioning @@ -142,3 +152,5 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - 2026-06-10: Profil-Sync-Tests erfolgreich; lokaler `/me` Request legt genau ein `profiles`-Profil an. - 2026-06-10: RBAC-Policy-Funktion mit tabellengetriebenen Rollen/Aktions-Tests erfolgreich. - 2026-06-10: Frontend-Prototyp mit Supabase Auth und Backend-`/me` Check erfolgreich gebaut. +- 2026-06-11: Membership-Resolver mit Middleware-Tests fuer erlaubte, fehlende und verbotene Tenant-Zugriffe angelegt. +- 2026-06-11: Autorisierungs-Middleware mit Tests fuer erlaubte, fehlende und verweigerte Permissions angelegt. diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 28def34..9145821 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -17,7 +17,10 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" "proxui/backend/internal/auth" + "proxui/backend/internal/authorization" + "proxui/backend/internal/membership" "proxui/backend/internal/profile" + "proxui/backend/internal/rbac" ) func main() { @@ -47,6 +50,8 @@ func main() { defer db.Close() profileMiddleware := profile.NewMiddleware(profile.NewRepository(db), logger) + membershipMiddleware := membership.NewMiddleware(membership.NewRepository(db), logger) + authorizationMiddleware := authorization.NewMiddleware() mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { @@ -59,6 +64,22 @@ func main() { _ = json.NewEncoder(w).Encode(principal) }) mux.Handle("GET /me", authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(meHandler))) + tenantMembershipHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenantMembership, _ := membership.FromRequest(r) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tenantMembership) + }) + mux.Handle( + "GET /tenants/{tenantID}/membership", + authMiddleware.RequireAuth( + profileMiddleware.EnsureProfile( + membershipMiddleware.RequireTenantMembership( + pathTenantID, + authorizationMiddleware.Require(rbac.ActionProjectRead, tenantMembershipHandler), + ), + ), + ), + ) server := &http.Server{ Addr: cfg.BackendAddr, @@ -87,6 +108,11 @@ func main() { } } +func pathTenantID(r *http.Request) (string, bool) { + tenantID := r.PathValue("tenantID") + return tenantID, tenantID != "" +} + func openDatabase(databaseURL string) (*sql.DB, error) { if databaseURL == "" { return nil, fmt.Errorf("DATABASE_URL is required") diff --git a/backend/internal/authorization/middleware.go b/backend/internal/authorization/middleware.go new file mode 100644 index 0000000..21f7670 --- /dev/null +++ b/backend/internal/authorization/middleware.go @@ -0,0 +1,33 @@ +package authorization + +import ( + "encoding/json" + "net/http" + + "proxui/backend/internal/membership" + "proxui/backend/internal/rbac" +) + +type Middleware struct{} + +func NewMiddleware() Middleware { + return Middleware{} +} + +func (m Middleware) Require(action rbac.Action, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + membership, ok := membership.FromRequest(r) + if !ok || !rbac.Can(membership.Role, action) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + + next.ServeHTTP(w, r) + }) +} + +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/authorization/middleware_test.go b/backend/internal/authorization/middleware_test.go new file mode 100644 index 0000000..2621d53 --- /dev/null +++ b/backend/internal/authorization/middleware_test.go @@ -0,0 +1,66 @@ +package authorization + +import ( + "net/http" + "net/http/httptest" + "testing" + + "proxui/backend/internal/membership" + "proxui/backend/internal/rbac" +) + +func TestRequireAllowsRoleWithPermission(t *testing.T) { + middleware := NewMiddleware() + handler := middleware.Require(rbac.ActionProjectManage, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := requestWithMembership(rbac.RoleAdmin) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } +} + +func TestRequireRejectsRoleWithoutPermission(t *testing.T) { + middleware := NewMiddleware() + handler := middleware.Require(rbac.ActionProjectManage, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := requestWithMembership(rbac.RoleViewer) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) + } +} + +func TestRequireRejectsMissingMembership(t *testing.T) { + middleware := NewMiddleware() + handler := middleware.Require(rbac.ActionProjectRead, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/tenants/tenant-1/projects", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) + } +} + +func requestWithMembership(role rbac.Role) *http.Request { + req := httptest.NewRequest(http.MethodGet, "/tenants/tenant-1/projects", nil) + return req.WithContext(membership.ContextWithMembership(req.Context(), membership.Membership{ + TenantID: "tenant-1", + Role: role, + })) +} diff --git a/backend/internal/membership/context.go b/backend/internal/membership/context.go new file mode 100644 index 0000000..14447e9 --- /dev/null +++ b/backend/internal/membership/context.go @@ -0,0 +1,28 @@ +package membership + +import ( + "context" + "net/http" + + "proxui/backend/internal/rbac" +) + +type Membership struct { + TenantID string `json:"tenant_id"` + Role rbac.Role `json:"role"` +} + +type contextKey struct{} + +func ContextWithMembership(ctx context.Context, membership Membership) context.Context { + return context.WithValue(ctx, contextKey{}, membership) +} + +func FromContext(ctx context.Context) (Membership, bool) { + membership, ok := ctx.Value(contextKey{}).(Membership) + return membership, ok +} + +func FromRequest(r *http.Request) (Membership, bool) { + return FromContext(r.Context()) +} diff --git a/backend/internal/membership/middleware.go b/backend/internal/membership/middleware.go new file mode 100644 index 0000000..c114974 --- /dev/null +++ b/backend/internal/membership/middleware.go @@ -0,0 +1,63 @@ +package membership + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + + "proxui/backend/internal/auth" +) + +type Resolver interface { + ResolveTenantMembership(ctx context.Context, profileID string, tenantID string) (Membership, bool, error) +} + +type TenantIDExtractor func(*http.Request) (string, bool) + +type Middleware struct { + resolver Resolver + logger *slog.Logger +} + +func NewMiddleware(resolver Resolver, logger *slog.Logger) Middleware { + return Middleware{ + resolver: resolver, + logger: logger, + } +} + +func (m Middleware) RequireTenantMembership(extractTenantID TenantIDExtractor, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, ok := auth.PrincipalFromRequest(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + tenantID, ok := extractTenantID(r) + if !ok || tenantID == "" { + writeError(w, http.StatusBadRequest, "tenant_id_required") + return + } + + membership, found, err := m.resolver.ResolveTenantMembership(r.Context(), principal.Subject, tenantID) + if err != nil { + m.logger.Error("membership resolve failed", "error", err) + writeError(w, http.StatusInternalServerError, "membership_resolve_failed") + return + } + if !found { + writeError(w, http.StatusForbidden, "tenant_forbidden") + return + } + + next.ServeHTTP(w, r.WithContext(ContextWithMembership(r.Context(), membership))) + }) +} + +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/membership/middleware_test.go b/backend/internal/membership/middleware_test.go new file mode 100644 index 0000000..2098d2d --- /dev/null +++ b/backend/internal/membership/middleware_test.go @@ -0,0 +1,159 @@ +package membership + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "proxui/backend/internal/auth" + "proxui/backend/internal/rbac" +) + +func TestRequireTenantMembershipStoresMembership(t *testing.T) { + resolver := &stubResolver{ + membership: Membership{ + TenantID: "tenant-1", + Role: rbac.RoleAdmin, + }, + found: true, + } + middleware := NewMiddleware(resolver, slog.Default()) + handler := middleware.RequireTenantMembership(staticTenantID("tenant-1"), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + membership, ok := FromRequest(r) + if !ok { + t.Fatal("membership missing from request") + } + if membership.TenantID != "tenant-1" { + t.Fatalf("tenant id = %q, want tenant-1", membership.TenantID) + } + if membership.Role != rbac.RoleAdmin { + t.Fatalf("role = %q, want %q", membership.Role, rbac.RoleAdmin) + } + w.WriteHeader(http.StatusNoContent) + })) + + req := requestWithPrincipal() + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } + if resolver.profileID != "profile-1" { + t.Fatalf("profile id = %q, want profile-1", resolver.profileID) + } + if resolver.tenantID != "tenant-1" { + t.Fatalf("tenant id = %q, want tenant-1", resolver.tenantID) + } +} + +func TestRequireTenantMembershipRejectsMissingPrincipal(t *testing.T) { + resolver := &stubResolver{found: true} + middleware := NewMiddleware(resolver, slog.Default()) + handler := middleware.RequireTenantMembership(staticTenantID("tenant-1"), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/tenants/tenant-1/membership", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } + if resolver.calls != 0 { + t.Fatalf("resolver calls = %d, want 0", resolver.calls) + } +} + +func TestRequireTenantMembershipRejectsMissingTenantID(t *testing.T) { + resolver := &stubResolver{found: true} + middleware := NewMiddleware(resolver, slog.Default()) + handler := middleware.RequireTenantMembership(func(*http.Request) (string, bool) { + return "", false + }, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := requestWithPrincipal() + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + if resolver.calls != 0 { + t.Fatalf("resolver calls = %d, want 0", resolver.calls) + } +} + +func TestRequireTenantMembershipRejectsNonMember(t *testing.T) { + resolver := &stubResolver{found: false} + middleware := NewMiddleware(resolver, slog.Default()) + handler := middleware.RequireTenantMembership(staticTenantID("tenant-1"), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := requestWithPrincipal() + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) + } +} + +func TestRequireTenantMembershipReturnsServerErrorOnResolverFailure(t *testing.T) { + resolver := &stubResolver{err: errors.New("db failed")} + middleware := NewMiddleware(resolver, slog.Default()) + handler := middleware.RequireTenantMembership(staticTenantID("tenant-1"), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := requestWithPrincipal() + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError) + } +} + +func requestWithPrincipal() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/tenants/tenant-1/membership", nil) + return req.WithContext(auth.ContextWithPrincipal(req.Context(), auth.Principal{ + Subject: "profile-1", + Email: "user@example.test", + Role: "authenticated", + })) +} + +func staticTenantID(tenantID string) TenantIDExtractor { + return func(*http.Request) (string, bool) { + return tenantID, true + } +} + +type stubResolver struct { + calls int + profileID string + tenantID string + membership Membership + found bool + err error +} + +func (s *stubResolver) ResolveTenantMembership(_ context.Context, profileID string, tenantID string) (Membership, bool, error) { + s.calls++ + s.profileID = profileID + s.tenantID = tenantID + return s.membership, s.found, s.err +} diff --git a/backend/internal/membership/repository.go b/backend/internal/membership/repository.go new file mode 100644 index 0000000..960bb8d --- /dev/null +++ b/backend/internal/membership/repository.go @@ -0,0 +1,39 @@ +package membership + +import ( + "context" + "database/sql" + "errors" + + "proxui/backend/internal/rbac" +) + +type Repository struct { + db *sql.DB +} + +func NewRepository(db *sql.DB) Repository { + return Repository{db: db} +} + +func (r Repository) ResolveTenantMembership(ctx context.Context, profileID string, tenantID string) (Membership, bool, error) { + var membership Membership + var role string + err := r.db.QueryRowContext(ctx, ` + select m.tenant_id::text, m.role::text + from public.memberships m + join public.tenants t on t.id = m.tenant_id + where m.profile_id = $1 + and m.tenant_id = $2 + and t.status = 'active' + `, profileID, tenantID).Scan(&membership.TenantID, &role) + if errors.Is(err, sql.ErrNoRows) { + return Membership{}, false, nil + } + if err != nil { + return Membership{}, false, err + } + + membership.Role = rbac.Role(role) + return membership, true, nil +}