Monorepo for Tangled tangled.org
2

Configure Feed

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

appview: read permissions thru knotacl service

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

author
Lewis
committer
Tangled
date (Jun 8, 2026, 4:18 PM +0300) commit bc598317 parent 47f2d701 change-id mntwymxl
+470 -139
+17 -9
appview/ingester.go
··· 27 27 "tangled.org/core/appview/cache" 28 28 "tangled.org/core/appview/config" 29 29 "tangled.org/core/appview/db" 30 + "tangled.org/core/appview/knotacl" 30 31 "tangled.org/core/appview/mentions" 31 32 "tangled.org/core/appview/models" 32 33 "tangled.org/core/appview/notify" ··· 42 43 Ctx context.Context 43 44 Db *db.DB 44 45 Enforcer *rbac.Enforcer 46 + Acl *knotacl.Service 45 47 IdResolver *idresolver.Resolver 46 48 Cache *cache.Cache 47 49 Config *config.Config ··· 116 118 case tangled.LabelDefinitionNSID: 117 119 err = i.ingestLabelDefinition(e, l) 118 120 case tangled.LabelOpNSID: 119 - err = i.ingestLabelOp(e, l) 121 + err = i.ingestLabelOp(ctx, e, l) 120 122 case tangled.RepoNSID: 121 123 err = i.ingestRepo(ctx, e, l) 122 124 } ··· 458 460 return fmt.Errorf("artifact record has neither valid repoDid nor repo field") 459 461 } 460 462 461 - ok, err := i.Enforcer.E.Enforce(did, repo.Knot, repo.RepoIdentifier(), "repo:push") 462 - if err != nil || !ok { 463 - return err 463 + allowed, permErr := i.Acl.HasRepoPermissionErr(ctx, repo, did, "repo:push") 464 + if permErr != nil { 465 + l.Warn("ingesting artifact without permission check", "did", did, "repo", repo.RepoIdentifier(), "err", permErr) 466 + } else if !allowed { 467 + l.Info("skipping unauthorized artifact", "did", did, "repo", repo.RepoIdentifier()) 468 + return nil 464 469 } 465 470 466 471 repoDid := repo.RepoDid ··· 473 478 } 474 479 } 475 480 476 - createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 477 - if err != nil { 481 + createdAt, parseErr := time.Parse(time.RFC3339, record.CreatedAt) 482 + if parseErr != nil { 478 483 createdAt = time.Now() 479 484 } 480 485 ··· 1778 1783 return nil 1779 1784 } 1780 1785 1781 - func (i *Ingester) ingestLabelOp(e *jmodels.Event, l *slog.Logger) error { 1786 + func (i *Ingester) ingestLabelOp(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { 1782 1787 did := e.Did 1783 1788 rkey := e.Commit.RKey 1784 1789 ··· 1828 1833 if !ok { 1829 1834 return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1830 1835 } 1831 - if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { 1832 - return fmt.Errorf("failed to validate labelop: %w", err) 1836 + if err := i.Validator.ValidateLabelOp(ctx, def, repo, &o); err != nil { 1837 + if !errors.Is(err, knotacl.ErrKnotUnreachable) { 1838 + return fmt.Errorf("failed to validate labelop: %w", err) 1839 + } 1840 + l.Warn("ingesting labelop without permission check", "did", o.Did, "err", err) 1833 1841 } 1834 1842 } 1835 1843
+6 -7
appview/issues/issues.go
··· 19 19 "tangled.org/core/appview/config" 20 20 "tangled.org/core/appview/db" 21 21 issues_indexer "tangled.org/core/appview/indexer/issues" 22 + "tangled.org/core/appview/knotacl" 22 23 "tangled.org/core/appview/mentions" 23 24 "tangled.org/core/appview/models" 24 25 "tangled.org/core/appview/notify" 25 26 "tangled.org/core/appview/oauth" 26 27 "tangled.org/core/appview/pages" 27 - "tangled.org/core/appview/pages/repoinfo" 28 28 "tangled.org/core/appview/pagination" 29 29 "tangled.org/core/appview/reporesolver" 30 30 "tangled.org/core/appview/searchquery" ··· 32 32 "tangled.org/core/idresolver" 33 33 "tangled.org/core/ogre" 34 34 "tangled.org/core/orm" 35 - "tangled.org/core/rbac" 36 35 "tangled.org/core/tid" 37 36 ) 38 37 39 38 type Issues struct { 40 39 oauth *oauth.OAuth 41 40 repoResolver *reporesolver.RepoResolver 42 - enforcer *rbac.Enforcer 41 + acl *knotacl.Service 43 42 pages *pages.Pages 44 43 idResolver *idresolver.Resolver 45 44 mentionsResolver *mentions.Resolver ··· 55 54 func New( 56 55 oauth *oauth.OAuth, 57 56 repoResolver *reporesolver.RepoResolver, 58 - enforcer *rbac.Enforcer, 57 + acl *knotacl.Service, 59 58 pages *pages.Pages, 60 59 idResolver *idresolver.Resolver, 61 60 mentionsResolver *mentions.Resolver, ··· 69 68 return &Issues{ 70 69 oauth: oauth, 71 70 repoResolver: repoResolver, 72 - enforcer: enforcer, 71 + acl: acl, 73 72 pages: pages, 74 73 idResolver: idResolver, 75 74 mentionsResolver: mentionsResolver, ··· 340 339 return 341 340 } 342 341 343 - roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 342 + roles := rp.acl.RolesInRepo(r.Context(), f, user.Did) 344 343 isRepoOwner := roles.IsOwner() 345 344 isCollaborator := roles.IsCollaborator() 346 345 isIssueOwner := user.Did == issue.Did ··· 388 387 return 389 388 } 390 389 391 - roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 390 + roles := rp.acl.RolesInRepo(r.Context(), f, user.Did) 392 391 isRepoOwner := roles.IsOwner() 393 392 isCollaborator := roles.IsCollaborator() 394 393 isIssueOwner := user.Did == issue.Did
+61 -8
appview/knots/knots.go
··· 6 6 "fmt" 7 7 "log/slog" 8 8 "net/http" 9 - "slices" 10 9 "strings" 11 10 "time" 12 11 ··· 14 13 "tangled.org/core/api/tangled" 15 14 "tangled.org/core/appview/config" 16 15 "tangled.org/core/appview/db" 16 + "tangled.org/core/appview/knotacl" 17 + "tangled.org/core/appview/knotcompat" 17 18 "tangled.org/core/appview/middleware" 18 19 "tangled.org/core/appview/models" 19 20 "tangled.org/core/appview/oauth" 20 21 "tangled.org/core/appview/pages" 21 22 "tangled.org/core/appview/serververify" 22 23 "tangled.org/core/appview/xrpcclient" 24 + "tangled.org/core/consts" 23 25 "tangled.org/core/eventconsumer" 24 26 "tangled.org/core/idresolver" 25 27 "tangled.org/core/orm" ··· 37 39 Pages *pages.Pages 38 40 Config *config.Config 39 41 Enforcer *rbac.Enforcer 42 + Acl *knotacl.Service 40 43 IdResolver *idresolver.Resolver 41 44 Logger *slog.Logger 42 45 Knotstream *eventconsumer.Consumer ··· 119 122 } 120 123 registration := registrations[0] 121 124 122 - members, err := k.Enforcer.GetUserByRole("server:member", domain) 123 - if err != nil { 124 - l.Error("failed to get knot members", "err", err) 125 - http.Error(w, "Not found", http.StatusInternalServerError) 126 - return 127 - } 128 - slices.Sort(members) 125 + members := k.Acl.KnotMembers(r.Context(), domain) 129 126 130 127 repos, err := db.GetRepos( 131 128 k.Db, ··· 556 553 return 557 554 } 558 555 556 + if knotcompat.KnotHasCapability(r.Context(), domain, k.Config.Core.Dev, consts.CapKnotACL) { 557 + client, err := k.OAuth.ServiceClient( 558 + r, 559 + oauth.WithService(domain), 560 + oauth.WithLxm(tangled.KnotAddMemberNSID), 561 + oauth.WithDev(k.Config.Core.Dev), 562 + ) 563 + if err != nil { 564 + l.Error("failed to create knot service client", "err", err) 565 + fail() 566 + return 567 + } 568 + 569 + err = tangled.KnotAddMember(r.Context(), client, &tangled.KnotAddMember_Input{ 570 + Subject: memberId.DID.String(), 571 + }) 572 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 573 + l.Error("failed to call XRPC knot.addMember", "xrpcerr", xrpcerr, "err", err) 574 + k.Pages.Notice(w, noticeId, xrpcerr.Error()) 575 + return 576 + } 577 + 578 + k.Acl.InvalidateMembers(domain) 579 + 580 + k.Pages.HxRedirect(w, fmt.Sprintf("/settings/knots/%s", domain)) 581 + return 582 + } 583 + 559 584 client, err := k.OAuth.AuthorizedClient(r) 560 585 if err != nil { 561 586 l.Error("failed to authorize client", "err", err) ··· 632 657 if err != nil { 633 658 l.Error("failed to resolve member identity to handle", "err", err) 634 659 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 660 + return 661 + } 662 + 663 + if knotcompat.KnotHasCapability(r.Context(), domain, k.Config.Core.Dev, consts.CapKnotACL) { 664 + client, err := k.OAuth.ServiceClient( 665 + r, 666 + oauth.WithService(domain), 667 + oauth.WithLxm(tangled.KnotRemoveMemberNSID), 668 + oauth.WithDev(k.Config.Core.Dev), 669 + ) 670 + if err != nil { 671 + l.Error("failed to create knot service client", "err", err) 672 + fail() 673 + return 674 + } 675 + 676 + err = tangled.KnotRemoveMember(r.Context(), client, &tangled.KnotRemoveMember_Input{ 677 + Subject: memberId.DID.String(), 678 + }) 679 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 680 + l.Error("failed to call XRPC knot.removeMember", "xrpcerr", xrpcerr, "err", err) 681 + k.Pages.Notice(w, noticeId, xrpcerr.Error()) 682 + return 683 + } 684 + 685 + k.Acl.InvalidateMembers(domain) 686 + 687 + k.Pages.HxRefresh(w) 635 688 return 636 689 } 637 690
+1 -1
appview/labels/labels.go
··· 167 167 168 168 for i := range labelOps { 169 169 def := actx.Defs[labelOps[i].OperandKey] 170 - if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 170 + if err := l.validator.ValidateLabelOp(r.Context(), def, repo, &labelOps[i]); err != nil { 171 171 fail(fmt.Sprintf("Invalid form data: %s", err), err) 172 172 return 173 173 }
+5 -3
appview/middleware/middleware.go
··· 17 17 "github.com/go-chi/chi/v5" 18 18 "tangled.org/core/appview/cache" 19 19 "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/knotacl" 20 21 "tangled.org/core/appview/models" 21 22 "tangled.org/core/appview/oauth" 22 23 "tangled.org/core/appview/pages" ··· 32 33 oauth *oauth.OAuth 33 34 db *db.DB 34 35 enforcer *rbac.Enforcer 36 + acl *knotacl.Service 35 37 repoResolver *reporesolver.RepoResolver 36 38 idResolver *idresolver.Resolver 37 39 pages *pages.Pages ··· 39 41 logger *slog.Logger 40 42 } 41 43 42 - func New(oauth *oauth.OAuth, db *db.DB, enforcer *rbac.Enforcer, repoResolver *reporesolver.RepoResolver, idResolver *idresolver.Resolver, pages *pages.Pages, rdb *cache.Cache, logger *slog.Logger) Middleware { 44 + func New(oauth *oauth.OAuth, db *db.DB, enforcer *rbac.Enforcer, acl *knotacl.Service, repoResolver *reporesolver.RepoResolver, idResolver *idresolver.Resolver, pages *pages.Pages, rdb *cache.Cache, logger *slog.Logger) Middleware { 43 45 return Middleware{ 44 46 oauth: oauth, 45 47 db: db, 46 48 enforcer: enforcer, 49 + acl: acl, 47 50 repoResolver: repoResolver, 48 51 idResolver: idResolver, 49 52 pages: pages, ··· 173 176 return 174 177 } 175 178 176 - ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.RepoIdentifier(), requiredPerm) 177 - if err != nil || !ok { 179 + if !mw.acl.HasRepoPermission(r.Context(), f, actor.Did, requiredPerm) { 178 180 l.Warn("permission denied", "did", actor.Did, "perm", requiredPerm, "repo", f.RepoIdentifier()) 179 181 http.Error(w, "Forbidden", http.StatusUnauthorized) 180 182 return
+13 -6
appview/oauth/oauth.go
··· 31 31 sessionCacheTTL = time.Hour 32 32 ) 33 33 34 + type KnotMembership interface { 35 + IsKnotMember(ctx context.Context, host, userDid string) bool 36 + InvalidateMembers(host string) 37 + } 38 + 34 39 type OAuth struct { 35 40 ClientApp *oauth.ClientApp 36 41 SessStore *sessions.CookieStore ··· 41 46 Posthog posthog.Client 42 47 Db *db.DB 43 48 Enforcer *rbac.Enforcer 49 + Acl KnotMembership 44 50 IdResolver *idresolver.Resolver 45 51 Logger *slog.Logger 46 52 ··· 92 98 return true 93 99 } 94 100 95 - func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) { 101 + func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, acl KnotMembership, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) { 96 102 var oauthConfig oauth.ClientConfig 97 103 var clientUri string 98 104 if config.Core.Dev { ··· 152 158 Posthog: ph, 153 159 Db: db, 154 160 Enforcer: enforcer, 161 + Acl: acl, 155 162 IdResolver: res, 156 163 Logger: logger, 157 164 sessionCache: expirable.NewLRU[string, *oauth.ClientSession](sessionCacheSize, nil, sessionCacheTTL), ··· 405 412 } 406 413 407 414 func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 408 - opts := DefaultServiceClientOpts() 409 - for _, o := range os { 410 - o(&opts) 411 - } 412 - 413 415 client, err := o.AuthorizedClient(r) 414 416 if err != nil { 415 417 return nil, err 418 + } 419 + 420 + opts := DefaultServiceClientOpts() 421 + for _, o := range os { 422 + o(&opts) 416 423 } 417 424 418 425 // force expiry to atleast 60 seconds in the future
+4
appview/oauth/scopes.go
··· 27 27 28 28 "blob:*/*", 29 29 30 + "rpc:sh.tangled.knot.addMember?aud=*", 31 + "rpc:sh.tangled.knot.removeMember?aud=*", 30 32 "rpc:sh.tangled.pipeline.cancelPipeline?aud=*", 33 + "rpc:sh.tangled.repo.addCollaborator?aud=*", 31 34 "rpc:sh.tangled.repo.addSecret?aud=*", 32 35 "rpc:sh.tangled.repo.create?aud=*", 33 36 "rpc:sh.tangled.repo.delete?aud=*", ··· 38 41 "rpc:sh.tangled.repo.listSecrets?aud=*", 39 42 "rpc:sh.tangled.repo.merge?aud=*", 40 43 "rpc:sh.tangled.repo.mergeCheck?aud=*", 44 + "rpc:sh.tangled.repo.removeCollaborator?aud=*", 41 45 "rpc:sh.tangled.repo.removeSecret?aud=*", 42 46 "rpc:sh.tangled.repo.setDefaultBranch?aud=*", 43 47 }
+13
appview/pages/templates/repo/settings/access.html
··· 20 20 </p> 21 21 </div> 22 22 {{ template "collaboratorsGrid" . }} 23 + <div id="collaborator-error" class="text-red-500 dark:text-red-400"></div> 23 24 </div> 24 25 {{ end }} 25 26 ··· 40 41 </a> 41 42 <p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p> 42 43 </div> 44 + 45 + {{ if and (eq .Role "collaborator") $.RepoInfo.Roles.CollaboratorInviteAllowed }} 46 + <button 47 + hx-delete="/{{ $.RepoInfo.FullName }}/settings/collaborator" 48 + hx-vals='{"collaborator": "{{ .Did }}"}' 49 + hx-confirm="Remove {{ $handle }} as a collaborator?" 50 + hx-swap="none" 51 + class="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400" 52 + title="Remove collaborator"> 53 + {{ i "trash-2" "size-4" }} 54 + </button> 55 + {{ end }} 43 56 </div> 44 57 </div> 45 58 {{ end }}
+1 -2
appview/pulls/compose.go
··· 18 18 "tangled.org/core/appview/oauth" 19 19 "tangled.org/core/appview/pages" 20 20 "tangled.org/core/appview/pages/markup" 21 - "tangled.org/core/appview/pages/repoinfo" 22 21 "tangled.org/core/appview/xrpcclient" 23 22 "tangled.org/core/patchutil" 24 23 "tangled.org/core/types" ··· 67 66 } 68 67 69 68 // Determine PR type based on input parameters 70 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(userDid.String(), f.Knot, f.RepoIdentifier())} 69 + roles := s.acl.RolesInRepo(r.Context(), f, userDid.String()) 71 70 isPushAllowed := roles.IsPushAllowed() 72 71 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 73 72 isForkBased := fromFork != "" && sourceBranch != ""
+3 -3
appview/pulls/create.go
··· 11 11 "time" 12 12 13 13 "tangled.org/core/api/tangled" 14 - "tangled.org/core/appview/compat113" 15 14 "tangled.org/core/appview/db" 15 + "tangled.org/core/appview/knotcompat" 16 16 "tangled.org/core/appview/models" 17 17 "tangled.org/core/appview/oauth" 18 18 "tangled.org/core/appview/reporesolver" ··· 303 303 Collection: tangled.RepoPullNSID, 304 304 Repo: userDid.String(), 305 305 Rkey: rkey, 306 - Record: compat113.Pull(&record), 306 + Record: knotcompat.Pull(&record), 307 307 }) 308 308 if err != nil { 309 309 l.Error("failed to create pull request", "err", err) ··· 403 403 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 404 404 Collection: tangled.RepoPullNSID, 405 405 Rkey: &p.Rkey, 406 - Value: compat113.Pull(&record), 406 + Value: knotcompat.Pull(&record), 407 407 }, 408 408 }) 409 409 }
+1 -1
appview/pulls/labels.go
··· 140 140 valid := make([]models.LabelOp, 0, len(raw)) 141 141 for _, op := range raw { 142 142 def := defs[op.OperandKey] 143 - if err := s.validator.ValidateLabelOp(def, repo, &op); err != nil { 143 + if err := s.validator.ValidateLabelOp(ctx, def, repo, &op); err != nil { 144 144 l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 145 145 continue 146 146 }
+2 -3
appview/pulls/lifecycle.go
··· 6 6 7 7 "tangled.org/core/appview/db" 8 8 "tangled.org/core/appview/models" 9 - "tangled.org/core/appview/pages/repoinfo" 10 9 "tangled.org/core/appview/reporesolver" 11 10 "tangled.org/core/orm" 12 11 ··· 36 35 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 37 36 38 37 // auth filter: only owner or collaborators can close 39 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 38 + roles := s.acl.RolesInRepo(r.Context(), f, user.Did) 40 39 isOwner := roles.IsOwner() 41 40 isCollaborator := roles.IsCollaborator() 42 41 isPullAuthor := user.Did == pull.OwnerDid ··· 113 112 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "state", pull.State) 114 113 115 114 // auth filter: only owner or collaborators can close 116 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 115 + roles := s.acl.RolesInRepo(r.Context(), f, user.Did) 117 116 isOwner := roles.IsOwner() 118 117 isCollaborator := roles.IsCollaborator() 119 118 isPullAuthor := user.Did == pull.OwnerDid
+4 -4
appview/pulls/pulls.go
··· 10 10 "tangled.org/core/appview/config" 11 11 "tangled.org/core/appview/db" 12 12 pulls_indexer "tangled.org/core/appview/indexer/pulls" 13 + "tangled.org/core/appview/knotacl" 13 14 "tangled.org/core/appview/mentions" 14 15 "tangled.org/core/appview/models" 15 16 "tangled.org/core/appview/notify" ··· 19 20 "tangled.org/core/appview/validator" 20 21 "tangled.org/core/idresolver" 21 22 "tangled.org/core/ogre" 22 - "tangled.org/core/rbac" 23 23 24 24 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 25 25 ) ··· 35 35 db *db.DB 36 36 config *config.Config 37 37 notifier notify.Notifier 38 - enforcer *rbac.Enforcer 38 + acl *knotacl.Service 39 39 logger *slog.Logger 40 40 validator *validator.Validator 41 41 indexer *pulls_indexer.Indexer ··· 51 51 db *db.DB, 52 52 config *config.Config, 53 53 notifier notify.Notifier, 54 - enforcer *rbac.Enforcer, 54 + acl *knotacl.Service, 55 55 validator *validator.Validator, 56 56 indexer *pulls_indexer.Indexer, 57 57 logger *slog.Logger, ··· 65 65 db: db, 66 66 config: config, 67 67 notifier: notifier, 68 - enforcer: enforcer, 68 + acl: acl, 69 69 logger: logger, 70 70 validator: validator, 71 71 indexer: indexer,
+5 -6
appview/pulls/resubmit.go
··· 7 7 "time" 8 8 9 9 "tangled.org/core/api/tangled" 10 - "tangled.org/core/appview/compat113" 11 10 "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/knotcompat" 12 12 "tangled.org/core/appview/models" 13 13 "tangled.org/core/appview/oauth" 14 14 "tangled.org/core/appview/pages" 15 - "tangled.org/core/appview/pages/repoinfo" 16 15 "tangled.org/core/appview/reporesolver" 17 16 "tangled.org/core/appview/xrpcclient" 18 17 "tangled.org/core/orm" ··· 123 122 return 124 123 } 125 124 126 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 125 + roles := s.acl.RolesInRepo(r.Context(), f, user.Did) 127 126 if !roles.IsPushAllowed() { 128 127 l.Warn("unauthorized user - no push permission") 129 128 w.WriteHeader(http.StatusUnauthorized) ··· 331 330 Repo: userDid.String(), 332 331 Rkey: pull.Rkey, 333 332 SwapRecord: ex.Cid, 334 - Record: compat113.Pull(&record), 333 + Record: knotcompat.Pull(&record), 335 334 }) 336 335 if err != nil { 337 336 l.Error("failed to update record on PDS", "err", err, "rkey", pull.Rkey) ··· 520 519 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 521 520 Collection: tangled.RepoPullNSID, 522 521 Rkey: &p.Rkey, 523 - Value: compat113.Pull(&record), 522 + Value: knotcompat.Pull(&record), 524 523 }, 525 524 }) 526 525 } ··· 578 577 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 579 578 Collection: tangled.RepoPullNSID, 580 579 Rkey: op.Rkey, 581 - Value: compat113.Pull(&record), 580 + Value: knotcompat.Pull(&record), 582 581 }, 583 582 }) 584 583 }
+1 -3
appview/pulls/single.go
··· 3 3 import ( 4 4 "fmt" 5 5 "net/http" 6 - "slices" 7 6 "strconv" 8 7 9 8 "tangled.org/core/api/tangled" ··· 364 363 } 365 364 366 365 // user can only delete branch if they are a collaborator in the repo that the branch belongs to 367 - perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.RepoIdentifier()) 368 - if !slices.Contains(perms, "repo:push") { 366 + if !s.acl.HasRepoPermission(r.Context(), repo, user.Did, "repo:push") { 369 367 return nil 370 368 } 371 369
+185 -10
appview/repo/repo.go
··· 15 15 "tangled.org/core/appview/cloudflare" 16 16 17 17 "tangled.org/core/api/tangled" 18 - "tangled.org/core/appview/compat113" 19 18 "tangled.org/core/appview/config" 20 19 "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/knotacl" 21 + "tangled.org/core/appview/knotcompat" 21 22 "tangled.org/core/appview/models" 22 23 "tangled.org/core/appview/notify" 23 24 "tangled.org/core/appview/oauth" ··· 27 28 "tangled.org/core/appview/sites" 28 29 "tangled.org/core/appview/validator" 29 30 xrpcclient "tangled.org/core/appview/xrpcclient" 31 + "tangled.org/core/consts" 30 32 "tangled.org/core/eventconsumer" 31 33 "tangled.org/core/idresolver" 32 34 "tangled.org/core/ogre" ··· 52 54 spindlestream *eventconsumer.Consumer 53 55 db *db.DB 54 56 enforcer *rbac.Enforcer 57 + acl *knotacl.Service 55 58 notifier notify.Notifier 56 59 logger *slog.Logger 57 60 serviceAuth *serviceauth.ServiceAuth ··· 70 73 config *config.Config, 71 74 notifier notify.Notifier, 72 75 enforcer *rbac.Enforcer, 76 + acl *knotacl.Service, 73 77 logger *slog.Logger, 74 78 validator *validator.Validator, 75 79 cfClient *cloudflare.Client, ··· 84 88 db: db, 85 89 notifier: notifier, 86 90 enforcer: enforcer, 91 + acl: acl, 87 92 logger: logger, 88 93 validator: validator, 89 94 cfClient: cfClient, ··· 754 759 l = l.With("collaborator", collaboratorIdent.Handle) 755 760 l = l.With("knot", f.Knot) 756 761 762 + if knotcompat.KnotHasCapability(r.Context(), f.Knot, rp.config.Core.Dev, consts.CapKnotACL) { 763 + if f.RepoDid == "" { 764 + fail("This repository is missing its DID and cannot manage collaborators.", nil) 765 + return 766 + } 767 + 768 + client, err := rp.oauth.ServiceClient( 769 + r, 770 + oauth.WithService(f.Knot), 771 + oauth.WithLxm(tangled.RepoAddCollaboratorNSID), 772 + oauth.WithDev(rp.config.Core.Dev), 773 + ) 774 + if err != nil { 775 + fail("Failed to connect to knot server.", err) 776 + return 777 + } 778 + 779 + err = tangled.RepoAddCollaborator(r.Context(), client, &tangled.RepoAddCollaborator_Input{ 780 + Repo: f.RepoDid, 781 + Subject: collaboratorIdent.DID.String(), 782 + }) 783 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 784 + l.Error("failed to call XRPC repo.addCollaborator", "xrpcerr", xrpcerr, "err", err) 785 + rp.pages.Notice(w, errorId, xrpcerr.Error()) 786 + return 787 + } 788 + 789 + rp.acl.InvalidateCollaborators(f.Knot, f.RepoDid) 790 + 791 + rp.pages.HxRefresh(w) 792 + return 793 + } 794 + 757 795 existing, err := db.GetCollaborators(rp.db, 758 796 orm.FilterEq("repo_did", f.RepoDid), 759 797 orm.FilterEq("subject_did", collaboratorIdent.DID.String()), ··· 782 820 Collection: tangled.RepoCollaboratorNSID, 783 821 Repo: currentUser.Did, 784 822 Rkey: rkey, 785 - Record: compat113.Collaborator(repoCollaboratorRecord(f, collaboratorIdent.DID.String(), createdAt)), 823 + Record: knotcompat.Collaborator(repoCollaboratorRecord(f, collaboratorIdent.DID.String(), createdAt)), 786 824 }) 787 825 // invalid record 788 826 if err != nil { ··· 853 891 rp.pages.HxRefresh(w) 854 892 } 855 893 894 + func (rp *Repo) RemoveCollaborator(w http.ResponseWriter, r *http.Request) { 895 + user := rp.oauth.GetMultiAccountUser(r) 896 + l := rp.logger.With("handler", "RemoveCollaborator") 897 + l = l.With("did", user.Did) 898 + 899 + f, err := rp.repoResolver.Resolve(r) 900 + if err != nil { 901 + l.Error("failed to get repo and knot", "err", err) 902 + return 903 + } 904 + 905 + errorId := "collaborator-error" 906 + fail := func(msg string, err error) { 907 + l.Error(msg, "err", err) 908 + rp.pages.Notice(w, errorId, msg) 909 + } 910 + 911 + collaborator := r.FormValue("collaborator") 912 + if collaborator == "" { 913 + fail("Invalid form.", nil) 914 + return 915 + } 916 + collaborator = strings.TrimPrefix(collaborator, "@") 917 + 918 + collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 919 + if err != nil { 920 + fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) 921 + return 922 + } 923 + l = l.With("collaborator", collaboratorIdent.Handle, "knot", f.Knot) 924 + 925 + if collaboratorIdent.DID.String() == f.Did { 926 + fail("Cannot remove the repository owner.", nil) 927 + return 928 + } 929 + 930 + if knotcompat.KnotHasCapability(r.Context(), f.Knot, rp.config.Core.Dev, consts.CapKnotACL) { 931 + if f.RepoDid == "" { 932 + fail("This repository is missing its DID and cannot manage collaborators.", nil) 933 + return 934 + } 935 + 936 + client, err := rp.oauth.ServiceClient( 937 + r, 938 + oauth.WithService(f.Knot), 939 + oauth.WithLxm(tangled.RepoRemoveCollaboratorNSID), 940 + oauth.WithDev(rp.config.Core.Dev), 941 + ) 942 + if err != nil { 943 + fail("Failed to connect to knot server.", err) 944 + return 945 + } 946 + 947 + err = tangled.RepoRemoveCollaborator(r.Context(), client, &tangled.RepoRemoveCollaborator_Input{ 948 + Repo: f.RepoDid, 949 + Subject: collaboratorIdent.DID.String(), 950 + }) 951 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 952 + l.Error("failed to call XRPC repo.removeCollaborator", "xrpcerr", xrpcerr, "err", err) 953 + rp.pages.Notice(w, errorId, xrpcerr.Error()) 954 + return 955 + } 956 + 957 + rp.acl.InvalidateCollaborators(f.Knot, f.RepoDid) 958 + 959 + rp.pages.HxRefresh(w) 960 + return 961 + } 962 + 963 + existing, err := db.GetCollaborators(rp.db, 964 + orm.FilterEq("repo_did", f.RepoDid), 965 + orm.FilterEq("subject_did", collaboratorIdent.DID.String()), 966 + ) 967 + if err != nil { 968 + fail("Failed to look up collaborator.", err) 969 + return 970 + } 971 + if len(existing) == 0 { 972 + fail(fmt.Sprintf("%s is not a collaborator.", collaboratorIdent.Handle), nil) 973 + return 974 + } 975 + row := existing[0] 976 + 977 + client, err := rp.oauth.AuthorizedClient(r) 978 + if err != nil { 979 + fail("Failed to write to PDS.", err) 980 + return 981 + } 982 + 983 + tx, err := rp.db.BeginTx(r.Context(), nil) 984 + if err != nil { 985 + fail("Failed to remove collaborator.", err) 986 + return 987 + } 988 + committed := false 989 + defer func() { 990 + if !committed { 991 + tx.Rollback() 992 + if err := rp.enforcer.E.LoadPolicy(); err != nil { 993 + l.Error("failed to reload policy after rollback", "err", err) 994 + } 995 + } 996 + }() 997 + 998 + if err := rp.enforcer.RemoveCollaborator(collaboratorIdent.DID.String(), f.Knot, f.RepoIdentifier()); err != nil { 999 + fail("Failed to remove collaborator permissions.", err) 1000 + return 1001 + } 1002 + 1003 + if err := db.DeleteCollaborator(tx, 1004 + orm.FilterEq("repo_did", f.RepoDid), 1005 + orm.FilterEq("subject_did", collaboratorIdent.DID.String()), 1006 + ); err != nil { 1007 + fail("Failed to remove collaborator.", err) 1008 + return 1009 + } 1010 + 1011 + if row.Rkey.Valid && row.Rkey.String != "" { 1012 + if _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1013 + Collection: tangled.RepoCollaboratorNSID, 1014 + Repo: row.Did.String(), 1015 + Rkey: row.Rkey.String, 1016 + }); err != nil { 1017 + fail("Failed to delete collaborator record from PDS.", err) 1018 + return 1019 + } 1020 + } 1021 + 1022 + if err := tx.Commit(); err != nil { 1023 + fail("Failed to remove collaborator.", err) 1024 + return 1025 + } 1026 + committed = true 1027 + 1028 + if err := rp.enforcer.E.SavePolicy(); err != nil { 1029 + fail("Failed to update collaborator permissions.", err) 1030 + return 1031 + } 1032 + 1033 + rp.pages.HxRefresh(w) 1034 + } 1035 + 856 1036 func (rp *Repo) RenameRepo(w http.ResponseWriter, r *http.Request) { 857 1037 l := rp.logger.With("handler", "RenameRepo") 858 1038 noticeId := "rename-repo-error" ··· 871 1051 return 872 1052 } 873 1053 874 - if !compat113.KnotSupports114(r.Context(), f.Knot, rp.config.Core.Dev) { 1054 + if !knotcompat.KnotSupports114(r.Context(), f.Knot, rp.config.Core.Dev) { 875 1055 rp.pages.Notice(w, noticeId, "This repository's knot is below v1.14 and does not yet support renames. Ask the knot operator to upgrade.") 876 1056 return 877 1057 } ··· 1242 1422 switch r.Method { 1243 1423 case http.MethodGet: 1244 1424 user := rp.oauth.GetMultiAccountUser(r) 1245 - knots, err := rp.enforcer.GetKnotsForUser(user.Did) 1246 - if err != nil { 1247 - rp.pages.Notice(w, "repo", "Invalid user account.") 1248 - return 1249 - } 1425 + knots := rp.acl.KnotsForUser(r.Context(), user.Did) 1250 1426 1251 1427 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1252 1428 LoggedInUser: user, ··· 1264 1440 } 1265 1441 l = l.With("targetKnot", targetKnot) 1266 1442 1267 - ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1268 - if err != nil || !ok { 1443 + if !rp.acl.IsRepoCreateAllowed(r.Context(), targetKnot, user.Did) { 1269 1444 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1270 1445 return 1271 1446 }
+1
appview/repo/router.go
··· 88 88 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/label/subscribe", rp.SubscribeLabel) 89 89 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/label/unsubscribe", rp.UnsubscribeLabel) 90 90 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 91 + r.With(mw.RepoPermissionMiddleware("repo:invite")).Delete("/collaborator", rp.RemoveCollaborator) 91 92 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 92 93 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/rename", rp.RenameRepo) 93 94 r.Put("/branches/default", rp.SetDefaultBranch)
+5 -31
appview/repo/settings.go
··· 448 448 l := rp.logger.With("handler", "accessSettings") 449 449 450 450 f, err := rp.repoResolver.Resolve(r) 451 - user := rp.oauth.GetMultiAccountUser(r) 452 - 453 - collaborators, err := func(repo *models.Repo) ([]pages.Collaborator, error) { 454 - repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(repo.RepoIdentifier(), repo.Knot) 455 - if err != nil { 456 - return nil, err 457 - } 458 - var collaborators []pages.Collaborator 459 - for _, item := range repoCollaborators { 460 - // currently only two roles: owner and member 461 - var role string 462 - switch item[3] { 463 - case "repo:owner": 464 - role = "owner" 465 - case "repo:collaborator": 466 - role = "collaborator" 467 - default: 468 - continue 469 - } 470 - 471 - did := item[0] 472 - 473 - c := pages.Collaborator{ 474 - Did: did, 475 - Role: role, 476 - } 477 - collaborators = append(collaborators, c) 478 - } 479 - return collaborators, nil 480 - }(f) 481 451 if err != nil { 482 - l.Error("failed to get collaborators", "err", err) 452 + l.Error("failed to resolve repo", "err", err) 453 + return 483 454 } 455 + user := rp.oauth.GetMultiAccountUser(r) 456 + 457 + collaborators := rp.acl.Collaborators(r.Context(), f) 484 458 485 459 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 486 460 LoggedInUser: user,
+8 -8
appview/reporesolver/resolver.go
··· 13 13 "tangled.org/core/appview/cache" 14 14 "tangled.org/core/appview/config" 15 15 "tangled.org/core/appview/db" 16 + "tangled.org/core/appview/knotacl" 16 17 "tangled.org/core/appview/models" 17 18 "tangled.org/core/appview/oauth" 18 19 "tangled.org/core/appview/pages/repoinfo" 19 - "tangled.org/core/rbac" 20 20 ) 21 21 22 22 var ( ··· 25 25 ) 26 26 27 27 type RepoResolver struct { 28 - config *config.Config 29 - enforcer *rbac.Enforcer 30 - execer db.Execer 31 - rdb *cache.Cache 28 + config *config.Config 29 + acl *knotacl.Service 30 + execer db.Execer 31 + rdb *cache.Cache 32 32 } 33 33 34 - func New(config *config.Config, enforcer *rbac.Enforcer, execer db.Execer, rdb *cache.Cache) *RepoResolver { 35 - return &RepoResolver{config: config, enforcer: enforcer, execer: execer, rdb: rdb} 34 + func New(config *config.Config, acl *knotacl.Service, execer db.Execer, rdb *cache.Cache) *RepoResolver { 35 + return &RepoResolver{config: config, acl: acl, execer: execer, rdb: rdb} 36 36 } 37 37 38 38 func CanonicalRepoPath(handle string, repo *models.Repo) string { ··· 102 102 roles := repoinfo.RolesInRepo{} 103 103 if user != nil { 104 104 isStarred = db.GetStarStatus(rr.execer, user.Did, repoDid) 105 - roles.Roles = rr.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.RepoIdentifier()) 105 + roles = rr.acl.RolesInRepo(r.Context(), repo, user.Did) 106 106 } 107 107 108 108 stats := repo.RepoStats
+10 -6
appview/state/knotstream.go
··· 16 16 "tangled.org/core/api/tangled" 17 17 "tangled.org/core/appview/config" 18 18 "tangled.org/core/appview/db" 19 + "tangled.org/core/appview/knotcompat" 19 20 "tangled.org/core/appview/models" 20 21 "tangled.org/core/appview/sites" 22 + "tangled.org/core/consts" 21 23 ec "tangled.org/core/eventconsumer" 22 24 "tangled.org/core/eventstream" 23 25 knotdb "tangled.org/core/knotserver/db" ··· 88 90 return err 89 91 } 90 92 91 - knownKnots, err := enforcer.GetKnotsForUser(record.CommitterDid) 92 - if err != nil { 93 - return err 94 - } 95 - if !slices.Contains(knownKnots, source.Host) { 96 - return fmt.Errorf("%s does not belong to %s, something is fishy", record.CommitterDid, source.Host) 93 + if !knotcompat.KnotHasCapability(ctx, source.Host, dev, consts.CapKnotACL) { 94 + knownKnots, err := enforcer.GetKnotsForUser(record.CommitterDid) 95 + switch { 96 + case err != nil: 97 + logger.Warn("gitRefUpdate membership lookup failed, ingesting without the sanity check", "committer", record.CommitterDid, "knot", source.Host, "err", err) 98 + case !slices.Contains(knownKnots, source.Host): 99 + logger.Warn("gitRefUpdate committer is not a known member of the knot, ingesting anyway", "committer", record.CommitterDid, "knot", source.Host) 100 + } 97 101 } 98 102 99 103 if record.Repo == "" {
+7 -2
appview/state/router.go
··· 10 10 "github.com/go-chi/chi/v5" 11 11 "tangled.org/core/appview/db" 12 12 "tangled.org/core/appview/issues" 13 + "tangled.org/core/appview/knotacl" 13 14 "tangled.org/core/appview/knots" 14 15 "tangled.org/core/appview/labels" 15 16 "tangled.org/core/appview/metrics" ··· 33 34 s.oauth, 34 35 s.db, 35 36 s.enforcer, 37 + s.aclService, 36 38 s.repoResolver, 37 39 s.idResolver, 38 40 s.pages, ··· 41 43 ) 42 44 43 45 router.Use(metrics.Middleware) 46 + router.Use(knotacl.MemoMiddleware) 44 47 45 48 if err := db.ReapStaleRunningMigrations(context.Background(), s.db); err != nil { 46 49 s.logger.Warn("failed to reap stale running migrations", "err", err) ··· 311 314 Pages: s.pages, 312 315 Config: s.config, 313 316 Enforcer: s.enforcer, 317 + Acl: s.aclService, 314 318 IdResolver: s.idResolver, 315 319 Knotstream: s.knotstream, 316 320 Logger: logger, ··· 338 342 issues := issues.New( 339 343 s.oauth, 340 344 s.repoResolver, 341 - s.enforcer, 345 + s.aclService, 342 346 s.pages, 343 347 s.idResolver, 344 348 s.mentionsResolver, ··· 362 366 s.db, 363 367 s.config, 364 368 s.notifier, 365 - s.enforcer, 369 + s.aclService, 366 370 s.validator, 367 371 s.indexer.Pulls, 368 372 log.SubLogger(s.logger, "pulls"), ··· 381 385 s.config, 382 386 s.notifier, 383 387 s.enforcer, 388 + s.aclService, 384 389 log.SubLogger(s.logger, "repo"), 385 390 s.validator, 386 391 s.cfClient,
+13 -10
appview/state/state.go
··· 19 19 "tangled.org/core/appview/db" 20 20 "tangled.org/core/appview/email" 21 21 "tangled.org/core/appview/indexer" 22 + "tangled.org/core/appview/knotacl" 23 + "tangled.org/core/appview/knotcompat" 22 24 "tangled.org/core/appview/mentions" 23 25 "tangled.org/core/appview/models" 24 26 "tangled.org/core/appview/notify" ··· 67 69 jc *jetstream.JetstreamClient 68 70 config *config.Config 69 71 repoResolver *reporesolver.RepoResolver 72 + aclService *knotacl.Service 70 73 knotstream *eventconsumer.Consumer 71 74 spindlestream *eventconsumer.Consumer 72 75 pipelineNotifier *pipelines.StatusNotifier ··· 111 114 } 112 115 113 116 pages := pages.NewPages(config, res, d, rdb, log.SubLogger(logger, "pages")) 114 - oauth, err := oauth.New(config, posthog, d, enforcer, res, log.SubLogger(logger, "oauth")) 117 + knotcompat.UseNativeLatch(knotacl.NewLatch(d, log.SubLogger(logger, "knotacl-latch"))) 118 + aclService := knotacl.NewService(enforcer, d, config.Core.Dev, log.SubLogger(logger, "knotacl")) 119 + oauth, err := oauth.New(config, posthog, d, enforcer, aclService, res, log.SubLogger(logger, "oauth")) 115 120 if err != nil { 116 121 return nil, fmt.Errorf("failed to start oauth handler: %w", err) 117 122 } 118 - validator := validator.New(d, res, enforcer) 119 123 120 - repoResolver := reporesolver.New(config, enforcer, d, rdb) 124 + validator := validator.New(d, res, aclService) 125 + 126 + repoResolver := reporesolver.New(config, aclService, d, rdb) 121 127 122 128 mentionsResolver := mentions.New(config, res, d, log.SubLogger(logger, "mentionsResolver")) 123 129 ··· 183 189 Ctx: ctx, 184 190 Db: d, 185 191 Enforcer: enforcer, 192 + Acl: aclService, 186 193 IdResolver: res, 187 194 Cache: rdb, 188 195 Config: config, ··· 236 243 jc: jc, 237 244 config: config, 238 245 repoResolver: repoResolver, 246 + aclService: aclService, 239 247 knotstream: knotstream, 240 248 spindlestream: spindlestream, 241 249 pipelineNotifier: pipelineNotifier, ··· 447 455 switch r.Method { 448 456 case http.MethodGet: 449 457 user := s.oauth.GetMultiAccountUser(r) 450 - knots, err := s.enforcer.GetKnotsForUser(user.Did) 451 - if err != nil { 452 - s.pages.Notice(w, "repo", "Invalid user account.") 453 - return 454 - } 458 + knots := s.aclService.KnotsForUser(r.Context(), user.Did) 455 459 456 460 s.pages.NewRepo(w, pages.NewRepoParams{ 457 461 LoggedInUser: user, ··· 499 503 } 500 504 501 505 // ACL validation 502 - ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 503 - if err != nil || !ok { 506 + if !s.aclService.IsRepoCreateAllowed(r.Context(), domain, user.Did) { 504 507 l.Info("unauthorized") 505 508 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 506 509 return
+12 -12
appview/validator/label.go
··· 95 95 return nil 96 96 } 97 97 98 - func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { 98 + func (v *Validator) ValidateLabelOp(ctx context.Context, labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { 99 99 if labelDef == nil { 100 100 return fmt.Errorf("label definition is required") 101 101 } ··· 104 104 } 105 105 if labelOp == nil { 106 106 return fmt.Errorf("label operation is required") 107 - } 108 - 109 - // validate permissions: only collaborators can apply labels currently 110 - // 111 - // TODO: introduce a repo:triage permission 112 - ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.RepoIdentifier()) 113 - if err != nil { 114 - return fmt.Errorf("failed to enforce permissions: %w", err) 115 - } 116 - if !ok { 117 - return fmt.Errorf("unauhtorized label operation") 118 107 } 119 108 120 109 expectedKey := labelDef.AtUri().String() ··· 140 129 // Validate performed time is not zero/invalid 141 130 if labelOp.PerformedAt.IsZero() { 142 131 return fmt.Errorf("performed_at timestamp is required") 132 + } 133 + 134 + // validate permissions: only collaborators can apply labels currently 135 + // 136 + // TODO: introduce a repo:triage permission 137 + ok, err := v.acl.HasRepoPermissionErr(ctx, repo, labelOp.Did, "repo:push") 138 + if err != nil { 139 + return err 140 + } 141 + if !ok { 142 + return fmt.Errorf("unauthorized label operation") 143 143 } 144 144 145 145 return nil
+87
appview/validator/label_test.go
··· 1 + package validator 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "io" 8 + "log/slog" 9 + "net/http" 10 + "net/http/httptest" 11 + "path/filepath" 12 + "strings" 13 + "testing" 14 + "time" 15 + 16 + "tangled.org/core/api/tangled" 17 + "tangled.org/core/appview/db" 18 + "tangled.org/core/appview/knotacl" 19 + "tangled.org/core/appview/models" 20 + "tangled.org/core/consts" 21 + "tangled.org/core/rbac" 22 + ) 23 + 24 + func unreachableListValidator(t *testing.T) (*Validator, string) { 25 + t.Helper() 26 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 27 + switch { 28 + case strings.HasSuffix(r.URL.Path, tangled.KnotVersionNSID): 29 + json.NewEncoder(w).Encode(tangled.KnotVersion_Output{Version: "v1.15.0", Capabilities: []string{string(consts.CapKnotACL)}}) 30 + case strings.HasSuffix(r.URL.Path, tangled.RepoListCollaboratorsNSID): 31 + http.Error(w, "list down", http.StatusInternalServerError) 32 + default: 33 + http.NotFound(w, r) 34 + } 35 + })) 36 + t.Cleanup(srv.Close) 37 + host := strings.TrimPrefix(srv.URL, "http://") 38 + 39 + dir := t.TempDir() 40 + enforcer, err := rbac.NewEnforcer(filepath.Join(dir, "rbac.db")) 41 + if err != nil { 42 + t.Fatalf("NewEnforcer: %v", err) 43 + } 44 + d, err := db.Make(context.Background(), filepath.Join(dir, "appview.db")) 45 + if err != nil { 46 + t.Fatalf("db.Make: %v", err) 47 + } 48 + svc := knotacl.NewService(enforcer, d, true, slog.New(slog.NewTextHandler(io.Discard, nil))) 49 + return &Validator{acl: svc}, host 50 + } 51 + 52 + func TestValidateLabelOp_MalformedRejectedBeforePermCheck(t *testing.T) { 53 + v, host := unreachableListValidator(t) 54 + def := &models.LabelDefinition{Did: "did:plc:akshay", Rkey: "deadbeef"} 55 + repo := &models.Repo{Did: "did:plc:akshay", Knot: host, RepoDid: "did:plc:limpet"} 56 + 57 + op := &models.LabelOp{ 58 + Did: "did:plc:scallop", 59 + OperandKey: "does-not-match-the-def-aturi", 60 + Operation: "garbage", 61 + } 62 + err := v.ValidateLabelOp(context.Background(), def, repo, op) 63 + if errors.Is(err, knotacl.ErrKnotUnreachable) { 64 + t.Fatalf("malformed op returned ErrKnotUnreachable; structural validation did not run before the perm check") 65 + } 66 + if err == nil || !strings.Contains(err.Error(), "operand key") { 67 + t.Fatalf("want a structural operand-key error, got %v", err) 68 + } 69 + } 70 + 71 + func TestValidateLabelOp_WellFormedFailsOpenWhenKnotUnreachable(t *testing.T) { 72 + v, host := unreachableListValidator(t) 73 + def := &models.LabelDefinition{Did: "did:plc:akshay", Rkey: "deadbeef"} 74 + repo := &models.Repo{Did: "did:plc:akshay", Knot: host, RepoDid: "did:plc:limpet"} 75 + 76 + op := &models.LabelOp{ 77 + Did: "did:plc:scallop", 78 + OperandKey: def.AtUri().String(), 79 + Operation: models.LabelOperationAdd, 80 + Subject: "at://did:plc:limpet/sh.tangled.repo.issue/abc123", 81 + PerformedAt: time.Now(), 82 + } 83 + err := v.ValidateLabelOp(context.Background(), def, repo, op) 84 + if !errors.Is(err, knotacl.ErrKnotUnreachable) { 85 + t.Fatalf("well-formed op against an unreachable knot = %v, want ErrKnotUnreachable so the ingester fails open", err) 86 + } 87 + }
+4 -4
appview/validator/validator.go
··· 2 2 3 3 import ( 4 4 "tangled.org/core/appview/db" 5 + "tangled.org/core/appview/knotacl" 5 6 "tangled.org/core/appview/pages/markup" 6 7 "tangled.org/core/idresolver" 7 - "tangled.org/core/rbac" 8 8 ) 9 9 10 10 type Validator struct { 11 11 db *db.DB 12 12 sanitizer markup.Sanitizer 13 13 resolver *idresolver.Resolver 14 - enforcer *rbac.Enforcer 14 + acl *knotacl.Service 15 15 } 16 16 17 - func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 17 + func New(db *db.DB, res *idresolver.Resolver, acl *knotacl.Service) *Validator { 18 18 return &Validator{ 19 19 db: db, 20 20 sanitizer: markup.NewSanitizer(), 21 21 resolver: res, 22 - enforcer: enforcer, 22 + acl: acl, 23 23 } 24 24 }
+1
nix/modules/appview.nix
··· 270 270 {env}`TANGLED_OAUTH_CLIENT_SECRET`, {env}`TANGLED_RESEND_API_KEY`, 271 271 {env}`TANGLED_CAMO_SHARED_SECRET`, {env}`TANGLED_AVATAR_SHARED_SECRET`, 272 272 {env}`TANGLED_REDIS_PASS`, {env}`TANGLED_PDS_ADMIN_SECRET`, 273 + {env}`TANGLED_KNOT_ADMIN_SECRET`, 273 274 {env}`TANGLED_CLOUDFLARE_API_TOKEN`, {env}`TANGLED_CLOUDFLARE_ZONE_ID`, 274 275 {env}`TANGLED_CLOUDFLARE_TURNSTILE_SITE_KEY`, 275 276 {env}`TANGLED_CLOUDFLARE_TURNSTILE_SECRET_KEY`,