Monorepo for Tangled
tangled.org
1package knotacl
2
3import (
4 "context"
5 "errors"
6 "log/slog"
7 "slices"
8 "sync"
9 "time"
10
11 "github.com/bluesky-social/indigo/atproto/syntax"
12 "golang.org/x/sync/errgroup"
13
14 "tangled.org/core/appview/db"
15 "tangled.org/core/appview/knotcompat"
16 "tangled.org/core/appview/models"
17 "tangled.org/core/appview/pages"
18 "tangled.org/core/appview/pages/repoinfo"
19 "tangled.org/core/consts"
20 "tangled.org/core/rbac"
21)
22
23var ErrKnotUnreachable = errors.New("knot unreachable")
24
25const (
26 pickerFanoutBudget = 3 * time.Second
27 pickerFanoutConcurrency = 16
28)
29
30type Service struct {
31 dev bool
32 log *slog.Logger
33 leg *legacyReader
34 nat *nativeReader
35}
36
37func NewService(enforcer *rbac.Enforcer, store *db.DB, dev bool, logger *slog.Logger) *Service {
38 return &Service{
39 dev: dev,
40 log: logger,
41 leg: &legacyReader{enforcer: enforcer},
42 nat: &nativeReader{client: newRoster(store, NewClient(dev, logger), reconcileTTL, nil, logger), execer: store},
43 }
44}
45
46func (s *Service) reader(ctx context.Context, host string) reader {
47 if knotcompat.KnotHasCapability(ctx, host, s.dev, consts.CapKnotACL) {
48 return s.nat
49 }
50 return s.leg
51}
52
53func (s *Service) RolesInRepo(ctx context.Context, repo *models.Repo, userDid string) repoinfo.RolesInRepo {
54 return repoinfo.RolesInRepo{Roles: s.repoPerms(ctx, repo, userDid)}
55}
56
57func (s *Service) HasRepoPermission(ctx context.Context, repo *models.Repo, userDid, perm string) bool {
58 return slices.Contains(s.repoPerms(ctx, repo, userDid), perm)
59}
60
61func (s *Service) HasRepoPermissionErr(ctx context.Context, repo *models.Repo, userDid, perm string) (bool, error) {
62 perms, err := s.repoPermsErr(ctx, repo, userDid)
63 if err != nil {
64 return false, err
65 }
66 return slices.Contains(perms, perm), nil
67}
68
69func (s *Service) IsRepoCreateAllowed(ctx context.Context, host, userDid string) bool {
70 return s.reader(ctx, host).isRepoCreateAllowed(ctx, host, userDid)
71}
72
73func (s *Service) KnotMembers(ctx context.Context, host string) []string {
74 return s.reader(ctx, host).knotMembers(ctx, host)
75}
76
77func (s *Service) Collaborators(ctx context.Context, repo *models.Repo) []pages.Collaborator {
78 return s.reader(ctx, repo.Knot).collaborators(ctx, repo)
79}
80
81func (s *Service) IsKnotMember(ctx context.Context, host, userDid string) bool {
82 return s.reader(ctx, host).isKnotMember(ctx, host, userDid)
83}
84
85func (s *Service) InvalidateMembers(host string) {
86 s.nat.client.InvalidateMembers(host)
87}
88
89func (s *Service) InvalidateCollaborators(host, repoDid string) {
90 s.nat.client.InvalidateCollaborators(host, repoDid)
91}
92
93func (s *Service) AddKnotMember(host string, subject syntax.DID, cursor Cursor) error {
94 return s.nat.client.AddKnotMember(host, subject, cursor)
95}
96
97func (s *Service) RemoveKnotMember(host string, subject syntax.DID, cursor Cursor) error {
98 return s.nat.client.RemoveKnotMember(host, subject, cursor)
99}
100
101func (s *Service) AddCollaborator(repoDid, subject syntax.DID, cursor Cursor) error {
102 return s.nat.client.AddCollaborator(repoDid, subject, cursor)
103}
104
105func (s *Service) RemoveCollaborator(repoDid, subject syntax.DID, cursor Cursor) error {
106 return s.nat.client.RemoveCollaborator(repoDid, subject, cursor)
107}
108
109func (s *Service) KnotsForUser(ctx context.Context, userDid string) []string {
110 legacyKnots, err := s.leg.enforcer.GetKnotsForUser(userDid)
111 if err != nil {
112 s.log.Error("knotsForUser: enforcer lookup failed, returning a partial list", "did", userDid, "err", err)
113 }
114
115 regs, err := db.GetRegistrations(s.nat.execer)
116 if err != nil {
117 s.log.Error("knotsForUser: registrations lookup failed, skipping native knots", "did", userDid, "err", err)
118 }
119 domains := dedup(filterMap(regs, func(r models.Registration) (string, bool) {
120 return r.Domain, r.Registered != nil
121 }))
122 owned := filterMap(regs, func(r models.Registration) (string, bool) {
123 return r.Domain, r.Registered != nil && r.ByDid == userDid
124 })
125 nativeMember := s.nativeMemberships(ctx, domains, userDid)
126
127 all := make([]string, 0, len(legacyKnots)+len(owned)+len(nativeMember))
128 all = append(all, legacyKnots...)
129 all = append(all, owned...)
130 all = append(all, nativeMember...)
131 return dedup(all)
132}
133
134func (s *Service) nativeMemberships(ctx context.Context, domains []string, userDid string) []string {
135 ctx, cancel := context.WithTimeout(ctx, pickerFanoutBudget)
136 defer cancel()
137
138 g, gctx := errgroup.WithContext(ctx)
139 g.SetLimit(pickerFanoutConcurrency)
140
141 var mu sync.Mutex
142 var hits []string
143
144 for _, host := range domains {
145 g.Go(func() error {
146 if !knotcompat.KnotHasCapability(gctx, host, s.dev, consts.CapKnotACL) {
147 return nil
148 }
149 members, err := s.nat.client.GetKnotMembers(gctx, host)
150 if err != nil || !slices.Contains(members, userDid) {
151 return nil
152 }
153 mu.Lock()
154 hits = append(hits, host)
155 mu.Unlock()
156 return nil
157 })
158 }
159 _ = g.Wait()
160 return hits
161}
162
163func (s *Service) repoPerms(ctx context.Context, repo *models.Repo, userDid string) []string {
164 perms, _ := s.repoPermsErr(ctx, repo, userDid)
165 return perms
166}
167
168func (s *Service) repoPermsErr(ctx context.Context, repo *models.Repo, userDid string) ([]string, error) {
169 return s.reader(ctx, repo.Knot).repoPerms(ctx, repo, userDid)
170}
171
172func filterMap[T, U any](items []T, f func(T) (U, bool)) []U {
173 var out []U
174 for _, it := range items {
175 if u, ok := f(it); ok {
176 out = append(out, u)
177 }
178 }
179 return out
180}