feat: add profile sync
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+34
-4
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user