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:
Arjun Patel
2026-04-16 15:14:34 -07:00
committed by GitHub
parent 28b1ff542b
commit ef899ee5cd
36 changed files with 805 additions and 169 deletions
+1 -1
View File
@@ -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 {
+143
View File
@@ -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
View File
@@ -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))
+2 -2
View File
@@ -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()