diff --git a/appview/knotacl/latch.go b/appview/knotacl/latch.go new file mode 100644 --- /dev/null +++ b/appview/knotacl/latch.go @@ -0,0 +1,36 @@ +package knotacl + +import ( + "context" + "log/slog" + "time" + + "tangled.org/core/appview/db" + "tangled.org/core/appview/knotcompat" +) + +const latchOpTimeout = 5 * time.Second + +type latch struct { + execer db.Execer + log *slog.Logger +} + +func NewLatch(execer db.Execer, logger *slog.Logger) knotcompat.NativeLatch { + return latch{execer: execer, log: logger} +} + +func (l latch) IsNative(host string) bool { + ctx, cancel := context.WithTimeout(context.Background(), latchOpTimeout) + defer cancel() + native, err := db.IsKnotAclNative(ctx, l.execer, host) + return err == nil && native +} + +func (l latch) MarkNative(host string) { + ctx, cancel := context.WithTimeout(context.Background(), latchOpTimeout) + defer cancel() + if err := db.MarkKnotAclNative(ctx, l.execer, host); err != nil { + l.log.Error("failed to persist native knot latch, it will be re-probed after restart", "host", host, "err", err) + } +} diff --git a/appview/knotacl/perms.go b/appview/knotacl/perms.go new file mode 100644 --- /dev/null +++ b/appview/knotacl/perms.go @@ -0,0 +1,13 @@ +package knotacl + +func ownerPermissions() []string { + return []string{"repo:settings", "repo:push", "repo:owner", "repo:invite", "repo:delete"} +} + +func collaboratorPermissions() []string { + return []string{"repo:collaborator", "repo:settings", "repo:push"} +} + +func serverOwnerRepoPermissions() []string { + return []string{"repo:delete"} +} diff --git a/appview/knotacl/reader.go b/appview/knotacl/reader.go new file mode 100644 --- /dev/null +++ b/appview/knotacl/reader.go @@ -0,0 +1,147 @@ +package knotacl + +import ( + "context" + "fmt" + "slices" + + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pages" + "tangled.org/core/orm" + "tangled.org/core/rbac" +) + +type reader interface { + repoPerms(ctx context.Context, repo *models.Repo, userDid string) ([]string, error) + knotMembers(ctx context.Context, host string) []string + collaborators(ctx context.Context, repo *models.Repo) []pages.Collaborator + isRepoCreateAllowed(ctx context.Context, host, userDid string) bool + isKnotMember(ctx context.Context, host, userDid string) bool +} + +type legacyReader struct { + enforcer *rbac.Enforcer +} + +func (r *legacyReader) repoPerms(ctx context.Context, repo *models.Repo, userDid string) ([]string, error) { + return r.enforcer.GetPermissionsInRepo(userDid, repo.Knot, repo.RepoIdentifier()), nil +} + +func (r *legacyReader) knotMembers(ctx context.Context, host string) []string { + members, err := r.enforcer.GetUserByRole("server:member", host) + if err != nil { + return nil + } + return members +} + +func (r *legacyReader) collaborators(ctx context.Context, repo *models.Repo) []pages.Collaborator { + policies, err := r.enforcer.E.GetImplicitUsersForResourceByDomain(repo.RepoIdentifier(), repo.Knot) + if err != nil { + return nil + } + return filterMap(policies, func(p []string) (pages.Collaborator, bool) { + // currently only two roles: owner and member + switch p[3] { + case "repo:owner": + return pages.Collaborator{Did: p[0], Role: "owner"}, true + case "repo:collaborator": + return pages.Collaborator{Did: p[0], Role: "collaborator"}, true + default: + return pages.Collaborator{}, false + } + }) +} + +func (r *legacyReader) isRepoCreateAllowed(ctx context.Context, host, userDid string) bool { + ok, err := r.enforcer.IsRepoCreateAllowed(userDid, host) + return err == nil && ok +} + +func (r *legacyReader) isKnotMember(ctx context.Context, host, userDid string) bool { + knots, err := r.enforcer.GetKnotsForUser(userDid) + return err == nil && slices.Contains(knots, host) +} + +type nativeReader struct { + client *cache + execer db.Execer +} + +func (r *nativeReader) repoPerms(ctx context.Context, repo *models.Repo, userDid string) ([]string, error) { + if userDid == repo.Did { + return ownerPermissions(), nil + } + var perms []string + if r.isRegisteredOwner(ctx, repo.Knot, userDid) { + perms = serverOwnerRepoPermissions() + } + collabs, err := r.client.GetRepoCollaborators(ctx, repo.Knot, repo.RepoDid) + if err != nil { + return dedup(perms), fmt.Errorf("%w: %v", ErrKnotUnreachable, err) + } + if slices.Contains(collabs, userDid) { + perms = append(perms, collaboratorPermissions()...) + } + return dedup(perms), nil +} + +func (r *nativeReader) knotMembers(ctx context.Context, host string) []string { + members, err := r.client.GetKnotMembers(ctx, host) + if err != nil { + return dedup(r.registeredOwners(ctx, host)) + } + return dedup(append(members, r.registeredOwners(ctx, host)...)) +} + +func (r *nativeReader) collaborators(ctx context.Context, repo *models.Repo) []pages.Collaborator { + owner := pages.Collaborator{Did: repo.Did, Role: "owner"} + collabs, err := r.client.GetRepoCollaborators(ctx, repo.Knot, repo.RepoDid) + if err != nil { + return []pages.Collaborator{owner} + } + rows := filterMap(collabs, func(d string) (pages.Collaborator, bool) { + if d == repo.Did { + return pages.Collaborator{}, false + } + return pages.Collaborator{Did: d, Role: "collaborator"}, true + }) + return append([]pages.Collaborator{owner}, rows...) +} + +func (r *nativeReader) isRepoCreateAllowed(ctx context.Context, host, userDid string) bool { + members, err := r.client.GetKnotMembers(ctx, host) + if err == nil && slices.Contains(members, userDid) { + return true + } + return r.isRegisteredOwner(ctx, host, userDid) +} + +func (r *nativeReader) isKnotMember(ctx context.Context, host, userDid string) bool { + return slices.Contains(r.knotMembers(ctx, host), userDid) +} + +func (r *nativeReader) registeredOwners(ctx context.Context, host string) []string { + key := "r\x00" + host + if memo := memoFrom(ctx); memo != nil { + if v, ok := memo.get(key); ok { + return slices.Clone(v) + } + } + regs, err := db.GetRegistrations(r.execer, orm.FilterEq("domain", host)) + if err != nil { + return nil + } + owners := filterMap(regs, func(reg models.Registration) (string, bool) { + return reg.ByDid, reg.Registered != nil + }) + if memo := memoFrom(ctx); memo != nil { + memo.put(key, slices.Clone(owners)) + } + return owners +} + +func (r *nativeReader) isRegisteredOwner(ctx context.Context, host, userDid string) bool { + return slices.Contains(r.registeredOwners(ctx, host), userDid) +} diff --git a/appview/knotacl/service.go b/appview/knotacl/service.go new file mode 100644 --- /dev/null +++ b/appview/knotacl/service.go @@ -0,0 +1,163 @@ +package knotacl + +import ( + "context" + "errors" + "log/slog" + "slices" + "sync" + "time" + + "golang.org/x/sync/errgroup" + + "tangled.org/core/appview/db" + "tangled.org/core/appview/knotcompat" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pages" + "tangled.org/core/appview/pages/repoinfo" + "tangled.org/core/consts" + "tangled.org/core/rbac" +) + +var ErrKnotUnreachable = errors.New("knot unreachable") + +const ( + pickerFanoutBudget = 3 * time.Second + pickerFanoutConcurrency = 16 +) + +type Service struct { + dev bool + log *slog.Logger + leg *legacyReader + nat *nativeReader +} + +func NewService(enforcer *rbac.Enforcer, execer db.Execer, dev bool, logger *slog.Logger) *Service { + return &Service{ + dev: dev, + log: logger, + leg: &legacyReader{enforcer: enforcer}, + nat: &nativeReader{client: newCache(NewClient(dev, logger), cacheTTL, nil), execer: execer}, + } +} + +func (s *Service) reader(ctx context.Context, host string) reader { + if knotcompat.KnotHasCapability(ctx, host, s.dev, consts.CapKnotACL) { + return s.nat + } + return s.leg +} + +func (s *Service) RolesInRepo(ctx context.Context, repo *models.Repo, userDid string) repoinfo.RolesInRepo { + return repoinfo.RolesInRepo{Roles: s.repoPerms(ctx, repo, userDid)} +} + +func (s *Service) HasRepoPermission(ctx context.Context, repo *models.Repo, userDid, perm string) bool { + return slices.Contains(s.repoPerms(ctx, repo, userDid), perm) +} + +func (s *Service) HasRepoPermissionErr(ctx context.Context, repo *models.Repo, userDid, perm string) (bool, error) { + perms, err := s.repoPermsErr(ctx, repo, userDid) + if err != nil { + return false, err + } + return slices.Contains(perms, perm), nil +} + +func (s *Service) IsRepoCreateAllowed(ctx context.Context, host, userDid string) bool { + return s.reader(ctx, host).isRepoCreateAllowed(ctx, host, userDid) +} + +func (s *Service) KnotMembers(ctx context.Context, host string) []string { + return s.reader(ctx, host).knotMembers(ctx, host) +} + +func (s *Service) Collaborators(ctx context.Context, repo *models.Repo) []pages.Collaborator { + return s.reader(ctx, repo.Knot).collaborators(ctx, repo) +} + +func (s *Service) IsKnotMember(ctx context.Context, host, userDid string) bool { + return s.reader(ctx, host).isKnotMember(ctx, host, userDid) +} + +func (s *Service) InvalidateMembers(host string) { + s.nat.client.InvalidateMembers(host) +} + +func (s *Service) InvalidateCollaborators(host, repoDid string) { + s.nat.client.InvalidateCollaborators(host, repoDid) +} + +func (s *Service) KnotsForUser(ctx context.Context, userDid string) []string { + legacyKnots, err := s.leg.enforcer.GetKnotsForUser(userDid) + if err != nil { + s.log.Error("knotsForUser: enforcer lookup failed, returning a partial list", "did", userDid, "err", err) + } + + regs, err := db.GetRegistrations(s.nat.execer) + if err != nil { + s.log.Error("knotsForUser: registrations lookup failed, skipping native knots", "did", userDid, "err", err) + } + domains := dedup(filterMap(regs, func(r models.Registration) (string, bool) { + return r.Domain, r.Registered != nil + })) + owned := filterMap(regs, func(r models.Registration) (string, bool) { + return r.Domain, r.Registered != nil && r.ByDid == userDid + }) + nativeMember := s.nativeMemberships(ctx, domains, userDid) + + all := make([]string, 0, len(legacyKnots)+len(owned)+len(nativeMember)) + all = append(all, legacyKnots...) + all = append(all, owned...) + all = append(all, nativeMember...) + return dedup(all) +} + +func (s *Service) nativeMemberships(ctx context.Context, domains []string, userDid string) []string { + ctx, cancel := context.WithTimeout(ctx, pickerFanoutBudget) + defer cancel() + + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(pickerFanoutConcurrency) + + var mu sync.Mutex + var hits []string + + for _, host := range domains { + g.Go(func() error { + if !knotcompat.KnotHasCapability(gctx, host, s.dev, consts.CapKnotACL) { + return nil + } + members, err := s.nat.client.GetKnotMembers(gctx, host) + if err != nil || !slices.Contains(members, userDid) { + return nil + } + mu.Lock() + hits = append(hits, host) + mu.Unlock() + return nil + }) + } + _ = g.Wait() + return hits +} + +func (s *Service) repoPerms(ctx context.Context, repo *models.Repo, userDid string) []string { + perms, _ := s.repoPermsErr(ctx, repo, userDid) + return perms +} + +func (s *Service) repoPermsErr(ctx context.Context, repo *models.Repo, userDid string) ([]string, error) { + return s.reader(ctx, repo.Knot).repoPerms(ctx, repo, userDid) +} + +func filterMap[T, U any](items []T, f func(T) (U, bool)) []U { + var out []U + for _, it := range items { + if u, ok := f(it); ok { + out = append(out, u) + } + } + return out +}