business: add privacy-aware user projection
(cherry picked from commit a636192ef22ee69d792b0ca7db1c6be963be9cb2)
This commit is contained in:
parent
14d220c971
commit
8ff3343ae0
29 changed files with 2239 additions and 112 deletions
268
internal/app/privacy/service.go
Normal file
268
internal/app/privacy/service.go
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
package privacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const maxPrivacyRules = 100
|
||||
|
||||
// Service owns account privacy rules and viewer-specific evaluation.
|
||||
type Service struct {
|
||||
rules store.PrivacyStore
|
||||
contacts store.ContactStore
|
||||
}
|
||||
|
||||
func NewService(rules store.PrivacyStore, contacts store.ContactStore) *Service {
|
||||
return &Service{rules: rules, contacts: contacts}
|
||||
}
|
||||
|
||||
func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, error) {
|
||||
if !ValidKey(key) {
|
||||
return domain.PrivacyRules{}, domain.ErrPrivacyKeyInvalid
|
||||
}
|
||||
if s == nil || s.rules == nil {
|
||||
return defaultRules(ownerUserID, key), nil
|
||||
}
|
||||
rules, ok, err := s.rules.GetPrivacyRules(ctx, ownerUserID, key)
|
||||
if err != nil {
|
||||
return domain.PrivacyRules{}, err
|
||||
}
|
||||
if !ok {
|
||||
return defaultRules(ownerUserID, key), nil
|
||||
}
|
||||
rules.OwnerUserID = ownerUserID
|
||||
rules.Key = key
|
||||
if len(rules.Rules) == 0 {
|
||||
rules.Rules = domain.DefaultPrivacyRules(key)
|
||||
}
|
||||
return cloneRules(rules), nil
|
||||
}
|
||||
|
||||
func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
|
||||
if !ValidKey(key) {
|
||||
return domain.PrivacyRules{}, domain.ErrPrivacyKeyInvalid
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
rules = domain.DefaultPrivacyRules(key)
|
||||
}
|
||||
if err := validateRules(rules); err != nil {
|
||||
return domain.PrivacyRules{}, err
|
||||
}
|
||||
out := domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}
|
||||
if s != nil && s.rules != nil {
|
||||
if err := s.rules.SetPrivacyRules(ctx, out); err != nil {
|
||||
return domain.PrivacyRules{}, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error) {
|
||||
if targetUserID == 0 {
|
||||
return domain.PrivacyRules{}, false, domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
rules, err := s.GetRules(ctx, ownerUserID, key)
|
||||
if err != nil {
|
||||
return domain.PrivacyRules{}, false, err
|
||||
}
|
||||
for i := range rules.Rules {
|
||||
if rules.Rules[i].Kind != domain.PrivacyRuleAllowUsers {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(rules.Rules[i].UserIDs, targetUserID) {
|
||||
return rules, false, nil
|
||||
}
|
||||
rules.Rules[i].UserIDs = append(rules.Rules[i].UserIDs, targetUserID)
|
||||
next, err := s.SetRules(ctx, ownerUserID, key, rules.Rules)
|
||||
return next, true, err
|
||||
}
|
||||
rules.Rules = append([]domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowUsers, UserIDs: []int64{targetUserID}}}, rules.Rules...)
|
||||
next, err := s.SetRules(ctx, ownerUserID, key, rules.Rules)
|
||||
return next, true, err
|
||||
}
|
||||
|
||||
func (s *Service) CanSee(ctx context.Context, ownerUserID, viewerUserID int64, key domain.PrivacyKey) (bool, error) {
|
||||
if ownerUserID == 0 || viewerUserID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if ownerUserID == viewerUserID {
|
||||
return true, nil
|
||||
}
|
||||
rules, err := s.GetRules(ctx, ownerUserID, key)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
evalCtx := domain.PrivacyContext{
|
||||
OwnerUserID: ownerUserID,
|
||||
ViewerUserID: viewerUserID,
|
||||
}
|
||||
if s != nil && s.contacts != nil {
|
||||
if _, found, err := s.contacts.Get(ctx, ownerUserID, viewerUserID); err != nil {
|
||||
return false, err
|
||||
} else if found {
|
||||
evalCtx.ViewerIsContact = true
|
||||
}
|
||||
}
|
||||
return Evaluate(rules, evalCtx), nil
|
||||
}
|
||||
|
||||
func Evaluate(rules domain.PrivacyRules, ctx domain.PrivacyContext) bool {
|
||||
if ctx.OwnerUserID != 0 && ctx.OwnerUserID == ctx.ViewerUserID {
|
||||
return true
|
||||
}
|
||||
if len(rules.Rules) == 0 {
|
||||
rules.Rules = domain.DefaultPrivacyRules(rules.Key)
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
if explicitDisallowMatches(rule, ctx) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
if explicitAllowMatches(rule, ctx) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleDisallowContacts:
|
||||
if ctx.ViewerIsContact {
|
||||
return false
|
||||
}
|
||||
case domain.PrivacyRuleAllowContacts:
|
||||
if ctx.ViewerIsContact {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleDisallowAll:
|
||||
return false
|
||||
case domain.PrivacyRuleAllowAll:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ValidKey(key domain.PrivacyKey) bool {
|
||||
switch key {
|
||||
case domain.PrivacyKeyStatusTimestamp,
|
||||
domain.PrivacyKeyChatInvite,
|
||||
domain.PrivacyKeyPhoneCall,
|
||||
domain.PrivacyKeyPhoneP2P,
|
||||
domain.PrivacyKeyForwards,
|
||||
domain.PrivacyKeyProfilePhoto,
|
||||
domain.PrivacyKeyPhoneNumber,
|
||||
domain.PrivacyKeyAddedByPhone,
|
||||
domain.PrivacyKeyVoiceMessages,
|
||||
domain.PrivacyKeyAbout,
|
||||
domain.PrivacyKeyBirthday,
|
||||
domain.PrivacyKeyStarGiftsAutoSave,
|
||||
domain.PrivacyKeyNoPaidMessages,
|
||||
domain.PrivacyKeySavedMusic:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateRules(rules []domain.PrivacyRule) error {
|
||||
if len(rules) > maxPrivacyRules {
|
||||
return domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
for _, rule := range rules {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleAllowContacts,
|
||||
domain.PrivacyRuleAllowAll,
|
||||
domain.PrivacyRuleAllowUsers,
|
||||
domain.PrivacyRuleDisallowContacts,
|
||||
domain.PrivacyRuleDisallowAll,
|
||||
domain.PrivacyRuleDisallowUsers,
|
||||
domain.PrivacyRuleAllowChatParticipants,
|
||||
domain.PrivacyRuleDisallowChatParticipants,
|
||||
domain.PrivacyRuleAllowCloseFriends,
|
||||
domain.PrivacyRuleAllowPremium,
|
||||
domain.PrivacyRuleAllowBots,
|
||||
domain.PrivacyRuleDisallowBots:
|
||||
default:
|
||||
return domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func explicitDisallowMatches(rule domain.PrivacyRule, ctx domain.PrivacyContext) bool {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleDisallowUsers:
|
||||
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
|
||||
case domain.PrivacyRuleDisallowChatParticipants:
|
||||
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
|
||||
case domain.PrivacyRuleDisallowBots:
|
||||
return ctx.ViewerIsBot
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func explicitAllowMatches(rule domain.PrivacyRule, ctx domain.PrivacyContext) bool {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleAllowUsers:
|
||||
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
|
||||
case domain.PrivacyRuleAllowChatParticipants:
|
||||
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
|
||||
case domain.PrivacyRuleAllowCloseFriends:
|
||||
return ctx.ViewerCloseFriend
|
||||
case domain.PrivacyRuleAllowPremium:
|
||||
return ctx.ViewerIsPremium
|
||||
case domain.PrivacyRuleAllowBots:
|
||||
return ctx.ViewerIsBot
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func intersects(a, b []int64) bool {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
set := make(map[int64]struct{}, len(a))
|
||||
for _, id := range a {
|
||||
set[id] = struct{}{}
|
||||
}
|
||||
for _, id := range b {
|
||||
if _, ok := set[id]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultRules(ownerUserID int64, key domain.PrivacyKey) domain.PrivacyRules {
|
||||
return domain.PrivacyRules{
|
||||
OwnerUserID: ownerUserID,
|
||||
Key: key,
|
||||
Rules: domain.DefaultPrivacyRules(key),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneRules(in domain.PrivacyRules) domain.PrivacyRules {
|
||||
out := in
|
||||
out.Rules = cloneRuleSlice(in.Rules)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneRuleSlice(in []domain.PrivacyRule) []domain.PrivacyRule {
|
||||
out := make([]domain.PrivacyRule, len(in))
|
||||
for i, rule := range in {
|
||||
out[i] = rule
|
||||
out[i].UserIDs = append([]int64(nil), rule.UserIDs...)
|
||||
out[i].ChatIDs = append([]int64(nil), rule.ChatIDs...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
75
internal/app/privacy/service_test.go
Normal file
75
internal/app/privacy/service_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package privacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestDefaultPrivacyRules(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore())
|
||||
phone, err := svc.GetRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil {
|
||||
t.Fatalf("phone rules: %v", err)
|
||||
}
|
||||
if len(phone.Rules) != 1 || phone.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
|
||||
t.Fatalf("phone default = %+v, want disallow all", phone.Rules)
|
||||
}
|
||||
birthday, err := svc.GetRules(ctx, 1001, domain.PrivacyKeyBirthday)
|
||||
if err != nil {
|
||||
t.Fatalf("birthday rules: %v", err)
|
||||
}
|
||||
if len(birthday.Rules) != 1 || birthday.Rules[0].Kind != domain.PrivacyRuleAllowContacts {
|
||||
t.Fatalf("birthday default = %+v, want allow contacts", birthday.Rules)
|
||||
}
|
||||
profile, err := svc.GetRules(ctx, 1001, domain.PrivacyKeyProfilePhoto)
|
||||
if err != nil {
|
||||
t.Fatalf("profile rules: %v", err)
|
||||
}
|
||||
if len(profile.Rules) != 1 || profile.Rules[0].Kind != domain.PrivacyRuleAllowAll {
|
||||
t.Fatalf("profile default = %+v, want allow all", profile.Rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAllowUserOverridesDisallowAll(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore())
|
||||
if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
|
||||
t.Fatalf("set rules: %v", err)
|
||||
}
|
||||
allowed, err := svc.CanSee(ctx, 1001, 1002, domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil {
|
||||
t.Fatalf("can see before: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Fatal("viewer should not see phone before exception")
|
||||
}
|
||||
if _, changed, err := svc.AddAllowUser(ctx, 1001, domain.PrivacyKeyPhoneNumber, 1002); err != nil {
|
||||
t.Fatalf("add allow: %v", err)
|
||||
} else if !changed {
|
||||
t.Fatal("first add allow should report changed")
|
||||
}
|
||||
allowed, err = svc.CanSee(ctx, 1001, 1002, domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil {
|
||||
t.Fatalf("can see after: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Fatal("viewer should see phone after allow-user exception")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitDisallowUserWins(t *testing.T) {
|
||||
rules := domain.PrivacyRules{
|
||||
Key: domain.PrivacyKeyProfilePhoto,
|
||||
Rules: []domain.PrivacyRule{
|
||||
{Kind: domain.PrivacyRuleAllowAll},
|
||||
{Kind: domain.PrivacyRuleDisallowUsers, UserIDs: []int64{1002}},
|
||||
},
|
||||
}
|
||||
if Evaluate(rules, domain.PrivacyContext{OwnerUserID: 1001, ViewerUserID: 1002}) {
|
||||
t.Fatal("explicit disallow user should win over allow all")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue