Monorepo for Tangled tangled.org
8

Configure Feed

Select the types of activity you want to include in your feed.

appview/knotacl: native membership service

Lewis: May this revision serve well! <lewis@tangled.org>

author
Lewis
committer
Tangled
date (Jun 8, 2026, 4:18 PM +0300) commit d896d9a8 parent 7e1a1e76 change-id lqkmyktm
+359
+36
appview/knotacl/latch.go
··· 1 + package knotacl 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "time" 7 + 8 + "tangled.org/core/appview/db" 9 + "tangled.org/core/appview/knotcompat" 10 + ) 11 + 12 + const latchOpTimeout = 5 * time.Second 13 + 14 + type latch struct { 15 + execer db.Execer 16 + log *slog.Logger 17 + } 18 + 19 + func NewLatch(execer db.Execer, logger *slog.Logger) knotcompat.NativeLatch { 20 + return latch{execer: execer, log: logger} 21 + } 22 + 23 + func (l latch) IsNative(host string) bool { 24 + ctx, cancel := context.WithTimeout(context.Background(), latchOpTimeout) 25 + defer cancel() 26 + native, err := db.IsKnotAclNative(ctx, l.execer, host) 27 + return err == nil && native 28 + } 29 + 30 + func (l latch) MarkNative(host string) { 31 + ctx, cancel := context.WithTimeout(context.Background(), latchOpTimeout) 32 + defer cancel() 33 + if err := db.MarkKnotAclNative(ctx, l.execer, host); err != nil { 34 + l.log.Error("failed to persist native knot latch, it will be re-probed after restart", "host", host, "err", err) 35 + } 36 + }
+13
appview/knotacl/perms.go
··· 1 + package knotacl 2 + 3 + func ownerPermissions() []string { 4 + return []string{"repo:settings", "repo:push", "repo:owner", "repo:invite", "repo:delete"} 5 + } 6 + 7 + func collaboratorPermissions() []string { 8 + return []string{"repo:collaborator", "repo:settings", "repo:push"} 9 + } 10 + 11 + func serverOwnerRepoPermissions() []string { 12 + return []string{"repo:delete"} 13 + }
+147
appview/knotacl/reader.go
··· 1 + package knotacl 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "slices" 7 + 8 + "tangled.org/core/appview/db" 9 + "tangled.org/core/appview/models" 10 + "tangled.org/core/appview/pages" 11 + "tangled.org/core/orm" 12 + "tangled.org/core/rbac" 13 + ) 14 + 15 + type reader interface { 16 + repoPerms(ctx context.Context, repo *models.Repo, userDid string) ([]string, error) 17 + knotMembers(ctx context.Context, host string) []string 18 + collaborators(ctx context.Context, repo *models.Repo) []pages.Collaborator 19 + isRepoCreateAllowed(ctx context.Context, host, userDid string) bool 20 + isKnotMember(ctx context.Context, host, userDid string) bool 21 + } 22 + 23 + type legacyReader struct { 24 + enforcer *rbac.Enforcer 25 + } 26 + 27 + func (r *legacyReader) repoPerms(ctx context.Context, repo *models.Repo, userDid string) ([]string, error) { 28 + return r.enforcer.GetPermissionsInRepo(userDid, repo.Knot, repo.RepoIdentifier()), nil 29 + } 30 + 31 + func (r *legacyReader) knotMembers(ctx context.Context, host string) []string { 32 + members, err := r.enforcer.GetUserByRole("server:member", host) 33 + if err != nil { 34 + return nil 35 + } 36 + return members 37 + } 38 + 39 + func (r *legacyReader) collaborators(ctx context.Context, repo *models.Repo) []pages.Collaborator { 40 + policies, err := r.enforcer.E.GetImplicitUsersForResourceByDomain(repo.RepoIdentifier(), repo.Knot) 41 + if err != nil { 42 + return nil 43 + } 44 + return filterMap(policies, func(p []string) (pages.Collaborator, bool) { 45 + // currently only two roles: owner and member 46 + switch p[3] { 47 + case "repo:owner": 48 + return pages.Collaborator{Did: p[0], Role: "owner"}, true 49 + case "repo:collaborator": 50 + return pages.Collaborator{Did: p[0], Role: "collaborator"}, true 51 + default: 52 + return pages.Collaborator{}, false 53 + } 54 + }) 55 + } 56 + 57 + func (r *legacyReader) isRepoCreateAllowed(ctx context.Context, host, userDid string) bool { 58 + ok, err := r.enforcer.IsRepoCreateAllowed(userDid, host) 59 + return err == nil && ok 60 + } 61 + 62 + func (r *legacyReader) isKnotMember(ctx context.Context, host, userDid string) bool { 63 + knots, err := r.enforcer.GetKnotsForUser(userDid) 64 + return err == nil && slices.Contains(knots, host) 65 + } 66 + 67 + type nativeReader struct { 68 + client *cache 69 + execer db.Execer 70 + } 71 + 72 + func (r *nativeReader) repoPerms(ctx context.Context, repo *models.Repo, userDid string) ([]string, error) { 73 + if userDid == repo.Did { 74 + return ownerPermissions(), nil 75 + } 76 + var perms []string 77 + if r.isRegisteredOwner(ctx, repo.Knot, userDid) { 78 + perms = serverOwnerRepoPermissions() 79 + } 80 + collabs, err := r.client.GetRepoCollaborators(ctx, repo.Knot, repo.RepoDid) 81 + if err != nil { 82 + return dedup(perms), fmt.Errorf("%w: %v", ErrKnotUnreachable, err) 83 + } 84 + if slices.Contains(collabs, userDid) { 85 + perms = append(perms, collaboratorPermissions()...) 86 + } 87 + return dedup(perms), nil 88 + } 89 + 90 + func (r *nativeReader) knotMembers(ctx context.Context, host string) []string { 91 + members, err := r.client.GetKnotMembers(ctx, host) 92 + if err != nil { 93 + return dedup(r.registeredOwners(ctx, host)) 94 + } 95 + return dedup(append(members, r.registeredOwners(ctx, host)...)) 96 + } 97 + 98 + func (r *nativeReader) collaborators(ctx context.Context, repo *models.Repo) []pages.Collaborator { 99 + owner := pages.Collaborator{Did: repo.Did, Role: "owner"} 100 + collabs, err := r.client.GetRepoCollaborators(ctx, repo.Knot, repo.RepoDid) 101 + if err != nil { 102 + return []pages.Collaborator{owner} 103 + } 104 + rows := filterMap(collabs, func(d string) (pages.Collaborator, bool) { 105 + if d == repo.Did { 106 + return pages.Collaborator{}, false 107 + } 108 + return pages.Collaborator{Did: d, Role: "collaborator"}, true 109 + }) 110 + return append([]pages.Collaborator{owner}, rows...) 111 + } 112 + 113 + func (r *nativeReader) isRepoCreateAllowed(ctx context.Context, host, userDid string) bool { 114 + members, err := r.client.GetKnotMembers(ctx, host) 115 + if err == nil && slices.Contains(members, userDid) { 116 + return true 117 + } 118 + return r.isRegisteredOwner(ctx, host, userDid) 119 + } 120 + 121 + func (r *nativeReader) isKnotMember(ctx context.Context, host, userDid string) bool { 122 + return slices.Contains(r.knotMembers(ctx, host), userDid) 123 + } 124 + 125 + func (r *nativeReader) registeredOwners(ctx context.Context, host string) []string { 126 + key := "r\x00" + host 127 + if memo := memoFrom(ctx); memo != nil { 128 + if v, ok := memo.get(key); ok { 129 + return slices.Clone(v) 130 + } 131 + } 132 + regs, err := db.GetRegistrations(r.execer, orm.FilterEq("domain", host)) 133 + if err != nil { 134 + return nil 135 + } 136 + owners := filterMap(regs, func(reg models.Registration) (string, bool) { 137 + return reg.ByDid, reg.Registered != nil 138 + }) 139 + if memo := memoFrom(ctx); memo != nil { 140 + memo.put(key, slices.Clone(owners)) 141 + } 142 + return owners 143 + } 144 + 145 + func (r *nativeReader) isRegisteredOwner(ctx context.Context, host, userDid string) bool { 146 + return slices.Contains(r.registeredOwners(ctx, host), userDid) 147 + }
+163
appview/knotacl/service.go
··· 1 + package knotacl 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "log/slog" 7 + "slices" 8 + "sync" 9 + "time" 10 + 11 + "golang.org/x/sync/errgroup" 12 + 13 + "tangled.org/core/appview/db" 14 + "tangled.org/core/appview/knotcompat" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/pages" 17 + "tangled.org/core/appview/pages/repoinfo" 18 + "tangled.org/core/consts" 19 + "tangled.org/core/rbac" 20 + ) 21 + 22 + var ErrKnotUnreachable = errors.New("knot unreachable") 23 + 24 + const ( 25 + pickerFanoutBudget = 3 * time.Second 26 + pickerFanoutConcurrency = 16 27 + ) 28 + 29 + type Service struct { 30 + dev bool 31 + log *slog.Logger 32 + leg *legacyReader 33 + nat *nativeReader 34 + } 35 + 36 + func NewService(enforcer *rbac.Enforcer, execer db.Execer, dev bool, logger *slog.Logger) *Service { 37 + return &Service{ 38 + dev: dev, 39 + log: logger, 40 + leg: &legacyReader{enforcer: enforcer}, 41 + nat: &nativeReader{client: newCache(NewClient(dev, logger), cacheTTL, nil), execer: execer}, 42 + } 43 + } 44 + 45 + func (s *Service) reader(ctx context.Context, host string) reader { 46 + if knotcompat.KnotHasCapability(ctx, host, s.dev, consts.CapKnotACL) { 47 + return s.nat 48 + } 49 + return s.leg 50 + } 51 + 52 + func (s *Service) RolesInRepo(ctx context.Context, repo *models.Repo, userDid string) repoinfo.RolesInRepo { 53 + return repoinfo.RolesInRepo{Roles: s.repoPerms(ctx, repo, userDid)} 54 + } 55 + 56 + func (s *Service) HasRepoPermission(ctx context.Context, repo *models.Repo, userDid, perm string) bool { 57 + return slices.Contains(s.repoPerms(ctx, repo, userDid), perm) 58 + } 59 + 60 + func (s *Service) HasRepoPermissionErr(ctx context.Context, repo *models.Repo, userDid, perm string) (bool, error) { 61 + perms, err := s.repoPermsErr(ctx, repo, userDid) 62 + if err != nil { 63 + return false, err 64 + } 65 + return slices.Contains(perms, perm), nil 66 + } 67 + 68 + func (s *Service) IsRepoCreateAllowed(ctx context.Context, host, userDid string) bool { 69 + return s.reader(ctx, host).isRepoCreateAllowed(ctx, host, userDid) 70 + } 71 + 72 + func (s *Service) KnotMembers(ctx context.Context, host string) []string { 73 + return s.reader(ctx, host).knotMembers(ctx, host) 74 + } 75 + 76 + func (s *Service) Collaborators(ctx context.Context, repo *models.Repo) []pages.Collaborator { 77 + return s.reader(ctx, repo.Knot).collaborators(ctx, repo) 78 + } 79 + 80 + func (s *Service) IsKnotMember(ctx context.Context, host, userDid string) bool { 81 + return s.reader(ctx, host).isKnotMember(ctx, host, userDid) 82 + } 83 + 84 + func (s *Service) InvalidateMembers(host string) { 85 + s.nat.client.InvalidateMembers(host) 86 + } 87 + 88 + func (s *Service) InvalidateCollaborators(host, repoDid string) { 89 + s.nat.client.InvalidateCollaborators(host, repoDid) 90 + } 91 + 92 + func (s *Service) KnotsForUser(ctx context.Context, userDid string) []string { 93 + legacyKnots, err := s.leg.enforcer.GetKnotsForUser(userDid) 94 + if err != nil { 95 + s.log.Error("knotsForUser: enforcer lookup failed, returning a partial list", "did", userDid, "err", err) 96 + } 97 + 98 + regs, err := db.GetRegistrations(s.nat.execer) 99 + if err != nil { 100 + s.log.Error("knotsForUser: registrations lookup failed, skipping native knots", "did", userDid, "err", err) 101 + } 102 + domains := dedup(filterMap(regs, func(r models.Registration) (string, bool) { 103 + return r.Domain, r.Registered != nil 104 + })) 105 + owned := filterMap(regs, func(r models.Registration) (string, bool) { 106 + return r.Domain, r.Registered != nil && r.ByDid == userDid 107 + }) 108 + nativeMember := s.nativeMemberships(ctx, domains, userDid) 109 + 110 + all := make([]string, 0, len(legacyKnots)+len(owned)+len(nativeMember)) 111 + all = append(all, legacyKnots...) 112 + all = append(all, owned...) 113 + all = append(all, nativeMember...) 114 + return dedup(all) 115 + } 116 + 117 + func (s *Service) nativeMemberships(ctx context.Context, domains []string, userDid string) []string { 118 + ctx, cancel := context.WithTimeout(ctx, pickerFanoutBudget) 119 + defer cancel() 120 + 121 + g, gctx := errgroup.WithContext(ctx) 122 + g.SetLimit(pickerFanoutConcurrency) 123 + 124 + var mu sync.Mutex 125 + var hits []string 126 + 127 + for _, host := range domains { 128 + g.Go(func() error { 129 + if !knotcompat.KnotHasCapability(gctx, host, s.dev, consts.CapKnotACL) { 130 + return nil 131 + } 132 + members, err := s.nat.client.GetKnotMembers(gctx, host) 133 + if err != nil || !slices.Contains(members, userDid) { 134 + return nil 135 + } 136 + mu.Lock() 137 + hits = append(hits, host) 138 + mu.Unlock() 139 + return nil 140 + }) 141 + } 142 + _ = g.Wait() 143 + return hits 144 + } 145 + 146 + func (s *Service) repoPerms(ctx context.Context, repo *models.Repo, userDid string) []string { 147 + perms, _ := s.repoPermsErr(ctx, repo, userDid) 148 + return perms 149 + } 150 + 151 + func (s *Service) repoPermsErr(ctx context.Context, repo *models.Repo, userDid string) ([]string, error) { 152 + return s.reader(ctx, repo.Knot).repoPerms(ctx, repo, userDid) 153 + } 154 + 155 + func filterMap[T, U any](items []T, f func(T) (U, bool)) []U { 156 + var out []U 157 + for _, it := range items { 158 + if u, ok := f(it); ok { 159 + out = append(out, u) 160 + } 161 + } 162 + return out 163 + }