security: access control for particles (#169)
* setup firebase custom token * docs * docs * feat: allow admin removing members from a network * fix: properly handle fallback avatar and names This is especially helpful in the case of members who were removed from a network
This commit was merged in pull request #169.
This commit is contained in:
@@ -68,7 +68,7 @@ func main() {
|
||||
|
||||
// Initialize services
|
||||
humanSvc := human.NewService(db.Pool())
|
||||
networkSvc := network.NewService(db.Pool(), aeroSvc, billing.Noop())
|
||||
networkSvc := network.NewService(db.Pool(), aeroSvc, billing.Noop(), nil)
|
||||
|
||||
slog.Info("starting email notification cycle")
|
||||
if err := runNotificationCycle(ctx, firestoreClient, aeroSvc, pusherSvc, humanSvc, networkSvc); err != nil {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// memberreconciler is a one-shot job (also safe to run on a cron) that makes
|
||||
// the Firestore membership mirror (humans/{humanId}.networks) match the
|
||||
// authoritative Postgres network_members table.
|
||||
//
|
||||
// Run on a cron to heal any drift from a dropped mirror
|
||||
// write in network.Service.
|
||||
//
|
||||
// The reconciler reads the current Firestore state and only writes humans
|
||||
// whose mirrored networks differ from Postgres. Writes cost ~3x reads, and in
|
||||
// steady state drift is rare, so read-first keeps the cron nearly free.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/firestore"
|
||||
"github.com/flowy-live/llink/internal/billing"
|
||||
"github.com/flowy-live/llink/internal/db"
|
||||
"github.com/flowy-live/llink/internal/human"
|
||||
"github.com/flowy-live/llink/internal/network"
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"google.golang.org/api/iterator"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
db.Init()
|
||||
defer db.Cleanup()
|
||||
|
||||
gcpProject := utils.MustGetEnv("GCP_PROJECT")
|
||||
fs, err := firestore.NewClient(ctx, gcpProject)
|
||||
if err != nil {
|
||||
slog.Error("failed to create Firestore client", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
humanSvc := human.NewService(db.Pool())
|
||||
// billing/aero/fs unused here — we only call ListAllMemberships.
|
||||
networkSvc := network.NewService(db.Pool(), nil, billing.Noop(), nil)
|
||||
|
||||
started := time.Now()
|
||||
written, scanned, err := reconcile(ctx, fs, humanSvc, networkSvc)
|
||||
if err != nil {
|
||||
slog.Error("reconciliation failed", "error", err, "elapsed", time.Since(started))
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("reconciliation complete",
|
||||
"humans_scanned", scanned,
|
||||
"humans_written", written,
|
||||
"elapsed", time.Since(started),
|
||||
)
|
||||
}
|
||||
|
||||
func reconcile(
|
||||
ctx context.Context,
|
||||
fs *firestore.Client,
|
||||
humanSvc human.Service,
|
||||
networkSvc network.Service,
|
||||
) (written, scanned int, err error) {
|
||||
humans, err := humanSvc.ListAll(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
memberships, err := networkSvc.ListAllMemberships(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
current, err := snapshotMirror(ctx, fs)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
bulk := fs.BulkWriter(ctx)
|
||||
for _, h := range humans {
|
||||
scanned++
|
||||
desired := memberships[h.ID]
|
||||
if desired == nil {
|
||||
desired = []string{}
|
||||
}
|
||||
if sameSet(current[h.ID], desired) {
|
||||
continue
|
||||
}
|
||||
if _, err := bulk.Set(fs.Collection("humans").Doc(h.ID), map[string]any{
|
||||
"networks": desired,
|
||||
"updated_at": firestore.ServerTimestamp,
|
||||
}, firestore.MergeAll); err != nil {
|
||||
return written, scanned, err
|
||||
}
|
||||
written++
|
||||
}
|
||||
bulk.End()
|
||||
return written, scanned, nil
|
||||
}
|
||||
|
||||
// snapshotMirror streams the humans collection once and returns a map of
|
||||
// humanId -> current networks array. One iterator, N billed reads.
|
||||
func snapshotMirror(ctx context.Context, fs *firestore.Client) (map[string][]string, error) {
|
||||
out := map[string][]string{}
|
||||
iter := fs.Collection("humans").Documents(ctx)
|
||||
defer iter.Stop()
|
||||
for {
|
||||
doc, err := iter.Next()
|
||||
if err == iterator.Done {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data struct {
|
||||
Networks []string `firestore:"networks"`
|
||||
}
|
||||
if err := doc.DataTo(&data); err != nil {
|
||||
slog.Warn("skipping malformed mirror doc", "id", doc.Ref.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
out[doc.Ref.ID] = data.Networks
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sameSet reports whether a and b contain the same elements, ignoring order
|
||||
// and duplicates. Firestore array ops don't preserve order, so set equality is
|
||||
// the right comparison for the networks array.
|
||||
func sameSet(a, b []string) bool {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
ac := slices.Clone(a)
|
||||
bc := slices.Clone(b)
|
||||
slices.Sort(ac)
|
||||
slices.Sort(bc)
|
||||
ac = slices.Compact(ac)
|
||||
bc = slices.Compact(bc)
|
||||
return slices.Equal(ac, bc)
|
||||
}
|
||||
+24
-11
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"cloud.google.com/go/firestore"
|
||||
"cloud.google.com/go/storage"
|
||||
firebase "firebase.google.com/go/v4"
|
||||
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||
"github.com/flowy-live/llink/internal"
|
||||
"github.com/flowy-live/llink/internal/auth"
|
||||
@@ -62,7 +63,19 @@ func main() {
|
||||
defer aeroServer.Close()
|
||||
aeroSvc := pbaero.NewPrimaryClient(aeroServer)
|
||||
|
||||
authSvc := auth.NewAuthService(redisClient, aeroSvc)
|
||||
gcpProject := utils.MustGetEnv("GCP_PROJECT")
|
||||
fbApp, err := firebase.NewApp(ctx, &firebase.Config{ProjectID: gcpProject})
|
||||
if err != nil {
|
||||
slog.Error("failed to init Firebase Admin app", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fbAuth, err := fbApp.Auth(ctx)
|
||||
if err != nil {
|
||||
slog.Error("failed to create Firebase auth client", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
authSvc := auth.NewAuthService(redisClient, aeroSvc, fbAuth)
|
||||
humanSvc := human.NewService(db.Pool())
|
||||
|
||||
billingSvc, err := billing.NewService(ctx, db.Pool(), billing.Config{
|
||||
@@ -78,7 +91,14 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
networkSvc := network.NewService(db.Pool(), aeroSvc, billingSvc)
|
||||
firestoreClient, err := firestore.NewClient(ctx, gcpProject)
|
||||
if err != nil {
|
||||
slog.Error("failed to create Firestore client", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer firestoreClient.Close()
|
||||
|
||||
networkSvc := network.NewService(db.Pool(), aeroSvc, billingSvc, firestoreClient)
|
||||
particleSvc := particle.NewService(db.Pool(), networkSvc)
|
||||
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
|
||||
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
|
||||
@@ -87,14 +107,6 @@ func main() {
|
||||
waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc)
|
||||
livekitClient := livekit.NewClient()
|
||||
|
||||
gcpProject := utils.MustGetEnv("GCP_PROJECT")
|
||||
firestoreClient, err := firestore.NewClient(ctx, gcpProject)
|
||||
if err != nil {
|
||||
slog.Error("failed to create Firestore client", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer firestoreClient.Close()
|
||||
|
||||
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, billingSvc, livekitClient, firestoreClient)
|
||||
|
||||
withAuth := func(hf http.HandlerFunc) http.Handler {
|
||||
@@ -125,6 +137,7 @@ func main() {
|
||||
// Auth
|
||||
mux.Handle("POST /auth/sign-out", withAuth(h.SignOut))
|
||||
mux.Handle("GET /auth/me", withAuth(h.GetCurrentHuman))
|
||||
mux.Handle("POST /auth/firebase-token", withAuth(h.FirebaseToken))
|
||||
|
||||
// Settings
|
||||
mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings))
|
||||
@@ -134,7 +147,7 @@ func main() {
|
||||
mux.Handle("GET /networks", withAuth(h.ListNetworks))
|
||||
mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork))
|
||||
mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork))
|
||||
// mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork))
|
||||
mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork))
|
||||
|
||||
// Billing (network admin only; admin check happens inside each handler)
|
||||
mux.Handle("GET /networks/{id}/billing", withAuth(h.GetNetworkBilling))
|
||||
|
||||
@@ -37,8 +37,8 @@ func main() {
|
||||
pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher)
|
||||
|
||||
// Services
|
||||
authSvc := auth.NewAuthService(authRedis, nil) // nil aeroSvc — pusher only calls GetSession
|
||||
networkSvc := network.NewService(db.Pool(), nil, billing.Noop()) // nil aeroSvc / noop billing — pusher never mutates membership
|
||||
authSvc := auth.NewAuthService(authRedis, nil, nil) // nil aeroSvc / fbAuth — pusher only calls GetSession
|
||||
networkSvc := network.NewService(db.Pool(), nil, billing.Noop(), nil) // nil aeroSvc / noop billing / nil firestore — pusher never mutates membership
|
||||
|
||||
// Pod identity (use hostname in k8s, which is the pod name)
|
||||
podID, err := os.Hostname()
|
||||
|
||||
Reference in New Issue
Block a user