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:
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
firebaseauth "firebase.google.com/go/v4/auth"
|
||||
"github.com/flowy-live/llink/genproto/aero"
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -56,16 +57,27 @@ type AuthService interface {
|
||||
ExtendSession(ctx context.Context, sessionToken string) error
|
||||
SignOut(ctx context.Context, sessionToken string) error
|
||||
|
||||
// MintFirebaseCustomToken returns a Firebase custom token with uid=humanId and no custom claims.
|
||||
MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error)
|
||||
|
||||
IsSystemAdmin(ctx context.Context, email string) bool
|
||||
}
|
||||
|
||||
type authServiceImpl struct {
|
||||
redisClient *redis.Client
|
||||
aeroSvc pbaero.PrimaryClient
|
||||
fbAuth *firebaseauth.Client
|
||||
}
|
||||
|
||||
func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient) AuthService {
|
||||
return &authServiceImpl{redisClient: redisClient, aeroSvc: aeroSvc}
|
||||
func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient, fbAuth *firebaseauth.Client) AuthService {
|
||||
return &authServiceImpl{redisClient: redisClient, aeroSvc: aeroSvc, fbAuth: fbAuth}
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error) {
|
||||
if humanId == "" {
|
||||
return "", errors.New("humanId is required")
|
||||
}
|
||||
return a.fbAuth.CustomToken(ctx, humanId)
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) IsSystemAdmin(ctx context.Context, email string) bool {
|
||||
|
||||
@@ -94,6 +94,10 @@ type SignInResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type FirebaseTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// Network Request DTOs
|
||||
|
||||
type CreateNetworkRequest struct {
|
||||
@@ -240,6 +244,27 @@ func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// FirebaseToken mints a Firebase custom token for the authenticated human so
|
||||
// the client can signInWithCustomToken and have request.auth.uid populated in
|
||||
// Firestore security rules.
|
||||
func (h *Handler) FirebaseToken(w http.ResponseWriter, r *http.Request) {
|
||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.authSvc.MintFirebaseCustomToken(r.Context(), humanId)
|
||||
if err != nil {
|
||||
slog.Error("failed to mint Firebase custom token", "error", err, "humanId", humanId)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(FirebaseTokenResponse{Token: token})
|
||||
}
|
||||
|
||||
// SignOut deletes the session from the token in headers
|
||||
func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) {
|
||||
token := extractBearerToken(r)
|
||||
@@ -521,34 +546,28 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// RemoveMemberFromNetwork removes a member from a network
|
||||
// RemoveMemberFromNetwork removes a member from a network. Admin-only.
|
||||
// Admins cannot remove themselves — doing so would leave networks.admin_human_id
|
||||
// dangling. Removal of a non-member is a no-op (204).
|
||||
func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) {
|
||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||
net, _, ok := h.loadNetworkForAdmin(w, r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
networkID := r.PathValue("id")
|
||||
memberHumanId := r.PathValue("humanId")
|
||||
if networkID == "" || memberHumanId == "" {
|
||||
http.Error(w, "network id and member humanId are required", http.StatusBadRequest)
|
||||
if memberHumanId == "" {
|
||||
http.Error(w, "member humanId is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
|
||||
if err != nil {
|
||||
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !isMember {
|
||||
http.Error(w, "access denied", http.StatusForbidden)
|
||||
if memberHumanId == net.AdminHumanId {
|
||||
http.Error(w, "admin cannot remove themselves", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.networkSvc.RemoveMember(r.Context(), networkID, memberHumanId); err != nil {
|
||||
slog.Error("failed to remove member from network", "error", err, "network_id", networkID, "memberHumanId", memberHumanId)
|
||||
if err := h.networkSvc.RemoveMember(r.Context(), net.ID, memberHumanId); err != nil {
|
||||
slog.Error("failed to remove member from network", "error", err, "network_id", net.ID, "memberHumanId", memberHumanId)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -34,7 +34,7 @@ func getStreamStatus(data json.RawMessage) string {
|
||||
|
||||
func TestParticleService_CreateAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network first
|
||||
@@ -73,7 +73,7 @@ func TestParticleService_CreateAndGet(t *testing.T) {
|
||||
|
||||
func TestParticleService_NestedParticles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network
|
||||
@@ -119,7 +119,7 @@ func TestParticleService_NestedParticles(t *testing.T) {
|
||||
|
||||
func TestParticleService_CustomVisibility(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network with a member
|
||||
@@ -161,7 +161,7 @@ func TestParticleService_CustomVisibility(t *testing.T) {
|
||||
|
||||
func TestParticleService_UpdateAndDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network
|
||||
@@ -199,7 +199,7 @@ func TestParticleService_UpdateAndDelete(t *testing.T) {
|
||||
|
||||
func TestParticleService_ListRootParticles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network
|
||||
@@ -225,7 +225,7 @@ func TestParticleService_ListRootParticles(t *testing.T) {
|
||||
|
||||
func TestParticleService_OpenCloseStream(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network
|
||||
@@ -273,7 +273,7 @@ func TestParticleService_OpenCloseStream(t *testing.T) {
|
||||
|
||||
func TestParticleService_NotAStream(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network
|
||||
@@ -302,7 +302,7 @@ func TestParticleService_NotAStream(t *testing.T) {
|
||||
|
||||
func TestParticleService_AccessInheritance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network with members
|
||||
|
||||
Reference in New Issue
Block a user