feat: add tenant authorization middleware
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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.
|
- Frontend-Prototyp mit Supabase Auth, Backend-Profilcheck und Platzhalter-Views angelegt.
|
||||||
- Zentrale RBAC-Policy-Funktion mit Rollen/Aktions-Matrix angelegt.
|
- Zentrale RBAC-Policy-Funktion mit Rollen/Aktions-Matrix angelegt.
|
||||||
- Lazy Profil-Sync vom Supabase-JWT nach `public.profiles` im Backend angelegt.
|
- Lazy Profil-Sync vom Supabase-JWT nach `public.profiles` im Backend angelegt.
|
||||||
|
|||||||
@@ -56,6 +56,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
|
||||||
|
|
||||||
|
Tenant-gescoped Backend-Routen laufen ueber JWT-Auth, Profil-Sync, Membership-Resolver und deklarative RBAC-Pruefung.
|
||||||
|
|
||||||
Frontend-Prototyp:
|
Frontend-Prototyp:
|
||||||
|
|
||||||
|
|||||||
@@ -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] `profiles` wird idempotent per `insert ... on conflict` erstellt/aktualisiert
|
||||||
- [x] Profil-Sync-Middleware mit Unit-Tests angelegt
|
- [x] Profil-Sync-Middleware mit Unit-Tests angelegt
|
||||||
- [x] `/me` fuehrt Profil-Sync vor Handler-Ausfuehrung aus
|
- [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] E4-T02: Policy-/Permission-Funktion
|
||||||
- [x] Rollen und Aktionen zentral definiert
|
- [x] Rollen und Aktionen zentral definiert
|
||||||
- [x] `Can(role, action)` angelegt
|
- [x] `Can(role, action)` angelegt
|
||||||
- [x] Tabellengetriebene Unit-Tests fuer Rollen/Aktionen angelegt
|
- [x] Tabellengetriebene Unit-Tests fuer Rollen/Aktionen angelegt
|
||||||
- [x] `cluster.manage` fuer Tenant-Rollen bewusst verweigert
|
- [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] E10-Vorgriff: Frontend-Prototyp
|
||||||
- [x] Supabase Auth Login/Registrierung angebunden
|
- [x] Supabase Auth Login/Registrierung angebunden
|
||||||
- [x] Backend-`/me` Profil-Sync-Pruefung 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
|
- [ ] 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
|
- [ ] 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
|
- [ ] E6: Worker-Grundgeruest und UPID-Polling
|
||||||
- [ ] E5: VM-Liste, Detail, Power-Aktionen und Reconciliation
|
- [ ] E5: VM-Liste, Detail, Power-Aktionen und Reconciliation
|
||||||
- [ ] E7: SSH-Keys, Templates und Provisioning
|
- [ ] 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: 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: 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-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.
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import (
|
|||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
|
||||||
"proxui/backend/internal/auth"
|
"proxui/backend/internal/auth"
|
||||||
|
"proxui/backend/internal/authorization"
|
||||||
|
"proxui/backend/internal/membership"
|
||||||
"proxui/backend/internal/profile"
|
"proxui/backend/internal/profile"
|
||||||
|
"proxui/backend/internal/rbac"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -47,6 +50,8 @@ func main() {
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
profileMiddleware := profile.NewMiddleware(profile.NewRepository(db), logger)
|
profileMiddleware := profile.NewMiddleware(profile.NewRepository(db), logger)
|
||||||
|
membershipMiddleware := membership.NewMiddleware(membership.NewRepository(db), logger)
|
||||||
|
authorizationMiddleware := authorization.NewMiddleware()
|
||||||
|
|
||||||
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) {
|
||||||
@@ -59,6 +64,22 @@ func main() {
|
|||||||
_ = json.NewEncoder(w).Encode(principal)
|
_ = json.NewEncoder(w).Encode(principal)
|
||||||
})
|
})
|
||||||
mux.Handle("GET /me", authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(meHandler)))
|
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{
|
server := &http.Server{
|
||||||
Addr: cfg.BackendAddr,
|
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) {
|
func openDatabase(databaseURL string) (*sql.DB, error) {
|
||||||
if databaseURL == "" {
|
if databaseURL == "" {
|
||||||
return nil, fmt.Errorf("DATABASE_URL is required")
|
return nil, fmt.Errorf("DATABASE_URL is required")
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user