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
+22
View File
@@ -47,6 +47,7 @@ type repository interface {
getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error)
isMember(ctx context.Context, networkID, humanId string) (bool, error)
listAll(ctx context.Context) ([]*Network, error)
listAllMemberships(ctx context.Context) (map[string][]string, error)
// Invitations
createInvitation(ctx context.Context, networkID, email string) error
@@ -232,6 +233,27 @@ func (r *repositoryImpl) isMember(ctx context.Context, networkID, humanId string
return isMember, err
}
// listAllMemberships returns humanId -> networkIds for every human with at
// least one membership. Humans with zero memberships are absent from the map;
// callers layer them in separately.
func (r *repositoryImpl) listAllMemberships(ctx context.Context) (map[string][]string, error) {
rows, err := r.pool.Query(ctx, `SELECT human_id, network_id FROM network_members`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string][]string{}
for rows.Next() {
var humanId, networkId string
if err := rows.Scan(&humanId, &networkId); err != nil {
return nil, err
}
out[humanId] = append(out[humanId], networkId)
}
return out, rows.Err()
}
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) {
rows, err := r.pool.Query(ctx,
`SELECT `+networkColumns+` FROM networks`,
+69 -7
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"strings"
"cloud.google.com/go/firestore"
pbaero "github.com/flowy-live/llink/genproto/aero"
"github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/utils"
@@ -37,6 +38,10 @@ type Service interface {
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
// ListAll returns all networks with their members
ListAll(ctx context.Context) ([]*Network, error)
// ListAllMemberships returns humanId -> networkIds for every human with at
// least one membership. Humans with zero memberships are absent from the map.
// Used by the membership reconciler to diff the Firestore mirror.
ListAllMemberships(ctx context.Context) (map[string][]string, error)
// Invitations (email-based, for users who haven't registered yet)
InviteByEmail(ctx context.Context, networkID string, emails []string) error
@@ -51,14 +56,19 @@ type serviceImpl struct {
repo repository
aeroSvc pbaero.PrimaryClient
billingSvc billing.Service
// fs mirrors network membership into humans/{humanId}.networks so
// Firestore security rules can check membership at rule-eval time.
// May be nil in services that never mutate membership (pusher).
fs *firestore.Client
}
func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient, billingSvc billing.Service) Service {
func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient, billingSvc billing.Service, fs *firestore.Client) Service {
return &serviceImpl{
pool: pool,
repo: newRepository(pool),
aeroSvc: aeroSvc,
billingSvc: billingSvc,
fs: fs,
}
}
@@ -105,23 +115,66 @@ func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, humanIds
if slices.Contains(humanIds, "") {
return fmt.Errorf("invalid humanId")
}
return s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
for _, humanId := range humanIds {
if err := s.repo.addMember(ctx, tx, networkID, humanId); err != nil {
return err
}
}
return nil
})
}); err != nil {
return err
}
for _, humanId := range humanIds {
s.mirrorAddMembership(ctx, humanId, networkID)
}
return nil
}
func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId string) error {
if humanId == "" {
return fmt.Errorf("invalid humanId")
}
return s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
return s.repo.removeMember(ctx, tx, networkID, humanId)
})
}); err != nil {
return err
}
s.mirrorRemoveMembership(ctx, humanId, networkID)
return nil
}
// mirrorAddMembership / mirrorRemoveMembership keep the Firestore membership
// mirror (humans/{humanId}.networks) in sync with Postgres. Called after the
// Postgres transaction commits. Failures are logged but not returned: Postgres
// is the source of truth and the reconciler will heal drift. A missing fs
// client (pusher) no-ops.
func (s *serviceImpl) mirrorAddMembership(ctx context.Context, humanId, networkID string) {
if s.fs == nil {
return
}
_, err := s.fs.Collection("humans").Doc(humanId).Set(ctx, map[string]any{
"networks": firestore.ArrayUnion(networkID),
"updated_at": firestore.ServerTimestamp,
}, firestore.MergeAll)
if err != nil {
slog.Error("firestore mirror add failed", "error", err, "humanId", humanId, "networkID", networkID)
}
}
func (s *serviceImpl) mirrorRemoveMembership(ctx context.Context, humanId, networkID string) {
if s.fs == nil {
return
}
_, err := s.fs.Collection("humans").Doc(humanId).Set(ctx, map[string]any{
"networks": firestore.ArrayRemove(networkID),
"updated_at": firestore.ServerTimestamp,
}, firestore.MergeAll)
if err != nil {
slog.Error("firestore mirror remove failed", "error", err, "humanId", humanId, "networkID", networkID)
}
}
// mutateMembers runs fn in a tx, recounts seats, calls billing.SyncSeats,
@@ -174,6 +227,10 @@ func (s *serviceImpl) ListAll(ctx context.Context) ([]*Network, error) {
return s.repo.listAll(ctx)
}
func (s *serviceImpl) ListAllMemberships(ctx context.Context) (map[string][]string, error) {
return s.repo.listAllMemberships(ctx)
}
func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error {
network, err := s.repo.getByID(ctx, networkID)
if err != nil {
@@ -226,13 +283,18 @@ func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, hu
return fmt.Errorf("invalid humanId")
}
return s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
err := s.repo.deleteInvitation(ctx, tx, networkID, normalized)
if err != nil {
return err
}
return s.repo.addMember(ctx, tx, networkID, humanId)
})
}); err != nil {
return err
}
s.mirrorAddMembership(ctx, humanId, networkID)
return nil
}
func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error {
+1 -1
View File
@@ -32,7 +32,7 @@ func newTestService(t *testing.T) network.Service {
ShootEmail(gomock.Any(), gomock.Any()).
Return(&pbaero.ShootEmailResponse{}, nil).
AnyTimes()
return network.NewService(dbPool, mockAero, billing.Noop())
return network.NewService(dbPool, mockAero, billing.Noop(), nil)
}
func TestNetworkService(t *testing.T) {