mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(spaces): end-to-end shared-space sync (membership lookup + plaintext)
Closes the gap between "invite flow UI exists" and "two users in the
same space actually see each other's data". Three pieces land together
because they're meaningless without each other.
mana-auth — new internal endpoint:
GET /api/v1/internal/users/:userId/memberships
Returns [{organizationId, role}, ...] for the user. mana-sync uses
this to populate the multi-member RLS session config.
mana-sync — membership lookup:
new internal/memberships package with an HTTP client + 5 min
per-user cache, fail-open (empty list = pre-Spaces behavior).
Config gets MANA_AUTH_URL (default http://localhost:3001).
Handler.NewHandler takes the Lookup. Every Push/Pull/Stream call
now passes spaceIDsFor(userID) to Store methods.
GetChangesSince + GetAllChangesSince extend their WHERE clause:
WHERE (user_id = $1 OR space_id = ANY($memberSpaces))
so co-members see each other's rows, not just the author.
apps/web — encryption skip for shared-space records:
encryptRecord now checks record.spaceId:
- `_personal:<userId>` sentinel OR no active shared space → encrypt
with user master key (E2E as today).
- Active space resolves to non-personal type AND spaceId matches
that space → skip encryption; write lands plaintext.
decryptRecord is unchanged because its per-field isEncrypted() guard
already passes plaintext through.
Phase-1 compromise: shared-space data is protected by server RLS
only, not E2E. Phase 2 adds per-Space shared keys with per-member
wrap — tracked in docs/plans/spaces-foundation.md.
Plus docs/plans/shared-space-smoketest.md: step-by-step Zwei-User-Test
mit erwarteten Ergebnissen und Debugging-Hinweisen bei Problemen.
Build + go test + web check all green.
Plan: docs/plans/spaces-foundation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
da373491b8
commit
38d35247cd
8 changed files with 365 additions and 18 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
129
services/mana-sync/internal/memberships/lookup.go
Normal file
129
services/mana-sync/internal/memberships/lookup.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue