This repository has no description
21

Configure Feed

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

feat: acl

isabel (Jun 18, 2026, 5:01 PM +0100) 6c86c74b c7a75953

+602 -16
+9 -1
cmd/snot/serve.go
··· 4 4 "context" 5 5 "fmt" 6 6 "log/slog" 7 + "maps" 7 8 "net/http" 8 9 "os" 10 + "slices" 9 11 10 12 "github.com/go-chi/chi/v5" 11 13 "github.com/go-chi/chi/v5/middleware" ··· 48 50 rkeys := db.Rkeys() 49 51 dids := db.RepoDids() 50 52 53 + acl := db.ACL() 54 + if err := acl.SeedMembers(slices.Sorted(maps.Keys(cfg.UserMap))); err != nil { 55 + return fmt.Errorf("seed knot members: %w", err) 56 + } 57 + 51 58 eventStore, err := db.SQL() 52 59 if err != nil { 53 60 return fmt.Errorf("event store: %w", err) ··· 76 83 Store: store, 77 84 Resolve: rs, 78 85 Rkeys: rkeys, 86 + ACL: acl, 79 87 Logger: logger.With("component", "xrpc"), 80 88 ServiceAuth: serviceauth.NewServiceAuth( 81 89 logger.With("component", "serviceauth"), 82 - resolver, 90 + resolver.Directory(), 83 91 serviceauth.DidWeb(cfg.Hostname).String(), 84 92 ), 85 93 }
+1 -1
default.nix
··· 9 9 10 10 src = ./.; 11 11 12 - vendorHash = "sha256-b/HJO+zvxFZHr+Z6TEw92VLjaTntaEVhlwFwSa0mLz4="; 12 + vendorHash = "sha256-c2wTNpQUQrvm5eSNdzXceCGOnrnEjUdtzCWVX+WJJs8="; 13 13 14 14 ldflags = [ 15 15 "-s"
+3 -3
go.mod
··· 12 12 github.com/go-git/go-git/v5 v5.14.0 13 13 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 14 14 github.com/jackc/pgx/v5 v5.10.0 15 + github.com/knadh/koanf/parsers/toml/v2 v2.2.1 15 16 github.com/knadh/koanf/providers/confmap v1.0.0 16 17 github.com/knadh/koanf/providers/env/v2 v2.0.0 18 + github.com/knadh/koanf/providers/file v1.2.1 17 19 github.com/knadh/koanf/v2 v2.3.5 18 20 gorm.io/gorm v1.31.1 19 - tangled.org/core v0.0.0-20260612103734-ff6d47cfb983 21 + tangled.org/core v1.15.0-alpha.0.20260618112045-1f3d6fdc27ee 20 22 ) 21 23 22 24 replace github.com/bluesky-social/indigo => github.com/boltlessengineer/indigo v0.0.0-20260315101958-fb1dfa36fed2 ··· 108 110 github.com/klauspost/compress v1.18.0 // indirect 109 111 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 110 112 github.com/knadh/koanf/maps v0.1.2 // indirect 111 - github.com/knadh/koanf/parsers/toml/v2 v2.2.1 // indirect 112 - github.com/knadh/koanf/providers/file v1.2.1 // indirect 113 113 github.com/landlock-lsm/go-landlock v0.8.1 // indirect 114 114 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 115 115 github.com/mattn/go-isatty v0.0.20 // indirect
+2 -4
go.sum
··· 100 100 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 101 101 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 102 102 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 103 - github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 104 - github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 105 103 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 106 104 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 107 105 github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= ··· 651 649 modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= 652 650 modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= 653 651 modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= 654 - tangled.org/core v0.0.0-20260612103734-ff6d47cfb983 h1:adp/YJS7cq6QI3xc3Ya/NB3KwWFFw0c/CxZvIDCTOvU= 655 - tangled.org/core v0.0.0-20260612103734-ff6d47cfb983/go.mod h1:DzlYk2UwbYk1I2CIn0x2wQAedlJLdatg3YKZpKHNWjU= 652 + tangled.org/core v1.15.0-alpha.0.20260618112045-1f3d6fdc27ee h1:f+CXIFGyUtZVSIydGgJe7gkJmKAOH0Bdi8QzVQ/FAx8= 653 + tangled.org/core v1.15.0-alpha.0.20260618112045-1f3d6fdc27ee/go.mod h1:DzlYk2UwbYk1I2CIn0x2wQAedlJLdatg3YKZpKHNWjU= 656 654 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 657 655 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+110
internal/state/acl.go
··· 1 + package state 2 + 3 + import ( 4 + "time" 5 + 6 + "gorm.io/gorm" 7 + "gorm.io/gorm/clause" 8 + ) 9 + 10 + type knotMemberRow struct { 11 + Subject string `gorm:"column:subject;primaryKey"` 12 + AddedBy string `gorm:"column:added_by"` 13 + Created string `gorm:"column:created"` 14 + } 15 + 16 + func (knotMemberRow) TableName() string { return "knot_members" } 17 + 18 + type repoCollaboratorRow struct { 19 + Repo string `gorm:"column:repo;primaryKey"` 20 + Subject string `gorm:"column:subject;primaryKey"` 21 + AddedBy string `gorm:"column:added_by"` 22 + Created string `gorm:"column:created"` 23 + } 24 + 25 + func (repoCollaboratorRow) TableName() string { return "repo_collaborators" } 26 + 27 + // Member is a knot member (a DID allowed to own/create repos on this knot). 28 + type Member struct { 29 + Subject string 30 + AddedBy string 31 + Created string 32 + } 33 + 34 + // Collaborator is a per-repo collaborator DID. 35 + type Collaborator struct { 36 + Subject string 37 + AddedBy string 38 + Created string 39 + } 40 + 41 + // ACL is the knot's member/collaborator store. It governs appview-side 42 + // permissions only; snot does not gate git pushes. 43 + type ACL struct { 44 + db *gorm.DB 45 + } 46 + 47 + func nowRFC3339() string { return time.Now().UTC().Format(time.RFC3339) } 48 + 49 + // SeedMembers idempotently ensures each DID is a member (AddedBy = itself). 50 + func (a *ACL) SeedMembers(dids []string) error { 51 + if len(dids) == 0 { 52 + return nil 53 + } 54 + now := nowRFC3339() 55 + rows := make([]knotMemberRow, 0, len(dids)) 56 + for _, did := range dids { 57 + rows = append(rows, knotMemberRow{Subject: did, AddedBy: did, Created: now}) 58 + } 59 + return a.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&rows).Error 60 + } 61 + 62 + func (a *ACL) Members() ([]Member, error) { 63 + var rows []knotMemberRow 64 + if err := a.db.Order("created asc").Find(&rows).Error; err != nil { 65 + return nil, err 66 + } 67 + out := make([]Member, len(rows)) 68 + for i, r := range rows { 69 + out[i] = Member{Subject: r.Subject, AddedBy: r.AddedBy, Created: r.Created} 70 + } 71 + return out, nil 72 + } 73 + 74 + func (a *ACL) IsMember(did string) (bool, error) { 75 + var n int64 76 + if err := a.db.Model(&knotMemberRow{}).Where("subject = ?", did).Count(&n).Error; err != nil { 77 + return false, err 78 + } 79 + return n > 0, nil 80 + } 81 + 82 + func (a *ACL) AddMember(subject, addedBy string) error { 83 + row := knotMemberRow{Subject: subject, AddedBy: addedBy, Created: nowRFC3339()} 84 + return a.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error 85 + } 86 + 87 + func (a *ACL) RemoveMember(subject string) error { 88 + return a.db.Where("subject = ?", subject).Delete(&knotMemberRow{}).Error 89 + } 90 + 91 + func (a *ACL) Collaborators(repoDid string) ([]Collaborator, error) { 92 + var rows []repoCollaboratorRow 93 + if err := a.db.Where("repo = ?", repoDid).Order("created asc").Find(&rows).Error; err != nil { 94 + return nil, err 95 + } 96 + out := make([]Collaborator, len(rows)) 97 + for i, r := range rows { 98 + out[i] = Collaborator{Subject: r.Subject, AddedBy: r.AddedBy, Created: r.Created} 99 + } 100 + return out, nil 101 + } 102 + 103 + func (a *ACL) AddCollaborator(repoDid, subject, addedBy string) error { 104 + row := repoCollaboratorRow{Repo: repoDid, Subject: subject, AddedBy: addedBy, Created: nowRFC3339()} 105 + return a.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error 106 + } 107 + 108 + func (a *ACL) RemoveCollaborator(repoDid, subject string) error { 109 + return a.db.Where("repo = ? AND subject = ?", repoDid, subject).Delete(&repoCollaboratorRow{}).Error 110 + }
+82
internal/state/acl_test.go
··· 1 + package state 2 + 3 + import "testing" 4 + 5 + func TestACLMembers(t *testing.T) { 6 + db, err := Open(t.TempDir()) 7 + if err != nil { 8 + t.Fatal(err) 9 + } 10 + acl := db.ACL() 11 + 12 + if err := acl.SeedMembers([]string{"did:plc:owner1", "did:plc:owner2"}); err != nil { 13 + t.Fatal(err) 14 + } 15 + // idempotent: seeding again must not error or duplicate 16 + if err := acl.SeedMembers([]string{"did:plc:owner1"}); err != nil { 17 + t.Fatal(err) 18 + } 19 + 20 + got, err := acl.Members() 21 + if err != nil { 22 + t.Fatal(err) 23 + } 24 + if len(got) != 2 { 25 + t.Fatalf("members = %d, want 2: %+v", len(got), got) 26 + } 27 + 28 + if ok, _ := acl.IsMember("did:plc:owner1"); !ok { 29 + t.Error("owner1 should be a member") 30 + } 31 + if ok, _ := acl.IsMember("did:plc:nobody"); ok { 32 + t.Error("nobody should not be a member") 33 + } 34 + 35 + if err := acl.AddMember("did:plc:extra", "did:plc:owner1"); err != nil { 36 + t.Fatal(err) 37 + } 38 + if ok, _ := acl.IsMember("did:plc:extra"); !ok { 39 + t.Error("extra should be a member after AddMember") 40 + } 41 + if err := acl.RemoveMember("did:plc:extra"); err != nil { 42 + t.Fatal(err) 43 + } 44 + if ok, _ := acl.IsMember("did:plc:extra"); ok { 45 + t.Error("extra should be gone after RemoveMember") 46 + } 47 + } 48 + 49 + func TestACLCollaborators(t *testing.T) { 50 + db, err := Open(t.TempDir()) 51 + if err != nil { 52 + t.Fatal(err) 53 + } 54 + acl := db.ACL() 55 + 56 + if err := acl.AddCollaborator("did:plc:repoA", "did:plc:bob", "did:plc:owner1"); err != nil { 57 + t.Fatal(err) 58 + } 59 + if err := acl.AddCollaborator("did:plc:repoB", "did:plc:carol", "did:plc:owner1"); err != nil { 60 + t.Fatal(err) 61 + } 62 + 63 + a, err := acl.Collaborators("did:plc:repoA") 64 + if err != nil { 65 + t.Fatal(err) 66 + } 67 + if len(a) != 1 || a[0].Subject != "did:plc:bob" || a[0].AddedBy != "did:plc:owner1" { 68 + t.Fatalf("repoA collaborators = %+v", a) 69 + } 70 + // repoB's collaborator must not leak into repoA 71 + b, _ := acl.Collaborators("did:plc:repoB") 72 + if len(b) != 1 || b[0].Subject != "did:plc:carol" { 73 + t.Fatalf("repoB collaborators = %+v", b) 74 + } 75 + 76 + if err := acl.RemoveCollaborator("did:plc:repoA", "did:plc:bob"); err != nil { 77 + t.Fatal(err) 78 + } 79 + if c, _ := acl.Collaborators("did:plc:repoA"); len(c) != 0 { 80 + t.Fatalf("repoA should be empty, got %+v", c) 81 + } 82 + }
+4 -1
internal/state/state.go
··· 61 61 if err := gdb.Exec("PRAGMA busy_timeout = 5000").Error; err != nil { 62 62 return nil, err 63 63 } 64 - if err := gdb.AutoMigrate(&rkeyRow{}, &repoDidRow{}, &eventRow{}); err != nil { 64 + if err := gdb.AutoMigrate(&rkeyRow{}, &repoDidRow{}, &eventRow{}, &knotMemberRow{}, &repoCollaboratorRow{}); err != nil { 65 65 return nil, err 66 66 } 67 67 // the db holds PLC rotation keys; keep it owner-only. ··· 81 81 82 82 // RepoDids returns the repo-DID accessor. 83 83 func (db *DB) RepoDids() *RepoDids { return &RepoDids{db: db.gorm} } 84 + 85 + // ACL returns the member/collaborator accessor. 86 + func (db *DB) ACL() *ACL { return &ACL{db: db.gorm} }
+155
internal/xrpc/acl_test.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "net/http" 8 + "net/http/httptest" 9 + "net/url" 10 + "testing" 11 + 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/xrpc/serviceauth" 15 + ) 16 + 17 + func TestListMembersReturnsSeeded(t *testing.T) { 18 + x := newXrpc(t) 19 + if err := x.ACL.SeedMembers([]string{"did:plc:owner123"}); err != nil { 20 + t.Fatal(err) 21 + } 22 + srv := newTestServer(t, x) 23 + defer srv.Close() 24 + 25 + code, m := getJSON(t, srv, "sh.tangled.knot.listMembers", nil) 26 + if code != 200 { 27 + t.Fatalf("code=%d m=%v", code, m) 28 + } 29 + items := m["items"].([]any) 30 + if len(items) != 1 || items[0].(map[string]any)["subject"] != "did:plc:owner123" { 31 + t.Errorf("items=%v", items) 32 + } 33 + } 34 + 35 + func TestListCollaborators(t *testing.T) { 36 + x := newXrpc(t) 37 + if err := x.ACL.AddCollaborator("did:plc:repo123", "did:plc:bob", "did:plc:owner123"); err != nil { 38 + t.Fatal(err) 39 + } 40 + srv := newTestServer(t, x) 41 + defer srv.Close() 42 + 43 + // known repoDid (seeded in newXrpc's Dids) → its collaborators 44 + code, m := getJSON(t, srv, "sh.tangled.repo.listCollaborators", 45 + url.Values{"subject": {"did:plc:repo123"}}) 46 + if code != 200 { 47 + t.Fatalf("code=%d m=%v", code, m) 48 + } 49 + items := m["items"].([]any) 50 + if len(items) != 1 || items[0].(map[string]any)["subject"] != "did:plc:bob" { 51 + t.Errorf("items=%v", items) 52 + } 53 + 54 + // unknown repoDid → 400 55 + code, _ = getJSON(t, srv, "sh.tangled.repo.listCollaborators", 56 + url.Values{"subject": {"did:plc:unknown"}}) 57 + if code != 400 { 58 + t.Errorf("unknown repo code=%d", code) 59 + } 60 + } 61 + 62 + // postAs invokes an ACL mutation handler directly with the actor DID injected 63 + // the way serviceauth middleware does. 64 + func postAs(t *testing.T, h func(http.ResponseWriter, *http.Request), actor string, body any) *httptest.ResponseRecorder { 65 + t.Helper() 66 + b, _ := json.Marshal(body) 67 + req := httptest.NewRequest("POST", "/x", bytes.NewReader(b)) 68 + d, _ := syntax.ParseDID(actor) 69 + req = req.WithContext(context.WithValue(req.Context(), serviceauth.ActorDid, d)) 70 + rec := httptest.NewRecorder() 71 + h(rec, req) 72 + return rec 73 + } 74 + 75 + func TestAddMemberRequiresMemberActor(t *testing.T) { 76 + x := newXrpc(t) 77 + _ = x.ACL.SeedMembers([]string{"did:plc:owner123"}) 78 + 79 + // non-member actor → 403 80 + rec := postAs(t, x.AddMember, "did:plc:intruder", tangled.KnotAddMember_Input{Subject: "did:plc:new"}) 81 + if rec.Code != 403 { 82 + t.Fatalf("non-member add: code=%d", rec.Code) 83 + } 84 + // member actor → 200, new member appears 85 + rec = postAs(t, x.AddMember, "did:plc:owner123", tangled.KnotAddMember_Input{Subject: "did:plc:new"}) 86 + if rec.Code != 200 { 87 + t.Fatalf("member add: code=%d body=%s", rec.Code, rec.Body) 88 + } 89 + if ok, _ := x.ACL.IsMember("did:plc:new"); !ok { 90 + t.Error("new member not added") 91 + } 92 + } 93 + 94 + func TestRemoveMemberRefusesConfigSeeded(t *testing.T) { 95 + x := newXrpc(t) // newXrpc's cfg.UserMap maps did:plc:owner123 -> isabel 96 + _ = x.ACL.SeedMembers([]string{"did:plc:owner123"}) 97 + 98 + // removing the config-seeded owner → 400, still a member 99 + rec := postAs(t, x.RemoveMember, "did:plc:owner123", tangled.KnotRemoveMember_Input{Subject: "did:plc:owner123"}) 100 + if rec.Code != 400 { 101 + t.Fatalf("remove config member: code=%d", rec.Code) 102 + } 103 + if ok, _ := x.ACL.IsMember("did:plc:owner123"); !ok { 104 + t.Error("config owner must remain a member") 105 + } 106 + 107 + // add then remove an extra member → 200 108 + _ = x.ACL.AddMember("did:plc:extra", "did:plc:owner123") 109 + rec = postAs(t, x.RemoveMember, "did:plc:owner123", tangled.KnotRemoveMember_Input{Subject: "did:plc:extra"}) 110 + if rec.Code != 200 { 111 + t.Fatalf("remove extra: code=%d", rec.Code) 112 + } 113 + } 114 + 115 + func TestAddCollaborator(t *testing.T) { 116 + x := newXrpc(t) 117 + _ = x.ACL.SeedMembers([]string{"did:plc:owner123"}) 118 + 119 + // known repoDid (did:plc:repo123 seeded in newXrpc's Dids) → 200 120 + rec := postAs(t, x.AddCollaborator, "did:plc:owner123", 121 + tangled.RepoAddCollaborator_Input{Repo: "did:plc:repo123", Subject: "did:plc:bob"}) 122 + if rec.Code != 200 { 123 + t.Fatalf("add collaborator: code=%d body=%s", rec.Code, rec.Body) 124 + } 125 + cs, _ := x.ACL.Collaborators("did:plc:repo123") 126 + if len(cs) != 1 || cs[0].Subject != "did:plc:bob" { 127 + t.Errorf("collaborators=%+v", cs) 128 + } 129 + 130 + // unknown repoDid → not 200 131 + rec = postAs(t, x.AddCollaborator, "did:plc:owner123", 132 + tangled.RepoAddCollaborator_Input{Repo: "did:plc:unknown", Subject: "did:plc:bob"}) 133 + if rec.Code == 200 { 134 + t.Errorf("unknown repo should fail") 135 + } 136 + } 137 + 138 + func TestVersionAdvertisesKnotACL(t *testing.T) { 139 + srv := newTestServer(t, newXrpc(t)) 140 + defer srv.Close() 141 + code, m := getJSON(t, srv, "sh.tangled.knot.version", nil) 142 + if code != 200 { 143 + t.Fatalf("code=%d", code) 144 + } 145 + caps, _ := m["capabilities"].([]any) 146 + found := false 147 + for _, c := range caps { 148 + if c == "knot-acl" { 149 + found = true 150 + } 151 + } 152 + if !found { 153 + t.Errorf("version capabilities missing knot-acl: %v", m["capabilities"]) 154 + } 155 + }
+107
internal/xrpc/collaborators.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.org/core/api/tangled" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 + ) 11 + 12 + // ListCollaborators returns a repo's collaborators. Open endpoint; 13 + // ?subject=<repoDid>. 14 + func (x *Xrpc) ListCollaborators(w http.ResponseWriter, r *http.Request) { 15 + subject := r.URL.Query().Get("subject") 16 + repoDid, err := syntax.ParseDID(subject) 17 + if err != nil { 18 + writeError(w, xrpcerr.InvalidRepoError(subject), http.StatusBadRequest) 19 + return 20 + } 21 + if _, ok := x.Resolve.Dids.Get(repoDid.String()); !ok { 22 + writeError(w, xrpcerr.InvalidRepoError(subject), http.StatusBadRequest) 23 + return 24 + } 25 + 26 + collaborators, err := x.ACL.Collaborators(repoDid.String()) 27 + if err != nil { 28 + x.Logger.Error("failed to list collaborators", "repoDid", repoDid, "error", err) 29 + writeError(w, xrpcerr.NewXrpcError( 30 + xrpcerr.WithTag("InternalServerError"), 31 + xrpcerr.WithMessage("failed to list collaborators"), 32 + ), http.StatusInternalServerError) 33 + return 34 + } 35 + items := make([]*tangled.RepoListCollaborators_ListItem, 0, len(collaborators)) 36 + for _, c := range collaborators { 37 + items = append(items, &tangled.RepoListCollaborators_ListItem{ 38 + Subject: c.Subject, 39 + AddedBy: c.AddedBy, 40 + CreatedAt: c.Created, 41 + }) 42 + } 43 + x.writeJson(w, tangled.RepoListCollaborators_Output{Items: items}) 44 + } 45 + 46 + func (x *Xrpc) AddCollaborator(w http.ResponseWriter, r *http.Request) { 47 + actor, autherr := x.requireMember(r) 48 + if autherr != nil { 49 + writeError(w, *autherr, http.StatusForbidden) 50 + return 51 + } 52 + var data tangled.RepoAddCollaborator_Input 53 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 54 + writeError(w, xrpcerr.GenericError(err), http.StatusBadRequest) 55 + return 56 + } 57 + repoDid, err := syntax.ParseDID(data.Repo) 58 + if err != nil { 59 + writeError(w, xrpcerr.InvalidRepoError(data.Repo), http.StatusBadRequest) 60 + return 61 + } 62 + if _, ok := x.Resolve.Dids.Get(repoDid.String()); !ok { 63 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNotFound) 64 + return 65 + } 66 + subject, err := syntax.ParseDID(data.Subject) 67 + if err != nil { 68 + writeError(w, xrpcerr.GenericError(err), http.StatusBadRequest) 69 + return 70 + } 71 + if err := x.ACL.AddCollaborator(repoDid.String(), subject.String(), actor.String()); err != nil { 72 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 73 + return 74 + } 75 + w.WriteHeader(http.StatusOK) 76 + } 77 + 78 + func (x *Xrpc) RemoveCollaborator(w http.ResponseWriter, r *http.Request) { 79 + if _, autherr := x.requireMember(r); autherr != nil { 80 + writeError(w, *autherr, http.StatusForbidden) 81 + return 82 + } 83 + var data tangled.RepoRemoveCollaborator_Input 84 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 85 + writeError(w, xrpcerr.GenericError(err), http.StatusBadRequest) 86 + return 87 + } 88 + repoDid, err := syntax.ParseDID(data.Repo) 89 + if err != nil { 90 + writeError(w, xrpcerr.InvalidRepoError(data.Repo), http.StatusBadRequest) 91 + return 92 + } 93 + if _, ok := x.Resolve.Dids.Get(repoDid.String()); !ok { 94 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNotFound) 95 + return 96 + } 97 + subject, err := syntax.ParseDID(data.Subject) 98 + if err != nil { 99 + writeError(w, xrpcerr.GenericError(err), http.StatusBadRequest) 100 + return 101 + } 102 + if err := x.ACL.RemoveCollaborator(repoDid.String(), subject.String()); err != nil { 103 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 104 + return 105 + } 106 + w.WriteHeader(http.StatusOK) 107 + }
+107
internal/xrpc/members.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.org/core/api/tangled" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 + "tangled.org/core/xrpc/serviceauth" 11 + ) 12 + 13 + // ListMembers returns the knot's members. Open endpoint; the appview reads it 14 + // to determine knot membership and repo-create permission. 15 + func (x *Xrpc) ListMembers(w http.ResponseWriter, r *http.Request) { 16 + members, err := x.ACL.Members() 17 + if err != nil { 18 + x.Logger.Error("failed to list members", "error", err) 19 + writeError(w, xrpcerr.NewXrpcError( 20 + xrpcerr.WithTag("InternalServerError"), 21 + xrpcerr.WithMessage("failed to list members"), 22 + ), http.StatusInternalServerError) 23 + return 24 + } 25 + items := make([]*tangled.KnotListMembers_ListItem, 0, len(members)) 26 + for _, m := range members { 27 + items = append(items, &tangled.KnotListMembers_ListItem{ 28 + Subject: m.Subject, 29 + AddedBy: m.AddedBy, 30 + CreatedAt: m.Created, 31 + }) 32 + } 33 + x.writeJson(w, tangled.KnotListMembers_Output{Items: items}) 34 + } 35 + 36 + // requireMember authorizes a mutation: the service-auth actor must be a 37 + // current knot member. Returns the actor DID or an error to write. 38 + func (x *Xrpc) requireMember(r *http.Request) (syntax.DID, *xrpcerr.XrpcError) { 39 + actorDid, ok := r.Context().Value(serviceauth.ActorDid).(syntax.DID) 40 + if !ok { 41 + e := xrpcerr.MissingActorDidError 42 + return "", &e 43 + } 44 + member, err := x.ACL.IsMember(actorDid.String()) 45 + if err != nil { 46 + e := xrpcerr.GenericError(err) 47 + return "", &e 48 + } 49 + if !member { 50 + e := xrpcerr.AccessControlError(actorDid.String()) 51 + return "", &e 52 + } 53 + return actorDid, nil 54 + } 55 + 56 + func (x *Xrpc) AddMember(w http.ResponseWriter, r *http.Request) { 57 + actor, autherr := x.requireMember(r) 58 + if autherr != nil { 59 + writeError(w, *autherr, http.StatusForbidden) 60 + return 61 + } 62 + var data tangled.KnotAddMember_Input 63 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 64 + writeError(w, xrpcerr.GenericError(err), http.StatusBadRequest) 65 + return 66 + } 67 + subject, err := syntax.ParseDID(data.Subject) 68 + if err != nil { 69 + writeError(w, xrpcerr.GenericError(err), http.StatusBadRequest) 70 + return 71 + } 72 + if err := x.ACL.AddMember(subject.String(), actor.String()); err != nil { 73 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 74 + return 75 + } 76 + w.WriteHeader(http.StatusOK) 77 + } 78 + 79 + func (x *Xrpc) RemoveMember(w http.ResponseWriter, r *http.Request) { 80 + if _, autherr := x.requireMember(r); autherr != nil { 81 + writeError(w, *autherr, http.StatusForbidden) 82 + return 83 + } 84 + var data tangled.KnotRemoveMember_Input 85 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 86 + writeError(w, xrpcerr.GenericError(err), http.StatusBadRequest) 87 + return 88 + } 89 + subject, err := syntax.ParseDID(data.Subject) 90 + if err != nil { 91 + writeError(w, xrpcerr.GenericError(err), http.StatusBadRequest) 92 + return 93 + } 94 + // Config-seeded members (SNOT_USER_MAP) are permanent. 95 + if _, configManaged := x.Cfg.UserMap[subject.String()]; configManaged { 96 + writeError(w, xrpcerr.NewXrpcError( 97 + xrpcerr.WithTag("InvalidRequest"), 98 + xrpcerr.WithMessage("member is config-managed (SNOT_USER_MAP) and cannot be removed"), 99 + ), http.StatusBadRequest) 100 + return 101 + } 102 + if err := x.ACL.RemoveMember(subject.String()); err != nil { 103 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 104 + return 105 + } 106 + w.WriteHeader(http.StatusOK) 107 + }
+5 -1
internal/xrpc/version.go
··· 6 6 "runtime/debug" 7 7 8 8 "tangled.org/core/api/tangled" 9 + "tangled.org/core/consts" 9 10 ) 10 11 11 12 // version is set during build time. 12 13 var version string 14 + 15 + var knotCapabilities = []string{string(consts.CapKnotACL)} 13 16 14 17 func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) { 15 18 if version == "" { ··· 45 48 } 46 49 47 50 response := tangled.KnotVersion_Output{ 48 - Version: version, 51 + Version: version, 52 + Capabilities: knotCapabilities, 49 53 } 50 54 51 55 x.writeJson(w, response)
+7
internal/xrpc/xrpc.go
··· 26 26 Store forgejo.Store 27 27 Resolve *resolve.Resolver 28 28 Rkeys *state.RkeyMap 29 + ACL *state.ACL 29 30 Logger *slog.Logger 30 31 ServiceAuth *serviceauth.ServiceAuth // nil in tests: auth middleware skipped 31 32 } ··· 38 39 r.Use(x.ServiceAuth.VerifyServiceAuth) 39 40 } 40 41 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 42 + r.Post("/"+tangled.KnotAddMemberNSID, x.AddMember) 43 + r.Post("/"+tangled.KnotRemoveMemberNSID, x.RemoveMember) 44 + r.Post("/"+tangled.RepoAddCollaboratorNSID, x.AddCollaborator) 45 + r.Post("/"+tangled.RepoRemoveCollaboratorNSID, x.RemoveCollaborator) 41 46 }) 42 47 43 48 // writes the shim does not support ··· 71 76 72 77 // knot/service query endpoints (no auth required) 73 78 r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys) 79 + r.Get("/"+tangled.KnotListMembersNSID, x.ListMembers) 80 + r.Get("/"+tangled.RepoListCollaboratorsNSID, x.ListCollaborators) 74 81 r.Get("/"+tangled.KnotVersionNSID, x.Version) 75 82 r.Get("/"+tangled.OwnerNSID, x.Owner) 76 83
+10 -5
internal/xrpc/xrpc_test.go
··· 18 18 func newXrpc(t *testing.T) *Xrpc { 19 19 t.Helper() 20 20 root := testutil.FixtureRepo(t, "isabel", "demo") 21 - rkeys, err := state.LoadRkeyMap(t.TempDir()) 21 + db, err := state.Open(t.TempDir()) 22 22 if err != nil { 23 23 t.Fatal(err) 24 24 } 25 - dids, err := state.LoadRepoDids(t.TempDir()) 26 - if err != nil { 27 - t.Fatal(err) 28 - } 25 + rkeys := db.Rkeys() 26 + dids := db.RepoDids() 27 + acl := db.ACL() 29 28 if err := dids.Put("did:plc:repo123", state.RepoDidInfo{User: "isabel", Repo: "demo", Key: []byte("k")}); err != nil { 30 29 t.Fatal(err) 31 30 } ··· 56 55 Store: store, 57 56 Resolve: rs, 58 57 Rkeys: rkeys, 58 + ACL: acl, 59 59 Logger: slog.Default(), 60 60 } 61 + } 62 + 63 + func newTestServer(t *testing.T, x *Xrpc) *httptest.Server { 64 + t.Helper() 65 + return httptest.NewServer(x.Router()) 61 66 } 62 67 63 68 func TestParseRepoParam(t *testing.T) {