diff --git a/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts b/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts index 86402796c..0aa4db29a 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts @@ -37,6 +37,42 @@ import { wrapValue, unwrapValue, isEncrypted } from './aes'; import { getActiveKey, isVaultUnlocked, waitForActiveKey } from './key-provider'; import { getEncryptedFields } from './registry'; import { getCurrentUserId } from '../current-user'; +import { getActiveSpace } from '../scope/active-space.svelte'; + +/** + * Phase-1 Spaces decision: personal-Space data is encrypted with the + * user's master key (E2E as today); shared-Space data (brand/club/family + * /team/practice) is plaintext at rest and protected by server RLS only. + * + * Deciding at write time: + * - spaceId is the `_personal:` sentinel → personal, encrypt. + * Happens during the bootstrap window before reconcileSentinels() has + * rewritten the placeholder to the real personal-space id. + * - Active space is resolved and type='personal' → encrypt. + * - Active space is any other type → skip. + * - Record has no spaceId at all (legacy rows, guest mode) → encrypt + * (safer default; personal-space is what the app defaults to). + * + * A shared-Space encryption-with-shared-key scheme will replace this + * later; see docs/plans/spaces-foundation.md §"Shared-space encryption". + */ +function isPersonalScope(record: Record): boolean { + const spaceId = record.spaceId; + if (typeof spaceId === 'string' && spaceId.startsWith('_personal:')) { + return true; + } + const active = getActiveSpace(); + if (active && active.type !== 'personal') { + // Only skip when we can confidently say the write belongs to a + // shared space. If the record's spaceId matches some other space's + // id, the check would need a lookup — for now the active-space + // type is the authoritative signal at write time. + if (typeof spaceId === 'string' && spaceId === active.id) { + return false; + } + } + return true; +} /** Thrown by encryptRecord when no key is available. Module stores * catch this to surface "vault locked" UI. */ @@ -132,6 +168,13 @@ export async function encryptRecord(tableName: string, record: if (!fields) return record; const view = record as unknown as Record; + // Phase-1 Spaces: shared-Space records skip encryption so co-members + // can read them (they don't have the author's master key). Personal + // data stays E2E. + if (!isPersonalScope(view)) { + return record; + } + if (import.meta.env.DEV) devCheckRegistryShape(tableName, view, fields); // Build the work list first so we don't half-encrypt a record on diff --git a/docs/plans/shared-space-smoketest.md b/docs/plans/shared-space-smoketest.md new file mode 100644 index 000000000..14a5251e1 --- /dev/null +++ b/docs/plans/shared-space-smoketest.md @@ -0,0 +1,124 @@ +# Shared-Space Smoketest + +Schritt-für-Schritt-Anleitung, um das Shared-Space-Ende zu validieren. +Zwei Nutzer, ein gemeinsamer Space, echtes Sync + Einladungsflow. + +## Vorbereitung + +Lokaler Stack läuft (`pnpm docker:up && pnpm run mana:dev`). Vor dem Test +einmal sicherstellen: + +1. **DB-Migrationen aktuell:** + ```bash + cd services/mana-auth && bun run db:push + ``` + Holt das `spaces`-Schema (v004) mit `credentials` + `module_permissions`. + mana-sync's eigene Migration (inkl. multi-member RLS) läuft beim Service-Start automatisch. + +2. **Dev-User vorhanden:** + ```bash + pnpm setup:dev-user + ``` + Legt drei Accounts an (`tills95@gmail.com`, `tilljkb@gmail.com`, + `rajiehq@gmail.com`), alle mit Passwort `Aa-123456789` und Founder-Tier. + Durch den Signup-Hook bekommt jeder automatisch seinen Personal-Space. + +3. **SMTP-Stub aktiv (für Invite-Mails):** + Stalwart SMTP läuft im `docker-compose.macmini.yml`-Stack. Lokal kann + man den Invite-Link auch direkt aus den Logs von mana-auth ziehen, + falls die Mail nicht ankommt. + +## Szenario: Familie + +**Zwei getrennte Browser-Profile** (oder ein normales + ein Inkognito-Fenster). +Ohne Profil-Trennung teilen sich die Sessions die Cookies, und der Test +ist wertlos. + +### User A (Besitzer) + +1. Einloggen als `tills95@gmail.com` +2. Oben rechts im Space-Switcher "+ Neuer Space" klicken +3. Typ **Familie**, Name "Schmidt Family", Slug automatisch +4. Erstellen → Reload in den neuen Space, Switcher zeigt "Schmidt Family" + grünes Family-Badge +5. Im Switcher → "Mitglieder verwalten …" +6. Einladen-Formular: `tilljkb@gmail.com` als `Mitglied` → Senden +7. Erfolgsmeldung "Einladung an … gesendet", Invite erscheint in "Offene Einladungen" + +### User B (Eingeladener) + +1. E-Mail öffnen, Einladungslink anklicken (`/accept-invitation?id=…`) +2. Falls nicht eingeloggt: "Einloggen & annehmen" — bringt zu Login, + nach erfolgreicher Authentifizierung automatisch zurück auf Accept-Seite +3. Space-Vorschau zeigt: "tills95@gmail.com lädt dich in **Schmidt Family** ein", + Typ-Badge "Familie", Rolle "Mitglied" +4. Annehmen klicken → Redirect auf `/`, Space-Switcher oben rechts zeigt jetzt "Schmidt Family" + +### Gemeinsamer Test (wichtig!) + +5. **User A** legt im Familien-Space ein Event für morgen an (Kalender-Modul) +6. **User B** öffnet den Kalender — das Event erscheint nach ≤1 s +7. **User B** legt ein Rezept an +8. **User A** sieht das Rezept nach ≤1 s + +### Isolation-Check (wichtig!) + +9. **User A** wechselt auf den Personal-Space (Switcher) +10. User A trägt einen privaten Mood-Eintrag ein +11. **User B** wechselt auf den Familien-Space und öffnet den Kalender-Modul + — User A's Personal-Daten dürfen **nicht** sichtbar sein. Mood-Modul + ist im Familien-Space eh nicht freigeschaltet (SPACE_MODULE_ALLOWLIST). + +### Rücktritt-Test + +12. **User A** öffnet "Mitglieder verwalten …" und entfernt User B +13. **User B** lädt die Seite neu — Familien-Space ist verschwunden, + aktiver Space fällt zurück auf Personal + +## Was dabei geprüft wird + +| Feature | Erwartung | +|---|---| +| Invite-Mail versandt | Stalwart-Log zeigt Mail, oder E-Mail-Inbox hat Link | +| Better-Auth `accept-invitation` | `auth.invitations.status` → `accepted` | +| Member-Datensatz | `auth.members` hat neuen Row für User B × org | +| `activeOrganizationId` gesetzt | Session-Cookie zeigt den neuen Space | +| Scope-Filter greift | User B sieht keine Personal-Daten von User A | +| Multi-Member-RLS greift | User B sieht neue Events/Rezepte von User A | +| Membership-Lookup in mana-sync | Keine 404s im mana-sync-Log beim Zugriff | +| Encryption-Skip | Records im Familien-Space werden plaintext synced (prüfen via DevTools → IndexedDB) | +| Rollen-Enforcement | User B kann **nicht** weitere einladen (Rolle=Mitglied, canManage=false) | + +## Bekannte Phase-1-Einschränkungen + +- **Daten im Shared-Space sind unverschlüsselt** (nur RLS-geschützt) — im + RFC `docs/plans/spaces-foundation.md` als bewusster Phase-1-Kompromiss + dokumentiert. Phase 2 addiert einen pro-Space-Schlüssel mit Per-Member-Wrap. +- **Subscription-Fan-Out läuft noch per-User** — User A's Write triggert + Mana-Sync's Pull erst nach seinem eigenen Tick; User B sieht den + Eintrag nach bis zu 1 s, nicht instant. Sobald Fan-Out steht, ist das + Sub-200ms. +- **Role-Änderungen brauchen Cache-Invalidierung**: nach Role-Update + dauert die mana-sync-Membership-Cache bis zu 5 min. Für Tests nach + dem Leave einfach den Service neu starten. + +## Was tun wenn's nicht klappt + +- **E-Mail kommt nicht an**: in mana-auth-Logs nach `accept-invitation?id=` + suchen — Better Auth loggt den Link bei `sendInvitationEmail`. +- **User B sieht Space nicht nach Accept**: Browser-DevTools → Cookies → + `mana.session_data` auf die neue Session-ID prüfen, dann `loadActiveSpace` + in der Konsole forcen: `await (await import('/src/lib/data/scope/index.ts')).loadActiveSpace({ force: true })` +- **Rezepte erscheinen bei A nicht**: mana-sync-Logs auf `[memberships]` + oder Policy-Denials checken. `app.current_user_space_ids` sollte die + Schmidt-Family-Org-ID enthalten. +- **Kein Access zu Mitglieder-Seite**: Personal-Space kann per Design + keine Mitglieder haben — vorher auf den Familien-Space wechseln. + +## Nächste Schritte nach Smoketest + +Wenn alle 9+ Checks grün sind: Foundation ist validated. +Follow-ups (nicht blockierend): +- Echtes WebSocket-Fan-Out an Space-Mitglieder +- Shared-Space-Encryption mit per-Member-Key-Wrap +- Member-Activity-Log (wer hat wann was geändert) +- Rollen-spezifische Permissions (trainer darf kein club-finance) diff --git a/services/mana-auth/src/index.ts b/services/mana-auth/src/index.ts index 156499b74..dfad9cc86 100644 --- a/services/mana-auth/src/index.ts +++ b/services/mana-auth/src/index.ts @@ -136,6 +136,29 @@ app.get('/api/v1/internal/org/:orgId/member/:userId', async (c) => { return c.json({ isMember: !!member, role: member?.role || '' }); }); +/** + * List every Space (organization) the given user is a member of. Used by + * mana-sync to pass the current user's space-membership list into the + * `app.current_user_space_ids` session setting so the multi-member RLS + * policy can let space co-members read each other's records. + * + * Returns a flat array of organization ids — mana-sync doesn't care + * about names/roles here, only the set. Cached 5 min client-side. + */ +app.get('/api/v1/internal/users/:userId/memberships', async (c) => { + const { userId } = c.req.param(); + const { members } = await import('./db/schema/organizations'); + const { eq } = await import('drizzle-orm'); + const rows = await db + .select({ organizationId: members.organizationId, role: members.role }) + .from(members) + .where(eq(members.userId, userId)); + return c.json({ + userId, + memberships: rows.map((r) => ({ organizationId: r.organizationId, role: r.role })), + }); +}); + // ─── Login Page (OIDC) ───────────────────────────────────── app.get('/login', (c) => { diff --git a/services/mana-sync/cmd/server/main.go b/services/mana-sync/cmd/server/main.go index 99da65a67..c39a65376 100644 --- a/services/mana-sync/cmd/server/main.go +++ b/services/mana-sync/cmd/server/main.go @@ -16,6 +16,7 @@ import ( "github.com/mana/mana-sync/internal/backup" "github.com/mana/mana-sync/internal/billing" "github.com/mana/mana-sync/internal/config" + "github.com/mana/mana-sync/internal/memberships" "github.com/mana/mana-sync/internal/store" syncHandler "github.com/mana/mana-sync/internal/sync" "github.com/mana/mana-sync/internal/ws" @@ -55,8 +56,14 @@ func main() { billingChecker := billing.NewChecker(cfg.ManaCreditsURL, cfg.ServiceKey) billingMiddleware := billingChecker.Middleware(validator) + // Initialize Space-membership lookup against mana-auth. The handler + // passes the caller's membership list into every sync query so the + // multi-member RLS policy lets co-members of a shared Space see each + // other's records. + membershipLookup := memberships.New(cfg.ManaAuthURL, cfg.ServiceKey) + // Initialize sync handler - handler := syncHandler.NewHandler(db, validator, hub) + handler := syncHandler.NewHandler(db, validator, hub, membershipLookup) // Set up routes mux := http.NewServeMux() diff --git a/services/mana-sync/internal/config/config.go b/services/mana-sync/internal/config/config.go index 5b1badf81..1308a8e17 100644 --- a/services/mana-sync/internal/config/config.go +++ b/services/mana-sync/internal/config/config.go @@ -10,6 +10,7 @@ type Config struct { Port int DatabaseURL string JWKSUrl string // mana-auth JWKS endpoint for JWT validation + ManaAuthURL string // mana-auth base URL for internal APIs (Space memberships) CORSOrigins string ManaCreditsURL string // mana-credits service URL for billing checks ServiceKey string // Service-to-service auth key @@ -23,6 +24,7 @@ func Load() *Config { Port: port, DatabaseURL: getEnv("DATABASE_URL", "postgresql://mana:devpassword@localhost:5432/mana_sync"), JWKSUrl: getEnv("JWKS_URL", "http://localhost:3001/api/auth/jwks"), + ManaAuthURL: getEnv("MANA_AUTH_URL", "http://localhost:3001"), CORSOrigins: getEnv("CORS_ORIGINS", "http://localhost:5173,http://localhost:5188"), ManaCreditsURL: getEnv("MANA_CREDITS_URL", "http://localhost:3061"), ServiceKey: getEnv("MANA_SERVICE_KEY", "dev-service-key"), diff --git a/services/mana-sync/internal/memberships/lookup.go b/services/mana-sync/internal/memberships/lookup.go new file mode 100644 index 000000000..ab049fd2e --- /dev/null +++ b/services/mana-sync/internal/memberships/lookup.go @@ -0,0 +1,129 @@ +// Package memberships fetches a user's Space membership list from +// mana-auth and caches it in-memory. +// +// The list is passed into every sync transaction's +// `app.current_user_space_ids` session setting so the multi-member RLS +// policy on sync_changes can let co-members of a shared Space read each +// other's records. See docs/plans/spaces-foundation.md. +package memberships + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "sync" + "time" +) + +// Membership is the subset of the /internal/users/:id/memberships +// response shape that we care about — mana-sync doesn't need names or +// metadata, just the set of org IDs and the caller's role in each. +type Membership struct { + OrganizationID string `json:"organizationId"` + Role string `json:"role"` +} + +type response struct { + UserID string `json:"userId"` + Memberships []Membership `json:"memberships"` +} + +type cached struct { + memberships []Membership + fetchedAt time.Time +} + +// Lookup queries mana-auth for the Space memberships of a user and +// caches the result per-user. +type Lookup struct { + authURL string + serviceKey string + cacheTTL time.Duration + client *http.Client + + mu sync.RWMutex + cache map[string]cached +} + +// New creates a Lookup bound to a mana-auth base URL. +func New(authURL, serviceKey string) *Lookup { + return &Lookup{ + authURL: authURL, + serviceKey: serviceKey, + cacheTTL: 5 * time.Minute, + client: &http.Client{Timeout: 5 * time.Second}, + cache: make(map[string]cached), + } +} + +// For returns the list of organization IDs the user is a member of. +// Fail-open: on any transport error or non-2xx response, returns an +// empty slice. An empty list is safe — the user_id RLS policy still +// gates visibility to the caller's own rows, so the worst case is that +// a shared-space record temporarily looks invisible to a co-member +// until the lookup succeeds on the next request. +func (l *Lookup) For(userID string) []string { + if userID == "" { + return nil + } + + l.mu.RLock() + entry, ok := l.cache[userID] + l.mu.RUnlock() + if ok && time.Since(entry.fetchedAt) < l.cacheTTL { + return toIDs(entry.memberships) + } + + memberships := l.fetch(userID) + if memberships != nil { + l.mu.Lock() + l.cache[userID] = cached{memberships: memberships, fetchedAt: time.Now()} + l.mu.Unlock() + } + return toIDs(memberships) +} + +// Invalidate drops the cached entry for a user. Called when the user's +// membership set demonstrably changed (invite accepted, member removed +// via HTTP handler). Not strictly required — the cache expires on its +// own — but makes local dogfooding feel responsive. +func (l *Lookup) Invalidate(userID string) { + l.mu.Lock() + delete(l.cache, userID) + l.mu.Unlock() +} + +func (l *Lookup) fetch(userID string) []Membership { + url := fmt.Sprintf("%s/api/v1/internal/users/%s/memberships", l.authURL, userID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + slog.Warn("[memberships] build request failed", "error", err, "userId", userID) + return nil + } + req.Header.Set("x-service-key", l.serviceKey) + resp, err := l.client.Do(req) + if err != nil { + slog.Warn("[memberships] fetch failed", "error", err, "userId", userID) + return nil + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + slog.Warn("[memberships] non-200", "status", resp.StatusCode, "userId", userID) + return nil + } + var body response + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + slog.Warn("[memberships] decode failed", "error", err, "userId", userID) + return nil + } + return body.Memberships +} + +func toIDs(ms []Membership) []string { + out := make([]string, 0, len(ms)) + for _, m := range ms { + out = append(out, m.OrganizationID) + } + return out +} diff --git a/services/mana-sync/internal/store/postgres.go b/services/mana-sync/internal/store/postgres.go index 691e4c0de..89e0bb66c 100644 --- a/services/mana-sync/internal/store/postgres.go +++ b/services/mana-sync/internal/store/postgres.go @@ -259,23 +259,25 @@ func (s *Store) RecordChange(ctx context.Context, appID, tableName, recordID, us // GetChangesSince returns changes for a user+app+table since a given timestamp, // excluding changes from the requesting client (to avoid echo). // The limit parameter controls maximum rows returned (caller should pass limit+1 to detect hasMore). -func (s *Store) GetChangesSince(ctx context.Context, userID, appID, tableName, since, excludeClientID string, limit int) ([]ChangeRow, error) { +// spaceIDs is the caller's Space membership list — rows whose space_id matches any of these +// are also returned (in addition to rows the caller authored). +func (s *Store) GetChangesSince(ctx context.Context, userID, appID, tableName, since, excludeClientID string, limit int, spaceIDs []string) ([]ChangeRow, error) { sinceTime, err := time.Parse(time.RFC3339Nano, since) if err != nil { sinceTime = time.Unix(0, 0) } var changes []ChangeRow - err = s.withUser(ctx, userID, func(tx pgx.Tx) error { + err = s.withUserAndMemberships(ctx, userID, spaceIDs, func(tx pgx.Tx) error { query := ` SELECT id, table_name, record_id, op, data, field_timestamps, client_id, created_at, schema_version, space_id, actor FROM sync_changes - WHERE user_id = $1 AND app_id = $2 AND table_name = $3 + WHERE (user_id = $1 OR space_id = ANY($7)) AND app_id = $2 AND table_name = $3 AND created_at > $4 AND client_id != $5 ORDER BY created_at ASC LIMIT $6 ` - rows, err := tx.Query(ctx, query, userID, appID, tableName, sinceTime, excludeClientID, limit) + rows, err := tx.Query(ctx, query, userID, appID, tableName, sinceTime, excludeClientID, limit, spaceIDs) if err != nil { return err } @@ -315,23 +317,25 @@ func (s *Store) GetChangesSince(ctx context.Context, userID, appID, tableName, s } // GetAllChangesSince returns changes across all tables for a user+app. -func (s *Store) GetAllChangesSince(ctx context.Context, userID, appID, since, excludeClientID string) ([]ChangeRow, error) { +// spaceIDs is the caller's Space membership list — rows whose space_id matches any of these +// are also returned (in addition to rows the caller authored). +func (s *Store) GetAllChangesSince(ctx context.Context, userID, appID, since, excludeClientID string, spaceIDs []string) ([]ChangeRow, error) { sinceTime, err := time.Parse(time.RFC3339Nano, since) if err != nil { sinceTime = time.Unix(0, 0) } var changes []ChangeRow - err = s.withUser(ctx, userID, func(tx pgx.Tx) error { + err = s.withUserAndMemberships(ctx, userID, spaceIDs, func(tx pgx.Tx) error { query := ` SELECT id, table_name, record_id, op, data, field_timestamps, client_id, created_at, schema_version, space_id, actor FROM sync_changes - WHERE user_id = $1 AND app_id = $2 + WHERE (user_id = $1 OR space_id = ANY($5)) AND app_id = $2 AND created_at > $3 AND client_id != $4 ORDER BY created_at ASC LIMIT 5000 ` - rows, err := tx.Query(ctx, query, userID, appID, sinceTime, excludeClientID) + rows, err := tx.Query(ctx, query, userID, appID, sinceTime, excludeClientID, spaceIDs) if err != nil { return err } diff --git a/services/mana-sync/internal/sync/handler.go b/services/mana-sync/internal/sync/handler.go index 8d62b8e52..c674cc002 100644 --- a/services/mana-sync/internal/sync/handler.go +++ b/services/mana-sync/internal/sync/handler.go @@ -9,20 +9,34 @@ import ( "time" "github.com/mana/mana-sync/internal/auth" + "github.com/mana/mana-sync/internal/memberships" "github.com/mana/mana-sync/internal/store" "github.com/mana/mana-sync/internal/ws" ) // Handler handles sync HTTP endpoints. type Handler struct { - store *store.Store - validator *auth.Validator - hub *ws.Hub + store *store.Store + validator *auth.Validator + hub *ws.Hub + memberships *memberships.Lookup } // NewHandler creates a new sync handler. -func NewHandler(s *store.Store, v *auth.Validator, h *ws.Hub) *Handler { - return &Handler{store: s, validator: v, hub: h} +// memberships may be nil — if so, the handler treats every user as +// having no shared-space memberships (same as pre-Spaces behavior). +func NewHandler(s *store.Store, v *auth.Validator, h *ws.Hub, m *memberships.Lookup) *Handler { + return &Handler{store: s, validator: v, hub: h, memberships: m} +} + +// spaceIDsFor returns the Space membership list for the caller, or an +// empty slice if no lookup is configured. Used to populate the session +// config the multi-member RLS policy reads. +func (h *Handler) spaceIDsFor(userID string) []string { + if h.memberships == nil { + return nil + } + return h.memberships.For(userID) } // maxBodySize is the maximum allowed request body (10 MB). @@ -210,7 +224,7 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) { } // Get server changes since client's last sync (excluding client's own changes) - serverChanges, err := h.store.GetAllChangesSince(ctx, userID, appID, changeset.Since, clientID) + serverChanges, err := h.store.GetAllChangesSince(ctx, userID, appID, changeset.Since, clientID, h.spaceIDsFor(userID)) if err != nil { slog.Error("failed to get server changes", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -275,7 +289,7 @@ func (h *Handler) HandlePull(w http.ResponseWriter, r *http.Request) { ctx := r.Context() const batchLimit = 1000 - serverChanges, err := h.store.GetChangesSince(ctx, userID, appID, collection, since, clientID, batchLimit+1) + serverChanges, err := h.store.GetChangesSince(ctx, userID, appID, collection, since, clientID, batchLimit+1, h.spaceIDsFor(userID)) if err != nil { slog.Error("failed to get changes", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -374,8 +388,9 @@ func (h *Handler) HandleStream(w http.ResponseWriter, r *http.Request) { } // Initial sync: send pending changes since cursor for each collection + memberSpaceIDs := h.spaceIDsFor(userID) for _, coll := range collections { - changes, err := h.store.GetChangesSince(ctx, userID, appID, coll, since, clientID, batchLimit+1) + changes, err := h.store.GetChangesSince(ctx, userID, appID, coll, since, clientID, batchLimit+1, memberSpaceIDs) if err != nil { slog.Error("SSE initial pull failed", "error", err, "collection", coll) cursors[coll] = now // Default to now on error @@ -416,7 +431,7 @@ func (h *Handler) HandleStream(w http.ResponseWriter, r *http.Request) { if cursor == "" { cursor = since } - changes, err := h.store.GetChangesSince(ctx, userID, appID, table, cursor, clientID, batchLimit+1) + changes, err := h.store.GetChangesSince(ctx, userID, appID, table, cursor, clientID, batchLimit+1, memberSpaceIDs) if err != nil || len(changes) == 0 { continue }