Monorepo for Tangled tangled.org

feat(repo): add collaborator removal #1123

open opened by murex.tngl.sh targeting master from murex.tngl.sh/tangled: feat/remove-collaborator
  • Add RemoveCollaborator handler: delete PDS record, RBAC policy, and DB row
  • Add DELETE /settings/collaborator route (repo:owner)
  • Load collaborator rkey from DB for removal
  • Add trash icon remove button on access settings

close: https://tangled.org/tangled.org/core/issues/210

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:owyua2lvxbs55wyhs22dqu2s/sh.tangled.repo.pull/3mgjrpxldea22
+278 -9
Diff #1
+3 -2
appview/pages/pages.go
··· 902 902 } 903 903 904 904 type Collaborator struct { 905 - Did string 906 - Role string 905 + Did string 906 + Role string 907 + Rkey string // set for collaborators (not owner) so remove button can delete the PDS record 907 908 } 908 909 909 910 type RepoSettingsParams struct {
+19
appview/pages/templates/repo/settings/access.html
··· 13 13 14 14 {{ define "collaboratorSettings" }} 15 15 <div class="grid grid-cols-1 gap-4 items-center"> 16 + <div id="remove-collaborator-error" class="text-red-500 dark:text-red-400"></div> 16 17 <div class="col-span-1"> 17 18 <h2 class="text-sm pb-2 uppercase font-bold">Collaborators</h2> 18 19 <p class="text-gray-500 dark:text-gray-400"> ··· 40 41 </a> 41 42 <p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p> 42 43 </div> 44 + {{ if and (eq .Role "collaborator") .Rkey $.RepoInfo.Roles.IsOwner }} 45 + <form 46 + method="post" 47 + action="/{{ $.RepoInfo.FullName }}/settings/collaborator?subject_did={{ .Did }}" 48 + hx-delete="/{{ $.RepoInfo.FullName }}/settings/collaborator?subject_did={{ .Did }}" 49 + hx-swap="none" 50 + hx-confirm="Remove {{ $handle }} from collaborators?" 51 + class="inline" 52 + > 53 + <button 54 + type="submit" 55 + class="btn-ghost p-1 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 56 + title="Remove collaborator" 57 + > 58 + {{ i "trash-2" "size-4" }} 59 + </button> 60 + </form> 61 + {{ end }} 43 62 </div> 44 63 </div> 45 64 {{ end }}
+95
appview/repo/repo.go
··· 821 821 rp.pages.HxRefresh(w) 822 822 } 823 823 824 + func (rp *Repo) RemoveCollaborator(w http.ResponseWriter, r *http.Request) { 825 + user := rp.oauth.GetMultiAccountUser(r) 826 + l := rp.logger.With("handler", "RemoveCollaborator") 827 + l = l.With("did", user.Active.Did) 828 + 829 + f, err := rp.repoResolver.Resolve(r) 830 + if err != nil { 831 + l.Error("failed to get repo and knot", "err", err) 832 + return 833 + } 834 + 835 + errorId := "remove-collaborator-error" 836 + fail := func(msg string, err error) { 837 + l.Error(msg, "err", err) 838 + rp.pages.Notice(w, errorId, msg) 839 + } 840 + 841 + subjectDid := r.FormValue("subject_did") 842 + if subjectDid == "" { 843 + fail("Invalid form.", nil) 844 + return 845 + } 846 + 847 + if subjectDid == user.Active.Did { 848 + fail("You cannot remove yourself as owner.", nil) 849 + return 850 + } 851 + 852 + // look up collaborator in db to get rkey (record lives on owner's PDS) 853 + collabs, err := db.GetCollaborators(rp.db, orm.FilterEq("repo_at", f.RepoAt()), orm.FilterEq("subject_did", subjectDid)) 854 + if err != nil { 855 + fail("Failed to remove collaborator.", err) 856 + return 857 + } 858 + if len(collabs) == 0 { 859 + fail("Collaborator not found.", nil) 860 + return 861 + } 862 + collab := collabs[0] 863 + 864 + client, err := rp.oauth.AuthorizedClient(r) 865 + if err != nil { 866 + fail("Failed to write to PDS.", err) 867 + return 868 + } 869 + 870 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 871 + Collection: tangled.RepoCollaboratorNSID, 872 + Repo: f.Did, 873 + Rkey: collab.Rkey, 874 + }) 875 + if err != nil { 876 + fail("Failed to remove collaborator record from PDS.", err) 877 + return 878 + } 879 + 880 + tx, err := rp.db.BeginTx(r.Context(), nil) 881 + if err != nil { 882 + fail("Failed to remove collaborator.", err) 883 + return 884 + } 885 + 886 + rollback := func() { 887 + tx.Rollback() 888 + rp.enforcer.E.LoadPolicy() 889 + } 890 + defer rollback() 891 + 892 + err = rp.enforcer.RemoveCollaborator(subjectDid, f.Knot, f.DidSlashRepo()) 893 + if err != nil { 894 + fail("Failed to remove collaborator permissions.", err) 895 + return 896 + } 897 + 898 + err = db.DeleteCollaborator(tx, orm.FilterEq("repo_at", f.RepoAt()), orm.FilterEq("subject_did", subjectDid)) 899 + if err != nil { 900 + fail("Failed to remove collaborator.", err) 901 + return 902 + } 903 + 904 + err = tx.Commit() 905 + if err != nil { 906 + fail("Failed to remove collaborator.", err) 907 + return 908 + } 909 + 910 + err = rp.enforcer.E.SavePolicy() 911 + if err != nil { 912 + fail("Failed to update permissions.", err) 913 + return 914 + } 915 + 916 + rp.pages.HxRefresh(w) 917 + } 918 + 824 919 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 825 920 user := rp.oauth.GetMultiAccountUser(r) 826 921 l := rp.logger.With("handler", "DeleteRepo")
+1
appview/repo/router.go
··· 83 83 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/label/subscribe", rp.SubscribeLabel) 84 84 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/label/unsubscribe", rp.UnsubscribeLabel) 85 85 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 86 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/collaborator", rp.RemoveCollaborator) 86 87 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 87 88 r.Put("/branches/default", rp.SetDefaultBranch) 88 89 r.Put("/secrets", rp.Secrets)
+9 -6
appview/repo/settings.go
··· 264 264 if err != nil { 265 265 return nil, err 266 266 } 267 + // load db collaborators for rkey (needed to remove via PDS delete) 268 + dbCollabs, _ := db.GetCollaborators(rp.db, orm.FilterEq("repo_at", repo.RepoAt())) 269 + rkeyByDid := make(map[string]string) 270 + for _, c := range dbCollabs { 271 + rkeyByDid[c.SubjectDid.String()] = c.Rkey 272 + } 267 273 var collaborators []pages.Collaborator 268 274 for _, item := range repoCollaborators { 269 - // currently only two roles: owner and member 270 275 var role string 271 276 switch item[3] { 272 277 case "repo:owner": ··· 276 281 default: 277 282 continue 278 283 } 279 - 280 284 did := item[0] 281 - 282 - c := pages.Collaborator{ 283 - Did: did, 284 - Role: role, 285 + c := pages.Collaborator{Did: did, Role: role} 286 + if role == "collaborator" { 287 + c.Rkey = rkeyByDid[did] 285 288 } 286 289 collaborators = append(collaborators, c) 287 290 }
+31
knotserver/db/collaborators.go
··· 1 + package db 2 + 3 + func (d *DB) AddCollaborator(ownerDid, rkey, subjectDid, didSlashRepo string) error { 4 + _, err := d.db.Exec( 5 + `insert or replace into collaborators (owner_did, rkey, subject_did, did_slash_repo) values (?, ?, ?, ?)`, 6 + ownerDid, rkey, subjectDid, didSlashRepo, 7 + ) 8 + return err 9 + } 10 + 11 + func (d *DB) RemoveCollaboratorByRkey(ownerDid, rkey string) (subjectDid, didSlashRepo string, err error) { 12 + row := d.db.QueryRow( 13 + `select subject_did, did_slash_repo from collaborators where owner_did = ? and rkey = ?`, 14 + ownerDid, rkey, 15 + ) 16 + err = row.Scan(&subjectDid, &didSlashRepo) 17 + if err != nil { 18 + return "", "", err 19 + } 20 + _, err = d.db.Exec(`delete from collaborators where owner_did = ? and rkey = ?`, ownerDid, rkey) 21 + return subjectDid, didSlashRepo, err 22 + } 23 + 24 + func (d *DB) CountCollaboratorRepos(subjectDid string) (int, error) { 25 + var n int 26 + err := d.db.QueryRow( 27 + `select count(*) from collaborators where subject_did = ?`, 28 + subjectDid, 29 + ).Scan(&n) 30 + return n, err 31 + }
+8
knotserver/db/db.go
··· 43 43 did text primary key 44 44 ); 45 45 46 + create table if not exists collaborators ( 47 + owner_did text not null, 48 + rkey text not null, 49 + subject_did text not null, 50 + did_slash_repo text not null, 51 + primary key (owner_did, rkey) 52 + ); 53 + 46 54 create table if not exists public_keys ( 47 55 id integer primary key autoincrement, 48 56 did text not null,
+43 -1
knotserver/ingester.go
··· 273 273 return err 274 274 } 275 275 276 + rkey := event.Commit.RKey 277 + if err := h.db.AddCollaborator(did, rkey, subjectId.DID.String(), didSlashRepo); err != nil { 278 + return err 279 + } 280 + 276 281 return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 277 282 } 278 283 284 + func (h *Knot) processCollaboratorDelete(ctx context.Context, event *models.Event) error { 285 + l := log.FromContext(ctx) 286 + ownerDid := event.Did 287 + rkey := event.Commit.RKey 288 + 289 + subjectDid, didSlashRepo, err := h.db.RemoveCollaboratorByRkey(ownerDid, rkey) 290 + if err != nil { 291 + l.Debug("collaborator not in DB (may not have been on this knot)", "owner", ownerDid, "rkey", rkey, "err", err) 292 + return nil 293 + } 294 + 295 + if err := h.e.RemoveCollaborator(subjectDid, rbac.ThisServer, didSlashRepo); err != nil { 296 + l.Error("failed to remove collaborator from RBAC", "err", err) 297 + return err 298 + } 299 + 300 + if h.e.HasAnyPermissionInDomain(subjectDid, rbac.ThisServer) { 301 + return nil 302 + } 303 + 304 + if err := h.db.RemovePublicKey(subjectDid); err != nil { 305 + l.Error("failed to remove public keys", "did", subjectDid, "err", err) 306 + return err 307 + } 308 + if err := h.db.RemoveDid(subjectDid); err != nil { 309 + return err 310 + } 311 + h.jc.RemoveDid(subjectDid) 312 + l.Info("removed collaborator and revoked keys", "subject", subjectDid) 313 + return nil 314 + } 315 + 279 316 func (h *Knot) fetchAndAddKeys(ctx context.Context, did string) error { 280 317 l := log.FromContext(ctx) 281 318 ··· 341 378 case tangled.RepoPullNSID: 342 379 err = h.processPull(ctx, event) 343 380 case tangled.RepoCollaboratorNSID: 344 - err = h.processCollaborator(ctx, event) 381 + switch event.Commit.Operation { 382 + case models.CommitOperationCreate, models.CommitOperationUpdate: 383 + err = h.processCollaborator(ctx, event) 384 + case models.CommitOperationDelete: 385 + err = h.processCollaboratorDelete(ctx, event) 386 + } 345 387 } 346 388 347 389 if err != nil {
+9
rbac/rbac.go
··· 305 305 return e.E.Enforce(user, domain, repo, "repo:invite") 306 306 } 307 307 308 + func (e *Enforcer) HasAnyPermissionInDomain(user, domain string) bool { 309 + perms := e.E.GetPermissionsForUserInDomain(user, domain) 310 + return len(perms) > 0 311 + } 312 + 313 + func (e *Enforcer) HasAnyPermissionInSpindle(user, domain string) bool { 314 + return e.HasAnyPermissionInDomain(user, intoSpindle(domain)) 315 + } 316 + 308 317 // given a repo, what permissions does this user have? repo:owner? repo:invite? etc. 309 318 func (e *Enforcer) GetPermissionsInRepo(user, domain, repo string) []string { 310 319 var permissions []string
+22
spindle/db/collaborators.go
··· 1 + package db 2 + 3 + func (d *DB) AddCollaborator(ownerDid, rkey, subjectDid, didSlashRepo string) error { 4 + _, err := d.Exec( 5 + `insert or replace into collaborators (owner_did, rkey, subject_did, did_slash_repo) values (?, ?, ?, ?)`, 6 + ownerDid, rkey, subjectDid, didSlashRepo, 7 + ) 8 + return err 9 + } 10 + 11 + func (d *DB) RemoveCollaboratorByRkey(ownerDid, rkey string) (subjectDid, didSlashRepo string, err error) { 12 + row := d.QueryRow( 13 + `select subject_did, did_slash_repo from collaborators where owner_did = ? and rkey = ?`, 14 + ownerDid, rkey, 15 + ) 16 + err = row.Scan(&subjectDid, &didSlashRepo) 17 + if err != nil { 18 + return "", "", err 19 + } 20 + _, err = d.Exec(`delete from collaborators where owner_did = ? and rkey = ?`, ownerDid, rkey) 21 + return subjectDid, didSlashRepo, err 22 + }
+8
spindle/db/db.go
··· 49 49 unique(owner, name) 50 50 ); 51 51 52 + create table if not exists collaborators ( 53 + owner_did text not null, 54 + rkey text not null, 55 + subject_did text not null, 56 + did_slash_repo text not null, 57 + primary key (owner_did, rkey) 58 + ); 59 + 52 60 create table if not exists spindle_members ( 53 61 -- identifiers for the record 54 62 id integer primary key autoincrement,
+30
spindle/ingester.go
··· 264 264 return fmt.Errorf("failed to add repo: %w", err) 265 265 } 266 266 267 + if err := s.db.AddCollaborator(owner.DID.String(), e.Commit.RKey, record.Subject, didSlashRepo); err != nil { 268 + return err 269 + } 270 + 271 + return nil 272 + 273 + case models.CommitOperationDelete: 274 + ownerDid := e.Did 275 + rkey := e.Commit.RKey 276 + 277 + subjectDid, didSlashRepo, err := s.db.RemoveCollaboratorByRkey(ownerDid, rkey) 278 + if err != nil { 279 + l.Debug("collaborator not in DB (may not have been on this spindle)", "owner", ownerDid, "rkey", rkey, "err", err) 280 + return nil 281 + } 282 + 283 + if err := s.e.RemoveCollaborator(subjectDid, rbac.ThisServer, didSlashRepo); err != nil { 284 + l.Error("failed to remove collaborator from RBAC", "err", err) 285 + return err 286 + } 287 + 288 + if s.e.HasAnyPermissionInSpindle(subjectDid, rbacDomain) { 289 + return nil 290 + } 291 + 292 + if err := s.db.RemoveDid(subjectDid); err != nil { 293 + return err 294 + } 295 + s.jc.RemoveDid(subjectDid) 296 + l.Info("removed collaborator", "subject", subjectDid) 267 297 return nil 268 298 } 269 299 return nil

History

2 rounds 4 comments
sign up or login to add to the discussion
1 commit
expand
feat(repo): add collaborator removal
no conflicts, ready to merge
expand 1 comment

added collaborators table to keep track in knotserver and spindle

1 commit
expand
feat(repo): add collaborator removal
expand 3 comments

thanks for the contribution! this PR does not handle all the scenarios:

  • the collaborator should be removed from the knot
  • their key must be removed from the knot if they don't have any other collaborator/membership relationship with the knot
  • likewise with spindles
  • nice catch about removing keys if no other relationship exist, will dig into this

lovely, thanks for picking this up. the tricky bit is that knots presently do not hold rkeys in their DBs, and embedded tap would help us backfill and achieve this easily.