Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

53 changes: 39 additions & 14 deletions chat/invite.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,18 @@ import (
// invites (TOO_MANY_INVITES; maxActiveInvites, MaxActiveInvites in
// production) and, when a concurrent create of the same ID won the race,
// returns that invite, judged by the same retry rule.
// - GetInvite is any registered user's: the invite for the token
// (NOT_FOUND), its chat's record (NOT_FOUND, which a stored invite never
// outlives), BANNED for a user banned from the chat, and otherwise the
// chat exactly as GetChat shows it to the caller under FULL (see
// chatAsSeenBy): a member's metadata in full, a non-member the record
// with in_lobby. With it goes the invite's status as of now, and nothing
// else of the invite: no use count, no key, nothing written.
// - GetInvite is anyone's who holds the link: the token is the credential,
// so auth is optional, and checked when set. It reads the invite for the
// token (NOT_FOUND), then its chat's record (NOT_FOUND, which a stored
// invite never outlives). An authenticated caller is then BANNED if
// banned from the chat, and otherwise gets the chat exactly as GetChat
// shows it to them under FULL (see chatAsSeenBy): a member's metadata in
// full, a non-member the record with in_lobby. A caller with no auth is
// no one, so is never BANNED, and gets the chat's public view (see
// publicChatMetadata), which for a private group is its record alone; a
// web page that a link opens previews the invite this way. With the chat
// goes the invite's status as of now, and nothing else of the invite: no
// use count, no key, nothing written.
// - GetChatInvites pages the chat's invites, every creator's and in every
// status, most recently created first (see InvitePosition), up to
// maxGetChatInvitesPageSize a page, with an exact has_more and a token
Expand All @@ -88,6 +93,9 @@ import (
// when no invite to this chat has it, another chat's included), deleting
// the invite's copy of the chat key in the same write; a repeat is OK.
//
// A token also shows the chat's roster while its invite admits users, with or
// without auth (see GetRoster in roster.go).
//
// Nothing here is published: an invite is the inviter's, and its effects
// (joins) are announced where they happen.

Expand Down Expand Up @@ -358,16 +366,21 @@ func createInviteRetry(existing Invite, chatID *commonpb.ChatId, userID *commonp
}

func (s *Server) GetInvite(ctx context.Context, req *chatpb.GetInviteRequest) (*chatpb.GetInviteResponse, error) {
userID, err := s.authz.Authorize(ctx, req, &req.Auth)
if err != nil {
return nil, err
// Auth is optional (see above): without it, userID stays nil.
var userID *commonpb.UserId
if req.Auth != nil {
var err error
userID, err = s.authz.Authorize(ctx, req, &req.Auth)
if err != nil {
return nil, err
}
}

inviteID := MustDeriveInviteID(req.InviteToken)
log := s.log.With(
zap.String("user_id", model.UserIDString(userID)),
zap.String("invite_id", hex.EncodeToString(inviteID.Value)),
)
log := s.log.With(zap.String("invite_id", hex.EncodeToString(inviteID.Value)))
if userID != nil {
log = log.With(zap.String("user_id", model.UserIDString(userID)))
}

invite, err := s.chats.GetInvite(ctx, inviteID)
switch {
Expand All @@ -388,6 +401,18 @@ func (s *Server) GetInvite(ctx context.Context, req *chatpb.GetInviteRequest) (*
return nil, status.Error(codes.Internal, "")
}

if userID == nil {
md, err := s.publicChatMetadata(ctx, log, c)
if err != nil {
return nil, err
}
return &chatpb.GetInviteResponse{
Result: chatpb.GetInviteResponse_OK,
Chat: md,
Status: invite.StatusAt(time.Now()),
}, nil
}

// A banned user is told that and nothing else, not even which chat. Read
// strongly consistent, as every gate reads a restriction.
state, err := s.access.viewerState(ctx, c.ID, userID)
Expand Down
113 changes: 99 additions & 14 deletions chat/roster.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/binary"
"encoding/hex"
"errors"
"time"

Expand Down Expand Up @@ -53,11 +54,30 @@ import (
// never are, since group pointer advances are not broadcast and a page of
// them would be stale on arrival.
//
// The read is a member's alone: who else is in a chat is not shown to anyone
// outside it, so a non-member is denied whatever the group's listener rules
// say of them, including one they would admit to read its messages.
// Membership is decided through the shared Access, off the record for a DM
// and from the store for a group.
// The read is a member's, and otherwise only an invite link's holder's: who
// else is in a chat is not shown to anyone outside it, so a non-member is
// denied whatever the group's listener rules say of them, including one they
// would admit to read its messages. Membership is decided through the shared
// Access, off the record for a DM and from the store for a group.
//
// A link's holder is someone who could join the chat at any moment (see
// invite.go), so the roster is shown to them too: a request carrying the
// token of an invite to the chat that admits users as of now (Invite.AdmitsAt)
// is served the page a member gets. The token is the credential, so auth is
// optional when one is sent, which is how a web page that a link opens shows
// who is in the group. Nothing ties the token to a user: a caller with no
// auth is no one, and a ban is not checked even for one with auth, who could
// as well have sent none. A ban stops a user joining, not reading the roster
// through a link they hold; the creator stops that by revoking the invite.
// The invite is read again on every page, strongly consistent, so a walk
// ends with DENIED once it stops admitting anyone. Only a private group has
// invites, so a token never serves a public group or a DM. A member's token
// is never looked at: they are served as a member. Reading counts no use of
// the invite and writes nothing.
//
// A caller with no auth has their invite read before the chat's record, so a
// token that admits no one costs one point read. Logs carry the invite's ID,
// never the token (see invite.go).
//
// A public group's roster can be switched off by configuration (see
// Server.disablePublicGroupRoster): a call naming one is then DENIED once the
Expand Down Expand Up @@ -87,12 +107,28 @@ const (
)

func (s *Server) GetRoster(ctx context.Context, req *chatpb.GetRosterRequest) (*chatpb.GetRosterResponse, error) {
userID, err := s.authz.Authorize(ctx, req, &req.Auth)
if err != nil {
return nil, err
// Auth is optional when an invite's token is sent (see above): without
// it, userID stays nil.
var userID *commonpb.UserId
if req.Auth != nil {
var err error
userID, err = s.authz.Authorize(ctx, req, &req.Auth)
if err != nil {
return nil, err
}
} else if req.InviteToken == nil {
return &chatpb.GetRosterResponse{Result: chatpb.GetRosterResponse_DENIED}, nil
}

log := s.log.With(zap.String("user_id", model.UserIDString(userID)))
log := s.log.With(zap.String("chat_id", model.ChatIDString(req.ChatId)))
if userID != nil {
log = log.With(zap.String("user_id", model.UserIDString(userID)))
}
var inviteID *chatpb.InviteId
if req.InviteToken != nil {
inviteID = MustDeriveInviteID(req.InviteToken)
log = log.With(zap.String("invite_id", hex.EncodeToString(inviteID.Value)))
}

limit := maxGetRosterPageSize
if pageSize := req.GetQueryOptions().GetPageSize(); pageSize > 0 && int(pageSize) < limit {
Expand All @@ -111,6 +147,20 @@ func (s *Server) GetRoster(ctx context.Context, req *chatpb.GetRosterRequest) (*
after = &pos
}

// A caller with no auth is admitted by the invite alone, read first (see
// above).
var inviteAdmits bool
if userID == nil {
var err error
inviteAdmits, err = s.inviteAdmitsToRoster(ctx, log, req.ChatId, inviteID)
if err != nil {
return nil, err
}
if !inviteAdmits {
return &chatpb.GetRosterResponse{Result: chatpb.GetRosterResponse_DENIED}, nil
}
}

// The canonical record first, as for GetChat: its absence is NOT_FOUND,
// and a DM's roster and every chat's rules are on it.
c, err := s.chats.GetChatByID(ctx, req.ChatId)
Expand All @@ -126,12 +176,27 @@ func (s *Server) GetRoster(ctx context.Context, req *chatpb.GetRosterRequest) (*
return &chatpb.GetRosterResponse{Result: chatpb.GetRosterResponse_DENIED}, nil
}

isMember, err := s.access.IsMemberWithChat(ctx, c, userID)
if err != nil {
log.With(zap.Error(err)).Warn("Failure determining chat membership")
return nil, status.Error(codes.Internal, "")
// A signed caller is admitted as a member, or failing that by their
// invite, read only then: a member's token is never read.
var isMember bool
if userID != nil {
isMember, err = s.access.IsMemberWithChat(ctx, c, userID)
if err != nil {
log.With(zap.Error(err)).Warn("Failure determining chat membership")
return nil, status.Error(codes.Internal, "")
}
if !isMember && inviteID != nil && c.IsPrivate {
inviteAdmits, err = s.inviteAdmitsToRoster(ctx, log, c.ID, inviteID)
if err != nil {
return nil, err
}
}
}
if !isMember {

// Only a private group has invites, which an admitting invite already
// implies; checked off the record all the same, so a token never serves
// any other chat.
if !isMember && !(inviteAdmits && c.IsPrivate) {
return &chatpb.GetRosterResponse{Result: chatpb.GetRosterResponse_DENIED}, nil
}

Expand Down Expand Up @@ -159,6 +224,26 @@ func (s *Server) GetRoster(ctx context.Context, req *chatpb.GetRosterRequest) (*
return resp, nil
}

// inviteAdmitsToRoster reports whether inviteID names an invite to chatID
// that admits users as of now, which shows the roster to a caller who is not
// a member (see above). The read is strongly consistent, so a revoke or a
// last use that completed before it is seen. A gRPC error is returned only
// for a failed read.
func (s *Server) inviteAdmitsToRoster(ctx context.Context, log *zap.Logger, chatID *commonpb.ChatId, inviteID *chatpb.InviteId) (bool, error) {
invite, err := s.chats.GetInvite(ctx, inviteID)
switch {
case errors.Is(err, ErrInviteNotFound):
return false, nil
case err != nil:
log.With(zap.Error(err)).Warn("Failure getting invite")
return false, status.Error(codes.Internal, "")
}
if !bytes.Equal(invite.ChatID.GetValue(), chatID.GetValue()) {
return false, nil
}
return invite.AdmitsAt(time.Now()) == nil, nil
}

// rosterPage reads one page of c's roster in roster order, strictly after
// the after position, at most limit members, choosing the shape described
// above, and reports whether members follow it. The summary is the one the
Expand Down
25 changes: 19 additions & 6 deletions chat/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,8 @@ func (s *Server) GetChat(ctx context.Context, req *chatpb.GetChatRequest) (*chat
// chatAsSeenBy is c's metadata as userID sees it under mode, which is what
// GetChat returns them (see GetChat), and ok false when they may not see it
// at all: a DM they are not a member of. Its other callers return a chat "as
// the caller sees it with GetChat" (see GetInvite). A gRPC error is returned
// the caller sees it with GetChat" (see GetInvite), and use
// publicChatMetadata for a caller with no auth. A gRPC error is returned
// only for a failed read.
func (s *Server) chatAsSeenBy(ctx context.Context, log *zap.Logger, c *Chat, userID *commonpb.UserId, mode messagingpb.ViewMode) (md *chatpb.Metadata, ok bool, err error) {
// The caller's standing is decided off the record where it can be — a DM's
Expand Down Expand Up @@ -493,15 +494,27 @@ func (s *Server) getPublicChat(ctx context.Context, req *chatpb.GetChatRequest)
return nil, status.Error(codes.Internal, "")
}

standing := s.access.PublicListenerStanding(c)
metadata, err := s.hydrator.hydrate(ctx, nil, standing, standing.Reading(req.GetViewMode()), fullDetail, []*Chat{c})
md, err := s.publicChatMetadata(ctx, log, c)
if err != nil {
log.With(zap.Error(err)).Warn("Failure hydrating chat metadata")
return nil, status.Error(codes.Internal, "")
return nil, err
}

return &chatpb.GetChatResponse{
Result: chatpb.GetChatResponse_OK,
Metadata: metadata[0],
Metadata: md,
}, nil
}

// publicChatMetadata is the group c's public view (see getPublicChat), which
// is what GetChat returns a caller with no auth. Its other callers return a
// chat's public view (see GetInvite). A gRPC error is returned only for a
// failed read.
func (s *Server) publicChatMetadata(ctx context.Context, log *zap.Logger, c *Chat) (*chatpb.Metadata, error) {
standing := s.access.PublicListenerStanding(c)
metadata, err := s.hydrator.hydrate(ctx, nil, standing, standing.Reading(messagingpb.ViewMode_REDACTED), fullDetail, []*Chat{c})
if err != nil {
log.With(zap.Error(err)).Warn("Failure hydrating chat metadata")
return nil, status.Error(codes.Internal, "")
}
return metadata[0], nil
}
Loading
Loading