From 000c08dae7df0ac072a20907a18d52c344dbfd12 Mon Sep 17 00:00:00 2001 From: Philipp Date: Wed, 10 Jun 2026 16:32:46 +0200 Subject: [PATCH] feat: add profile sync --- CHANGELOG.md | 1 + README.md | 4 +- TODO.md | 6 ++ backend/cmd/api/main.go | 38 +++++++- backend/go.mod | 11 +++ backend/go.sum | 21 +++++ backend/internal/auth/middleware.go | 2 +- backend/internal/auth/principal.go | 2 +- backend/internal/profile/middleware.go | 50 +++++++++++ backend/internal/profile/middleware_test.go | 99 +++++++++++++++++++++ backend/internal/profile/repository.go | 25 ++++++ 11 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 backend/go.sum create mode 100644 backend/internal/profile/middleware.go create mode 100644 backend/internal/profile/middleware_test.go create mode 100644 backend/internal/profile/repository.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 527bc2e..9241b83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Lazy Profil-Sync vom Supabase-JWT nach `public.profiles` im Backend angelegt. - Backend-JWT-Middleware mit JWKS-Validierung, lokalem HS256-Fallback und geschuetztem `/me` Endpunkt angelegt. - RLS-Advisor-Cleanup fuer alle uebrigen Public-Tabellen angelegt. - RLS-Migration fuer tenant-bezogene Tabellen mit Membership-basierten Policies angelegt. diff --git a/README.md b/README.md index 8e272bf..67d9553 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Supabase self-hosted liegt unter `deploy/supabase/`. Die lokale `deploy/supabase Migrationen nutzen `MIGRATE_DATABASE_URL`, falls gesetzt. Andernfalls wird `DATABASE_DIRECT_URL` aus `.env` oder eine lokale Supavisor-URL aus `deploy/supabase/.env` verwendet. -Das Backend validiert Supabase-JWTs ueber `SUPABASE_JWKS_URL` und `SUPABASE_ISSUER`. Fuer das lokale Self-Hosted-Setup mit leerem JWKS wird zusaetzlich `SUPABASE_JWT_SECRET` als HS256-Fallback genutzt. +Das Backend validiert Supabase-JWTs ueber `SUPABASE_JWKS_URL` und `SUPABASE_ISSUER`. Fuer das lokale Self-Hosted-Setup mit leerem JWKS wird zusaetzlich `SUPABASE_JWT_SECRET` als HS256-Fallback genutzt. Beim ersten authentifizierten Request synchronisiert das Backend den Supabase-User lazy nach `public.profiles`. Lokale Dienste: @@ -55,7 +55,7 @@ Aktuelle Targets: Backend-Endpunkte: - `GET /healthz`: oeffentlicher Healthcheck -- `GET /me`: geschuetzt, gibt den authentifizierten Principal aus dem JWT zurueck +- `GET /me`: geschuetzt, synchronisiert `profiles` und gibt den authentifizierten Principal aus dem JWT zurueck ## CI diff --git a/TODO.md b/TODO.md index 32dfc56..b9b1877 100644 --- a/TODO.md +++ b/TODO.md @@ -77,6 +77,11 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - [x] Issuer, Expiry, Not-Before und Signatur werden validiert - [x] `sub`, `email`, `role` werden als Principal in den Request-Context gelegt - [x] Geschuetzten `/me` Endpunkt angelegt +- [x] E1-T03: Profil-Sync + - [x] Lazy Provisioning nach erfolgreicher JWT-Validierung angelegt + - [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 ## MVP-Backlog @@ -125,3 +130,4 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - 2026-06-10: RLS-Migration `0008_rls_policies` angelegt und Mitglied/Nicht-Mitglied-Isolation lokal gegen Supabase verifiziert. - 2026-06-10: RLS-Advisor-Cleanup `0009_rls_advisor_cleanup` angelegt; alle Public-Tabellen haben RLS aktiv. - 2026-06-10: JWT-Middleware-Tests fuer gueltige, abgelaufene, manipulierte und fehlende Tokens erfolgreich. +- 2026-06-10: Profil-Sync-Tests erfolgreich; lokaler `/me` Request legt genau ein `profiles`-Profil an. diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 06e7045..d1cd169 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -2,18 +2,22 @@ package main import ( "context" + "database/sql" "encoding/json" "errors" + "fmt" "net/http" "os" "os/signal" "syscall" "time" - "proxui/backend/internal/auth" - "forgejo.digital-droplets.de/philschlo/proxui/platform/config" "forgejo.digital-droplets.de/philschlo/proxui/platform/logging" + _ "github.com/jackc/pgx/v5/stdlib" + + "proxui/backend/internal/auth" + "proxui/backend/internal/profile" ) func main() { @@ -35,17 +39,26 @@ func main() { os.Exit(1) } authMiddleware := auth.NewMiddleware(jwtValidator) + db, err := openDatabase(cfg.DatabaseURL) + if err != nil { + logger.Error("failed to connect database", "error", err) + os.Exit(1) + } + defer db.Close() + + profileMiddleware := profile.NewMiddleware(profile.NewRepository(db), logger) mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":"ok","service":"backend"}`)) }) - mux.Handle("GET /me", authMiddleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + meHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { principal, _ := auth.PrincipalFromRequest(r) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(principal) - }))) + }) + mux.Handle("GET /me", authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(meHandler))) server := &http.Server{ Addr: cfg.BackendAddr, @@ -73,3 +86,20 @@ func main() { os.Exit(1) } } + +func openDatabase(databaseURL string) (*sql.DB, error) { + if databaseURL == "" { + return nil, fmt.Errorf("DATABASE_URL is required") + } + + db, err := sql.Open("pgx", databaseURL) + if err != nil { + return nil, err + } + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, err + } + + return db, nil +} diff --git a/backend/go.mod b/backend/go.mod index e8bdb80..8249ed8 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,3 +1,14 @@ module proxui/backend go 1.22 + +require github.com/jackc/pgx/v5 v5.7.6 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..7fa6af1 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,21 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go index b3e4097..9ac6d25 100644 --- a/backend/internal/auth/middleware.go +++ b/backend/internal/auth/middleware.go @@ -28,7 +28,7 @@ func (m Middleware) RequireAuth(next http.Handler) http.Handler { return } - next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), principal))) + next.ServeHTTP(w, r.WithContext(ContextWithPrincipal(r.Context(), principal))) }) } diff --git a/backend/internal/auth/principal.go b/backend/internal/auth/principal.go index 8db8660..cb9263d 100644 --- a/backend/internal/auth/principal.go +++ b/backend/internal/auth/principal.go @@ -18,7 +18,7 @@ func PrincipalFromContext(ctx context.Context) (Principal, bool) { return principal, ok } -func withPrincipal(ctx context.Context, principal Principal) context.Context { +func ContextWithPrincipal(ctx context.Context, principal Principal) context.Context { return context.WithValue(ctx, principalContextKey{}, principal) } diff --git a/backend/internal/profile/middleware.go b/backend/internal/profile/middleware.go new file mode 100644 index 0000000..68bbee7 --- /dev/null +++ b/backend/internal/profile/middleware.go @@ -0,0 +1,50 @@ +package profile + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + + "proxui/backend/internal/auth" +) + +type Ensurer interface { + Ensure(ctx context.Context, id string, email string) error +} + +type Middleware struct { + ensurer Ensurer + logger *slog.Logger +} + +func NewMiddleware(ensurer Ensurer, logger *slog.Logger) Middleware { + return Middleware{ + ensurer: ensurer, + logger: logger, + } +} + +func (m Middleware) EnsureProfile(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 + } + + if err := m.ensurer.Ensure(r.Context(), principal.Subject, principal.Email); err != nil { + m.logger.Error("profile sync failed", "error", err) + writeError(w, http.StatusInternalServerError, "profile_sync_failed") + 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/profile/middleware_test.go b/backend/internal/profile/middleware_test.go new file mode 100644 index 0000000..b61f774 --- /dev/null +++ b/backend/internal/profile/middleware_test.go @@ -0,0 +1,99 @@ +package profile + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "proxui/backend/internal/auth" +) + +func TestEnsureProfileCreatesMissingProfile(t *testing.T) { + ensurer := &stubEnsurer{} + middleware := NewMiddleware(ensurer, slog.Default()) + handler := middleware.EnsureProfile(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/me", nil) + req = req.WithContext(auth.ContextWithPrincipal(req.Context(), auth.Principal{ + Subject: "00000000-0000-0000-0000-000000000001", + Email: "user@example.test", + Role: "authenticated", + })) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } + if ensurer.calls != 1 { + t.Fatalf("Ensure calls = %d, want 1", ensurer.calls) + } + if ensurer.id != "00000000-0000-0000-0000-000000000001" { + t.Fatalf("id = %q", ensurer.id) + } + if ensurer.email != "user@example.test" { + t.Fatalf("email = %q", ensurer.email) + } +} + +func TestEnsureProfileRejectsMissingPrincipal(t *testing.T) { + ensurer := &stubEnsurer{} + middleware := NewMiddleware(ensurer, slog.Default()) + handler := middleware.EnsureProfile(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/me", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } + if ensurer.calls != 0 { + t.Fatalf("Ensure calls = %d, want 0", ensurer.calls) + } +} + +func TestEnsureProfileReturnsServerErrorOnSyncFailure(t *testing.T) { + ensurer := &stubEnsurer{err: errors.New("db failed")} + middleware := NewMiddleware(ensurer, slog.Default()) + handler := middleware.EnsureProfile(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/me", nil) + req = req.WithContext(auth.ContextWithPrincipal(req.Context(), auth.Principal{ + Subject: "00000000-0000-0000-0000-000000000001", + Email: "user@example.test", + Role: "authenticated", + })) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError) + } +} + +type stubEnsurer struct { + calls int + id string + email string + err error +} + +func (s *stubEnsurer) Ensure(_ context.Context, id string, email string) error { + s.calls++ + s.id = id + s.email = email + return s.err +} diff --git a/backend/internal/profile/repository.go b/backend/internal/profile/repository.go new file mode 100644 index 0000000..7673774 --- /dev/null +++ b/backend/internal/profile/repository.go @@ -0,0 +1,25 @@ +package profile + +import ( + "context" + "database/sql" +) + +type Repository struct { + db *sql.DB +} + +func NewRepository(db *sql.DB) Repository { + return Repository{db: db} +} + +func (r Repository) Ensure(ctx context.Context, id string, email string) error { + _, err := r.db.ExecContext(ctx, ` + insert into public.profiles (id, email) + values ($1, $2) + on conflict (id) do update + set email = excluded.email + where public.profiles.email is distinct from excluded.email + `, id, email) + return err +}