diff --git a/api/org_tangled/allowListadd.go b/api/org_tangled/allowListadd.go new file mode 100644 index 000000000..9d4b2137e --- /dev/null +++ b/api/org_tangled/allowListadd.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package org_tangled + +// schema: org.tangled.temp.spindle.allowList.add + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempSpindleAllowListAddNSID = "org.tangled.temp.spindle.allowList.add" +) + +// TempSpindleAllowListAdd_Input is the input argument to a org.tangled.temp.spindle.allowList.add call. +type TempSpindleAllowListAdd_Input struct { + // did: DID of the account allowed to enroll repos on this spindle. + Did string `json:"did" cborgen:"did"` +} + +// TempSpindleAllowListAdd calls the XRPC method "org.tangled.temp.spindle.allowList.add". +func TempSpindleAllowListAdd(ctx context.Context, c util.LexClient, input *TempSpindleAllowListAdd_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.spindle.allowList.add", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/org_tangled/allowListlist.go b/api/org_tangled/allowListlist.go new file mode 100644 index 000000000..85675c6f4 --- /dev/null +++ b/api/org_tangled/allowListlist.go @@ -0,0 +1,31 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package org_tangled + +// schema: org.tangled.temp.spindle.allowList.list + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempSpindleAllowListListNSID = "org.tangled.temp.spindle.allowList.list" +) + +// TempSpindleAllowListList_Output is the output of a org.tangled.temp.spindle.allowList.list call. +type TempSpindleAllowListList_Output struct { + // dids: Allow-list entries ordered lexically. + Dids []string `json:"dids" cborgen:"dids"` +} + +// TempSpindleAllowListList calls the XRPC method "org.tangled.temp.spindle.allowList.list". +func TempSpindleAllowListList(ctx context.Context, c util.LexClient) (*TempSpindleAllowListList_Output, error) { + var out TempSpindleAllowListList_Output + if err := c.LexDo(ctx, util.Query, "", "org.tangled.temp.spindle.allowList.list", nil, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/org_tangled/allowListremove.go b/api/org_tangled/allowListremove.go new file mode 100644 index 000000000..6bd9a0f52 --- /dev/null +++ b/api/org_tangled/allowListremove.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package org_tangled + +// schema: org.tangled.temp.spindle.allowList.remove + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempSpindleAllowListRemoveNSID = "org.tangled.temp.spindle.allowList.remove" +) + +// TempSpindleAllowListRemove_Input is the input argument to a org.tangled.temp.spindle.allowList.remove call. +type TempSpindleAllowListRemove_Input struct { + // did: DID to remove from the allow-list. + Did string `json:"did" cborgen:"did"` +} + +// TempSpindleAllowListRemove calls the XRPC method "org.tangled.temp.spindle.allowList.remove". +func TempSpindleAllowListRemove(ctx context.Context, c util.LexClient, input *TempSpindleAllowListRemove_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.spindle.allowList.remove", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/appview/oauth/handler.go b/appview/oauth/handler.go index 66e55223f..496dcd1cc 100644 --- a/appview/oauth/handler.go +++ b/appview/oauth/handler.go @@ -25,7 +25,6 @@ import ( "tangled.org/core/appview/models" "tangled.org/core/consts" "tangled.org/core/idresolver" - "tangled.org/core/orm" "tangled.org/core/tid" "tangled.org/core/xrpc/serviceauth" ) @@ -120,7 +119,6 @@ func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { o.ensureProfileRecord(sessData.AccountDID, sessData.SessionID) go o.addToDefaultKnot(sessData.AccountDID, sessData.SessionID) - go o.addToDefaultSpindle(sessData.AccountDID.String()) go o.autoClaimTnglShDomain(sessData.AccountDID.String()) if !o.Config.Core.Dev { @@ -168,48 +166,6 @@ func (o *OAuth) isAccountDeactivated(sessData *oauth.ClientSessionData) bool { xrpcBody.ErrStr == "RepoDeactivated" } -func (o *OAuth) addToDefaultSpindle(did string) { - l := o.Logger.With("subject", did) - - // use the tangled.sh app password to get an accessJwt - // and create an sh.tangled.spindle.member record with that - spindleMembers, err := db.GetSpindleMembers( - o.Db, - orm.FilterEq("instance", "spindle.tangled.sh"), - orm.FilterEq("subject", did), - ) - if err != nil { - l.Error("failed to get spindle members", "err", err) - return - } - - if len(spindleMembers) != 0 { - l.Warn("already a member of the default spindle") - return - } - - l.Debug("adding to default spindle") - session, err := o.getAppPasswordSession() - if err != nil { - l.Error("failed to create session", "err", err) - return - } - - record := tangled.SpindleMember{ - LexiconTypeID: tangled.SpindleMemberNSID, - Subject: did, - Instance: consts.DefaultSpindle, - CreatedAt: time.Now().Format(time.RFC3339), - } - - if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { - l.Error("failed to add to default spindle", "err", err) - return - } - - l.Debug("successfully added to default spindle", "did", did) -} - type onboardAction int const ( diff --git a/appview/oauth/scopes.go b/appview/oauth/scopes.go index ff0a55910..4609713b2 100644 --- a/appview/oauth/scopes.go +++ b/appview/oauth/scopes.go @@ -26,7 +26,6 @@ var TangledScopes = []string{ "repo:sh.tangled.repo.pull.comment", "repo:sh.tangled.repo.pull.status", "repo:sh.tangled.spindle", - "repo:sh.tangled.spindle.member", "repo:sh.tangled.string", "blob:*/*", @@ -45,16 +44,13 @@ var TangledScopes = []string{ "rpc:sh.tangled.git.keepCommit?aud=*", "rpc:sh.tangled.repo.acceptCollaboration?aud=*", "rpc:sh.tangled.repo.addCollaborator?aud=*", - "rpc:sh.tangled.repo.addSecret?aud=*", "rpc:sh.tangled.repo.create?aud=*", "rpc:sh.tangled.repo.delete?aud=*", "rpc:sh.tangled.repo.deleteBranch?aud=*", "rpc:sh.tangled.repo.forkSync?aud=*", "rpc:sh.tangled.repo.hiddenRef?aud=*", - "rpc:sh.tangled.repo.listSecrets?aud=*", "rpc:sh.tangled.repo.merge?aud=*", "rpc:sh.tangled.repo.mergeCheck?aud=*", "rpc:sh.tangled.repo.removeCollaborator?aud=*", - "rpc:sh.tangled.repo.removeSecret?aud=*", "rpc:sh.tangled.repo.setDefaultBranch?aud=*", } diff --git a/appview/pages/templates/spindles/dashboard.html b/appview/pages/templates/spindles/dashboard.html index a79abad50..b98f11494 100644 --- a/appview/pages/templates/spindles/dashboard.html +++ b/appview/pages/templates/spindles/dashboard.html @@ -25,9 +25,6 @@ {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Spindle.Owner) }} {{ if .Spindle.Verified }} {{ i "shield-check" "w-4 h-4" }} Verified - {{ if $isOwner }} - {{ template "spindles/fragments/addMemberModal" .Spindle }} - {{ end }} {{ else }} {{ i "shield-off" "w-4 h-4" }} Unverified {{ if $isOwner }} @@ -60,9 +57,6 @@
{{ template "user/fragments/picHandleLink" . }}
- {{ if ne $.LoggedInUser.Did . }} - {{ block "removeMemberButton" (list $ . ) }} {{ end }} - {{ end }}
{{ $repos := index $.Repos . }} @@ -114,20 +108,3 @@ {{ end }} -{{ define "removeMemberButton" }} - {{ $root := index . 0 }} - {{ $member := index . 1 }} - -{{ end }} - diff --git a/appview/pages/templates/spindles/fragments/addMemberModal.html b/appview/pages/templates/spindles/fragments/addMemberModal.html deleted file mode 100644 index f999bd84b..000000000 --- a/appview/pages/templates/spindles/fragments/addMemberModal.html +++ /dev/null @@ -1,65 +0,0 @@ -{{ define "spindles/fragments/addMemberModal" }} - - -
- {{ block "addSpindleMemberPopover" . }} {{ end }} -
-{{ end }} - -{{ define "addSpindleMemberPopover" }} -
- -

Members can register repositories and run workflows on this spindle.

- - - -
- - -
-
-
-{{ end }} diff --git a/appview/pages/templates/spindles/fragments/spindleListing.html b/appview/pages/templates/spindles/fragments/spindleListing.html index b74f206e8..e0b9b8feb 100644 --- a/appview/pages/templates/spindles/fragments/spindleListing.html +++ b/appview/pages/templates/spindles/fragments/spindleListing.html @@ -36,7 +36,6 @@ {{ block "spindleRetryButton" . }} {{ end }} {{ else if .Verified }} {{ i "shield-check" "w-4 h-4" }} Verified - {{ template "spindles/fragments/addMemberModal" . }} {{ else }} {{ i "shield-off" "w-4 h-4" }} Unverified {{ block "spindleRetryButton" . }} {{ end }} diff --git a/appview/spindles/spindles.go b/appview/spindles/spindles.go index 4524759e2..8499e9fe8 100644 --- a/appview/spindles/spindles.go +++ b/appview/spindles/spindles.go @@ -2,7 +2,6 @@ package spindles import ( "errors" - "fmt" "log/slog" "net/http" "slices" @@ -21,7 +20,6 @@ import ( "tangled.org/core/idresolver" "tangled.org/core/orm" "tangled.org/core/rbac" - "tangled.org/core/tid" "tangled.org/core/xrpc/xrpcclient" comatproto "github.com/bluesky-social/indigo/api/atproto" @@ -49,8 +47,6 @@ func (s *Spindles) Router() http.Handler { r.With(middleware.AuthMiddleware(s.OAuth)).Delete("/{instance}", s.delete) r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/retry", s.retry) - r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/add", s.addMember) - r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/remove", s.removeMember) return r } @@ -427,179 +423,3 @@ func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) { w.Header().Set("HX-Reswap", "outerHTML") s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]}) } - -func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) { - user := s.OAuth.GetMultiAccountUser(r) - l := s.Logger.With("handler", "addMember") - - instance := chi.URLParam(r, "instance") - if instance == "" { - l.Error("empty instance") - http.Error(w, "Not found", http.StatusNotFound) - return - } - l = l.With("instance", instance) - l = l.With("user", user.Did) - - spindles, err := db.GetSpindles( - r.Context(), - s.Db, - orm.FilterEq("owner", user.Did), - orm.FilterEq("instance", instance), - ) - if err != nil || len(spindles) != 1 { - l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) - http.Error(w, "Not found", http.StatusNotFound) - return - } - - noticeId := fmt.Sprintf("add-member-error-%d", spindles[0].Id) - defaultErr := "Failed to add member. Try again later." - fail := func() { - s.Pages.Notice(w, noticeId, defaultErr) - } - - if string(spindles[0].Owner) != user.Did { - l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) - s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") - return - } - - member := r.FormValue("member") - member = strings.TrimPrefix(member, "@") - if member == "" { - l.Error("empty member") - s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") - return - } - l = l.With("member", member) - - memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) - if err != nil { - l.Error("failed to resolve member identity to handle", "err", err) - s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") - return - } - if memberId.Handle.IsInvalidHandle() { - l.Error("failed to resolve member identity to handle") - s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") - return - } - - // write to pds - client, err := s.OAuth.AuthorizedClient(r) - if err != nil { - l.Error("failed to authorize client", "err", err) - fail() - return - } - - rkey := tid.TID() - - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ - Collection: tangled.SpindleMemberNSID, - Repo: user.Did, - Rkey: rkey, - Record: &lexutil.LexiconTypeDecoder{ - Val: &tangled.SpindleMember{ - CreatedAt: time.Now().Format(time.RFC3339), - Instance: instance, - Subject: memberId.DID.String(), - }, - }, - }) - if err != nil { - l.Error("failed to add record to PDS", "err", err) - s.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") - return - } - - // success - s.Pages.HxRedirect(w, fmt.Sprintf("/settings/spindles/%s", instance)) -} - -func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { - user := s.OAuth.GetMultiAccountUser(r) - l := s.Logger.With("handler", "removeMember") - - noticeId := "operation-error" - defaultErr := "Failed to remove member. Try again later." - fail := func() { - s.Pages.Notice(w, noticeId, defaultErr) - } - - instance := chi.URLParam(r, "instance") - if instance == "" { - l.Error("empty instance") - fail() - return - } - l = l.With("instance", instance) - l = l.With("user", user.Did) - - spindles, err := db.GetSpindles( - r.Context(), - s.Db, - orm.FilterEq("owner", user.Did), - orm.FilterEq("instance", instance), - ) - if err != nil || len(spindles) != 1 { - l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) - fail() - return - } - - if string(spindles[0].Owner) != user.Did { - l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) - s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") - return - } - - member := r.FormValue("member") - member = strings.TrimPrefix(member, "@") - if member == "" { - l.Error("empty member") - s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") - return - } - l = l.With("member", member) - - memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) - if err != nil { - l.Error("failed to resolve member identity to handle", "err", err) - s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") - return - } - - members, err := db.GetSpindleMembers( - s.Db, - orm.FilterEq("did", user.Did), - orm.FilterEq("instance", instance), - orm.FilterEq("subject", memberId.DID), - ) - if err != nil || len(members) != 1 { - l.Error("failed to get member", "err", err) - fail() - return - } - - client, err := s.OAuth.AuthorizedClient(r) - if err != nil { - l.Error("failed to authorize client", "err", err) - fail() - return - } - - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ - Collection: tangled.SpindleMemberNSID, - Repo: user.Did, - Rkey: members[0].Rkey, - }) - if err != nil { - l.Error("failed to delete record", "err", err) - fail() - return - } - - s.Pages.HxRefresh(w) -} diff --git a/jetstream/jetstream_test.go b/jetstream/jetstream_test.go index 6c5084d2a..d8ccaec20 100644 --- a/jetstream/jetstream_test.go +++ b/jetstream/jetstream_test.go @@ -61,6 +61,36 @@ func TestLastSeenTracksEveryEventPreFilter(t *testing.T) { } } +func TestExemptCollectionShouldBypassDidFilter(t *testing.T) { + j := &JetstreamClient{ + wantedDids: Set[string]{"did:plc:boltless": {}}, + unfilteredNsids: make(Set[string]), + } + j.ExemptCollection("sh.tangled.repo") + + var processed int + wrapped := j.withDidFilter(func(context.Context, *models.Event) error { + processed++ + return nil + }) + + _ = wrapped(context.Background(), &models.Event{ + Did: "did:plc:akshay", TimeUS: 100, + Commit: &models.Commit{Collection: "sh.tangled.repo"}, + }) + if processed != 1 { + t.Fatal("An exempt collection must reach this processor from an unwanted DID; enrollment is decided later, not by this filter.") + } + + _ = wrapped(context.Background(), &models.Event{ + Did: "did:plc:akshay", TimeUS: 200, + Commit: &models.Commit{Collection: "sh.tangled.repo.pull"}, + }) + if processed != 1 { + t.Fatal("A non-exempt collection must still be filtered by DID.") + } +} + func TestLastSeenAdvancesAfterProcessing(t *testing.T) { j := &JetstreamClient{wantedDids: make(Set[string])} diff --git a/lexicons/org/tangled/temp/spindle/allowList/add.json b/lexicons/org/tangled/temp/spindle/allowList/add.json new file mode 100644 index 000000000..78bbfaf64 --- /dev/null +++ b/lexicons/org/tangled/temp/spindle/allowList/add.json @@ -0,0 +1,24 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.spindle.allowList.add", + "defs": { + "main": { + "type": "procedure", + "description": "Add a DID to this given spindle's enrollment-allow-list. This is callable only by a server owner. Sending an empty list will cause enrollment to be open to anyone who isn't directly banned.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["did"], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "DID of account that should be allowed to enroll repos on this here spindle." + } + } + } + } + } + } +} diff --git a/lexicons/org/tangled/temp/spindle/allowList/list.json b/lexicons/org/tangled/temp/spindle/allowList/list.json new file mode 100644 index 000000000..f938776e4 --- /dev/null +++ b/lexicons/org/tangled/temp/spindle/allowList/list.json @@ -0,0 +1,24 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.spindle.allowList.list", + "defs": { + "main": { + "type": "query", + "description": "Read this given spindle's enrollment-allow-list. Allowed for only server-owners. Empty output indicates that enrollment is open to anyone who isn't directly banned.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["dids"], + "properties": { + "dids": { + "type": "array", + "description": "Allow-list entries.", + "items": { "type": "string", "format": "did" } + } + } + } + } + } + } +} diff --git a/lexicons/org/tangled/temp/spindle/allowList/remove.json b/lexicons/org/tangled/temp/spindle/allowList/remove.json new file mode 100644 index 000000000..30d8b0955 --- /dev/null +++ b/lexicons/org/tangled/temp/spindle/allowList/remove.json @@ -0,0 +1,24 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.spindle.allowList.remove", + "defs": { + "main": { + "type": "procedure", + "description": "Remove a DID from this given spindle's enrollment-allow-list. Only callable by server-owner. By removing the last entry, it means reopening enrollment to everyone that's not directly banned.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["did"], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "DID to remove from allow-list." + } + } + } + } + } + } +} diff --git a/spindle/allowlist_test.go b/spindle/allowlist_test.go new file mode 100644 index 000000000..01c694449 --- /dev/null +++ b/spindle/allowlist_test.go @@ -0,0 +1,36 @@ +package spindle + +import ( + "testing" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/spindle/db" +) + +func TestEmptyAllowlistShouldEnrollEveryone(t *testing.T) { + d, _ := newTestSpindleDB(t) + nel := syntax.DID("did:plc:nel") + squid := syntax.DID("did:plc:squid") + + enrolls := func(want bool, did syntax.DID) { + t.Helper() + got, err := db.Enrolls(d, did) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("Enrolls(%s) = %v, want %v", did, got, want) + } + } + + enrolls(true, nel) + if err := db.AddToAllowlist(d, squid); err != nil { + t.Fatal(err) + } + enrolls(false, nel) + enrolls(true, squid) + if err := d.PutBan(db.BanEntry{SubjectDid: squid}); err != nil { + t.Fatal(err) + } + enrolls(false, squid) +} diff --git a/spindle/db/allowlist.go b/spindle/db/allowlist.go new file mode 100644 index 000000000..57b06c9e2 --- /dev/null +++ b/spindle/db/allowlist.go @@ -0,0 +1,69 @@ +package db + +import ( + "database/sql" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +func AddToAllowlist(q DBTX, did syntax.DID) error { + _, err := q.Exec(`insert or ignore into instance_allowlist (did) values (?)`, did) + return err +} + +func RemoveFromAllowlist(q DBTX, did syntax.DID) error { + _, err := q.Exec(`delete from instance_allowlist where did = ?`, did) + return err +} + +func AllowlistContains(q DBTX, did syntax.DID) (bool, error) { + var one int + err := q.QueryRow(`select 1 from instance_allowlist where did = ?`, did).Scan(&one) + switch { + case err == sql.ErrNoRows: + return false, nil + case err != nil: + return false, err + default: + return true, nil + } +} + +func ListAllowlist(q DBTX) ([]syntax.DID, error) { + rows, err := q.Query(`select did from instance_allowlist order by did asc`) + if err != nil { + return nil, err + } + defer rows.Close() + + dids := []syntax.DID{} + for rows.Next() { + var did string + if err := rows.Scan(&did); err != nil { + return nil, err + } + parsed, err := syntax.ParseDID(did) + if err != nil { + continue + } + dids = append(dids, parsed) + } + return dids, rows.Err() +} + +func Enrolls(q DBTX, did syntax.DID) (bool, error) { + var banned, listed bool + var count int64 + err := q.QueryRow(` + select + exists(select 1 from bans where subject_did = ?), + (select count(*) from instance_allowlist), + exists(select 1 from instance_allowlist where did = ?)`, + did, did, + ).Scan(&banned, &count, &listed) + if err != nil { + return false, err + } + return !banned && (count == 0 || listed), nil +} + diff --git a/spindle/db/db.go b/spindle/db/db.go index 75444fb20..69475209a 100644 --- a/spindle/db/db.go +++ b/spindle/db/db.go @@ -5,7 +5,6 @@ import ( "database/sql" "encoding/json" "log/slog" - "slices" "strings" "time" @@ -23,6 +22,7 @@ type DB struct { } type DBTX interface { + Query(query string, args ...any) (*sql.Rows, error) QueryRow(query string, args ...any) *sql.Row Exec(query string, args ...any) (sql.Result, error) } @@ -48,10 +48,10 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { last_time_us integer not null ); - create table if not exists known_dids ( - did text primary key + create table if not exists instance_allowlist ( + did text primary key, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); - create table if not exists repos ( id integer primary key autoincrement, knot text not null, @@ -90,16 +90,6 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { unique(owner_did, rkey) ); - create table if not exists spindle_members ( - id integer primary key autoincrement, - did text not null, - rkey text not null, - instance text not null, - subject text not null, - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - unique (did, rkey) - ); - create table if not exists nixos_toplevel_cache ( config_key text primary key, toplevel text not null, @@ -380,53 +370,6 @@ func runMigrations(_ context.Context, conn *sql.Conn, logger *slog.Logger) error return err } - if err := orm.RunMigration(conn, logger, "spindle-members-unique-on-rkey", func(tx *sql.Tx) error { - hasTarget, err := hasUniqueIndex(tx, "spindle_members", []string{"did", "rkey"}) - if err != nil { - return err - } - if hasTarget { - return nil - } - - var totalRows, distinctRows int - if err := tx.QueryRow(`select count(*) from spindle_members`).Scan(&totalRows); err != nil { - return err - } - if err := tx.QueryRow(`select count(*) from (select 1 from spindle_members group by did, rkey)`).Scan(&distinctRows); err != nil { - return err - } - if dropped := totalRows - distinctRows; dropped > 0 { - logger.Warn("dropping duplicate (did, rkey) rows during spindle_members rebuild", "dropped", dropped, "kept", distinctRows) - } - - _, err = tx.Exec(` - create table spindle_members_new ( - id integer primary key autoincrement, - did text not null, - rkey text not null, - instance text not null, - subject text not null, - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - unique (did, rkey) - ); - - insert into spindle_members_new (id, did, rkey, instance, subject, created) - select id, did, rkey, instance, subject, created - from spindle_members sm - where id = ( - select max(id) from spindle_members - where did = sm.did and rkey = sm.rkey - ); - - drop table spindle_members; - alter table spindle_members_new rename to spindle_members; - `) - return err - }); err != nil { - return err - } - if err := orm.RunMigration(conn, logger, "events-pipeline-index", func(tx *sql.Tx) error { _, err := tx.Exec(` create table if not exists events ( @@ -1126,6 +1069,15 @@ func runMigrations(_ context.Context, conn *sql.Conn, logger *slog.Logger) error }); err != nil { return err } + if err := orm.RunMigration(conn, logger, "drop-spindle-members-and-known-dids", func(tx *sql.Tx) error { + _, err := tx.Exec(` + drop table if exists spindle_members; + drop table if exists known_dids; + `) + return err + }); err != nil { + return err + } return nil } @@ -1171,57 +1123,6 @@ func migratePipelineIdentityColumns(tx *sql.Tx) error { return nil } -func hasUniqueIndex(tx *sql.Tx, table string, cols []string) (bool, error) { - rows, err := tx.Query( - `select name from pragma_index_list(?) where "unique" = 1`, - table, - ) - if err != nil { - return false, err - } - defer rows.Close() - - var indexNames []string - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - return false, err - } - indexNames = append(indexNames, name) - } - if err := rows.Err(); err != nil { - return false, err - } - - wantSorted := slices.Clone(cols) - slices.Sort(wantSorted) - - for _, name := range indexNames { - colRows, err := tx.Query( - `select name from pragma_index_info(?) order by seqno`, - name, - ) - if err != nil { - return false, err - } - var got []string - for colRows.Next() { - var c string - if err := colRows.Scan(&c); err != nil { - colRows.Close() - return false, err - } - got = append(got, c) - } - colRows.Close() - slices.Sort(got) - if slices.Equal(got, wantSorted) { - return true, nil - } - } - return false, nil -} - func (d *DB) SaveLastTimeUs(lastTimeUs int64) error { _, err := d.Exec(` insert into _jetstream (id, last_time_us) diff --git a/spindle/db/known_dids.go b/spindle/db/known_dids.go deleted file mode 100644 index 2192e0692..000000000 --- a/spindle/db/known_dids.go +++ /dev/null @@ -1,44 +0,0 @@ -package db - -func AddDid(q DBTX, did string) error { - _, err := q.Exec(`insert or ignore into known_dids (did) values (?)`, did) - return err -} - -func RemoveDid(q DBTX, did string) error { - _, err := q.Exec(`delete from known_dids where did = ?`, did) - return err -} - -func (d *DB) GetAllDids() ([]string, error) { - var dids []string - - rows, err := d.Query(`select did from known_dids`) - if err != nil { - return nil, err - } - defer rows.Close() - - for rows.Next() { - var did string - if err := rows.Scan(&did); err != nil { - return nil, err - } - dids = append(dids, did) - } - - if err := rows.Err(); err != nil { - return nil, err - } - - return dids, nil -} - -func (d *DB) HasKnownDids() bool { - var count int - err := d.QueryRow(`select count(*) from known_dids`).Scan(&count) - if err != nil { - return false - } - return count > 0 -} diff --git a/spindle/db/member.go b/spindle/db/member.go deleted file mode 100644 index 31fbfe19d..000000000 --- a/spindle/db/member.go +++ /dev/null @@ -1,63 +0,0 @@ -package db - -import ( - "github.com/bluesky-social/indigo/atproto/syntax" -) - -type SpindleMember struct { - Id int - Did syntax.DID // owner of the record - Rkey string // rkey of the record - Instance string - Subject syntax.DID // the member being added -} - -func AddSpindleMember(q DBTX, member SpindleMember) error { - _, err := q.Exec( - `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, - member.Did, - member.Rkey, - member.Instance, - member.Subject, - ) - return err -} - -func RemoveSpindleMember(q DBTX, ownerDid, rkey string) error { - _, err := q.Exec( - "delete from spindle_members where did = ? and rkey = ?", - ownerDid, - rkey, - ) - return err -} - -func CountSpindleMembersBySubject(q DBTX, subject string) (int, error) { - var count int - err := q.QueryRow( - `select count(*) from spindle_members where subject = ?`, - subject, - ).Scan(&count) - return count, err -} - -func GetSpindleMember(q DBTX, did, rkey string) (*SpindleMember, error) { - query := - `select id, did, rkey, instance, subject - from spindle_members - where did = ? and rkey = ?` - - var member SpindleMember - err := q.QueryRow(query, did, rkey).Scan( - &member.Id, - &member.Did, - &member.Rkey, - &member.Instance, - &member.Subject, - ) - if err != nil { - return nil, err - } - - return &member, nil -} diff --git a/spindle/ingester.go b/spindle/ingester.go index a5e5a0ed5..b2da54a2b 100644 --- a/spindle/ingester.go +++ b/spindle/ingester.go @@ -2,9 +2,6 @@ package spindle import ( "context" - "database/sql" - "encoding/json" - "errors" "fmt" "github.com/bluesky-social/indigo/atproto/syntax" @@ -12,7 +9,6 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "tangled.org/core/api/tangled" - "tangled.org/core/spindle/db" "tangled.org/core/spindle/observability" "tangled.org/core/tapc" ) @@ -48,8 +44,6 @@ func (s *Spindle) ingest() Ingester { } var err error switch e.Commit.Collection { - case tangled.SpindleMemberNSID: - err = s.ingestMember(ctx, e) case tangled.RepoNSID, tangled.RepoCollaboratorNSID: if evt, ok := jetstreamToTapEvent(e); ok { err = s.tap.processEvent(ctx, evt) @@ -110,187 +104,6 @@ func jetstreamToTapEvent(e *models.Event) (tapc.Event, bool) { }, true } -func (s *Spindle) ingestMember(ctx context.Context, e *models.Event) error { - did := e.Did - rkey := e.Commit.RKey - l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID, "did", did, "rkey", rkey) - - switch e.Commit.Operation { - case models.CommitOperationCreate, models.CommitOperationUpdate: - raw := e.Commit.Record - record := tangled.SpindleMember{} - if err := json.Unmarshal(raw, &record); err != nil { - return fmt.Errorf("invalid record: %w", err) - } - - domain := s.cfg.Server.Hostname - recordInstance := record.Instance - - if recordInstance != domain { - return fmt.Errorf("domain mismatch: %s != %s", record.Instance, domain) - } - - subject, err := syntax.ParseDID(record.Subject) - if err != nil { - return fmt.Errorf("invalid subject DID %q: %w", record.Subject, err) - } - - ok, err := s.e.IsSpindleInviteAllowed(did, rbacDomain) - if err != nil { - return fmt.Errorf("failed to enforce permissions: %w", err) - } - if !ok { - return fmt.Errorf("permission denied for %s", did) - } - - sqlTx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to start txn: %w", err) - } - committed := false - defer func() { - if !committed { - sqlTx.Rollback() - } - }() - - existing, err := db.GetSpindleMember(sqlTx, did, rkey) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("failed to look up existing member: %w", err) - } - - var staleSubject string - if existing != nil && existing.Subject != subject { - staleSubject = existing.Subject.String() - if err := db.RemoveSpindleMember(sqlTx, did, rkey); err != nil { - return fmt.Errorf("failed to remove stale member row: %w", err) - } - } - - if err := db.AddSpindleMember(sqlTx, db.SpindleMember{ - Did: syntax.DID(did), - Rkey: rkey, - Instance: recordInstance, - Subject: subject, - }); err != nil { - return fmt.Errorf("failed to add member: %w", err) - } - - if err := db.AddDid(sqlTx, subject.String()); err != nil { - return fmt.Errorf("failed to add did: %w", err) - } - - dropStaleAcl := false - var staleDidDropped bool - if staleSubject != "" { - remaining, err := db.CountSpindleMembersBySubject(sqlTx, staleSubject) - if err != nil { - return fmt.Errorf("failed to count stale subject rows: %w", err) - } - if remaining == 0 { - dropStaleAcl = true - stillNeeded, err := s.e.WouldHaveAnyPolicyExcludingSpindleMember(staleSubject, rbacDomain) - if err != nil { - return fmt.Errorf("failed to check residual policies for stale subject: %w", err) - } - if !stillNeeded { - if err := db.RemoveDid(sqlTx, staleSubject); err != nil { - return fmt.Errorf("failed to remove stale did: %w", err) - } - staleDidDropped = true - } - } - l.Info("replaced stale spindle member", "old_subject", staleSubject, "new_subject", subject, "stale_did_dropped", staleDidDropped) - } - - if err := sqlTx.Commit(); err != nil { - return fmt.Errorf("failed to commit txn: %w", err) - } - committed = true - - if dropStaleAcl { - if _, err := s.e.TryRemoveSpindleMember(rbacDomain, staleSubject); err != nil { - l.Error("post-commit: failed to remove stale ACL", "subject", staleSubject, "err", err) - } - } - if _, err := s.e.TryAddSpindleMember(rbacDomain, subject.String()); err != nil { - l.Error("post-commit: failed to add member ACL", "subject", subject, "err", err) - } - - if staleDidDropped { - s.jc.RemoveDid(staleSubject) - } - s.jc.AddDid(subject.String()) - l.Info("added member from firehose", "member", subject) - return nil - - case models.CommitOperationDelete: - sqlTx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to start txn: %w", err) - } - committed := false - defer func() { - if !committed { - sqlTx.Rollback() - } - }() - - record, err := db.GetSpindleMember(sqlTx, did, rkey) - if errors.Is(err, sql.ErrNoRows) { - l.Info("spindle member already removed") - return nil - } - if err != nil { - return fmt.Errorf("failed to find member: %w", err) - } - - staleSubject := record.Subject.String() - - if err := db.RemoveSpindleMember(sqlTx, did, rkey); err != nil { - return fmt.Errorf("failed to remove member: %w", err) - } - - remaining, err := db.CountSpindleMembersBySubject(sqlTx, staleSubject) - if err != nil { - return fmt.Errorf("failed to count remaining member rows: %w", err) - } - - dropAcl := false - var staleDidDropped bool - if remaining == 0 { - dropAcl = true - stillNeeded, err := s.e.WouldHaveAnyPolicyExcludingSpindleMember(staleSubject, rbacDomain) - if err != nil { - return fmt.Errorf("failed to check residual policies: %w", err) - } - if !stillNeeded { - if err := db.RemoveDid(sqlTx, staleSubject); err != nil { - return fmt.Errorf("failed to remove did: %w", err) - } - staleDidDropped = true - } - } - - if err := sqlTx.Commit(); err != nil { - return fmt.Errorf("failed to commit txn: %w", err) - } - committed = true - - if dropAcl { - if _, err := s.e.TryRemoveSpindleMember(rbacDomain, staleSubject); err != nil { - l.Error("post-commit: failed to remove member ACL", "subject", staleSubject, "err", err) - } - } - - if staleDidDropped { - s.jc.RemoveDid(staleSubject) - } - l.Info("removed member from firehose", "member", record.Subject, "remaining_rows", remaining, "stale_did_dropped", staleDidDropped) - } - return nil -} - func (s *Spindle) ingestAccount(ctx context.Context, e *models.Event) error { if e.Account == nil || e.Account.Active { return nil diff --git a/spindle/ingester_test.go b/spindle/ingester_test.go index 99a309ece..2a3890bdb 100644 --- a/spindle/ingester_test.go +++ b/spindle/ingester_test.go @@ -3,16 +3,12 @@ package spindle import ( "context" "encoding/json" - "log/slog" - "strings" - "tangled.org/core/jetstream" "testing" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/api/tangled" - "tangled.org/core/rbac" "tangled.org/core/spindle/config" "tangled.org/core/tapc" ) @@ -89,205 +85,3 @@ func TestEmbeddedTapDoesNotSubscribeToPullRecords(t *testing.T) { } } } - -func TestIngestMember_RBAC(t *testing.T) { - d, e := newTestSpindleDB(t) - - cfg := &config.Config{} - cfg.Server.Hostname = "spindle.test" - - jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) - if jcerr != nil { - t.Fatalf("NewJetstreamClient: %v", jcerr) - } - - s := &Spindle{ - db: d, - e: e, - l: slog.Default(), - cfg: cfg, - jc: jc, - rootCtx: context.Background(), - } - - actorDid := "did:plc:adminactor" - subjectDid := "did:plc:newmember" - rbacDomain := rbac.ThisServer - - memberRecord := tangled.SpindleMember{ - Instance: "spindle.test", - Subject: subjectDid, - } - memberRecordJson, _ := json.Marshal(memberRecord) - - evt := &models.Event{ - Did: actorDid, - Kind: models.EventKindCommit, - Commit: &models.Commit{ - Operation: models.CommitOperationCreate, - Collection: tangled.SpindleMemberNSID, - RKey: "member-rkey-1", - Record: memberRecordJson, - }, - } - - err := s.ingestMember(context.Background(), evt) - if err == nil { - t.Fatal("expected permission denied error, got nil") - } - if !strings.Contains(err.Error(), "permission denied") { - t.Fatalf("expected permission denied, got error: %v", err) - } - - var dbCount int - err = d.QueryRow(`select count(*) from spindle_members where subject = ?`, subjectDid).Scan(&dbCount) - if err != nil { - t.Fatalf("DB query error: %v", err) - } - if dbCount > 0 { - t.Fatal("spindle member was registered in DB on failed auth") - } - - err = e.AddSpindle(rbacDomain) - if err != nil { - t.Fatalf("AddSpindle: %v", err) - } - err = e.AddSpindleOwner(rbacDomain, actorDid) - if err != nil { - t.Fatalf("AddSpindleOwner: %v", err) - } - - err = s.ingestMember(context.Background(), evt) - if err != nil { - t.Fatalf("ingestMember failed for authorized actor: %v", err) - } - - err = d.QueryRow(`select count(*) from spindle_members where subject = ?`, subjectDid).Scan(&dbCount) - if err != nil || dbCount != 1 { - t.Fatalf("expected exactly 1 member in DB, got: %d (err: %v)", dbCount, err) - } - - isMember, err := e.IsSpindleMember(subjectDid, rbacDomain) - if err != nil || !isMember { - t.Fatalf("expected subject to be spindle member in Casbin, got: %t (err: %v)", isMember, err) - } - - deleteEvt := &models.Event{ - Did: actorDid, - Kind: models.EventKindCommit, - Commit: &models.Commit{ - Operation: models.CommitOperationDelete, - Collection: tangled.SpindleMemberNSID, - RKey: "member-rkey-1", - }, - } - - err = s.ingestMember(context.Background(), deleteEvt) - if err != nil { - t.Fatalf("ingestMember delete failed: %v", err) - } - - err = d.QueryRow(`select count(*) from spindle_members where subject = ?`, subjectDid).Scan(&dbCount) - if err != nil || dbCount != 0 { - t.Fatalf("expected 0 members in DB after delete, got: %d (err: %v)", dbCount, err) - } - - isMember, err = e.IsSpindleMember(subjectDid, rbacDomain) - if err != nil || isMember { - t.Fatalf("expected subject to NOT be spindle member in Casbin, got: %t (err: %v)", isMember, err) - } -} - -func TestIngestMember_ForgeDeleteRejection(t *testing.T) { - d, e := newTestSpindleDB(t) - - cfg := &config.Config{} - cfg.Server.Hostname = "spindle.test" - - jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) - if jcerr != nil { - t.Fatalf("NewJetstreamClient: %v", jcerr) - } - - s := &Spindle{ - db: d, - e: e, - l: slog.Default(), - cfg: cfg, - jc: jc, - rootCtx: context.Background(), - } - - adminDid := "did:plc:adminactor" - bobDid := "did:plc:bobactor" - subjectDid := "did:plc:newmember" - rbacDomain := rbac.ThisServer - - err := e.AddSpindle(rbacDomain) - if err != nil { - t.Fatalf("AddSpindle: %v", err) - } - err = e.AddSpindleOwner(rbacDomain, adminDid) - if err != nil { - t.Fatalf("AddSpindleOwner: %v", err) - } - - memberRecord := tangled.SpindleMember{ - Instance: "spindle.test", - Subject: subjectDid, - } - memberRecordJson, _ := json.Marshal(memberRecord) - - evt := &models.Event{ - Did: adminDid, - Kind: models.EventKindCommit, - Commit: &models.Commit{ - Operation: models.CommitOperationCreate, - Collection: tangled.SpindleMemberNSID, - RKey: "member-rkey-1", - Record: memberRecordJson, - }, - } - - err = s.ingestMember(context.Background(), evt) - if err != nil { - t.Fatalf("ingestMember failed for admin: %v", err) - } - - var dbCount int - err = d.QueryRow(`select count(*) from spindle_members where subject = ?`, subjectDid).Scan(&dbCount) - if err != nil || dbCount != 1 { - t.Fatalf("expected member in DB, got: %d (err: %v)", dbCount, err) - } - - isMember, err := e.IsSpindleMember(subjectDid, rbacDomain) - if err != nil || !isMember { - t.Fatalf("expected subject to be spindle member, got %t (err: %v)", isMember, err) - } - - // bob tries to delete alice's spindle member record, must reject forged delete - deleteEvt := &models.Event{ - Did: bobDid, // Bob is the actor - Kind: models.EventKindCommit, - Commit: &models.Commit{ - Operation: models.CommitOperationDelete, - Collection: tangled.SpindleMemberNSID, - RKey: "member-rkey-1", - }, - } - - err = s.ingestMember(context.Background(), deleteEvt) - if err != nil { - t.Fatalf("ingestMember delete returned error: %v", err) - } - - err = d.QueryRow(`select count(*) from spindle_members where subject = ?`, subjectDid).Scan(&dbCount) - if err != nil || dbCount != 1 { - t.Fatalf("member was deleted from DB, expected remaining, count: %d (err: %v)", dbCount, err) - } - - isMember, err = e.IsSpindleMember(subjectDid, rbacDomain) - if err != nil || !isMember { - t.Fatal("member policy was removed from Casbin by forged delete") - } -} diff --git a/spindle/server.go b/spindle/server.go index 3882893fa..74d6459a7 100644 --- a/spindle/server.go +++ b/spindle/server.go @@ -267,7 +267,6 @@ func New(ctx context.Context, cfg *config.Config, d *db.DB, engines map[string]m } collections := []string{ - tangled.SpindleMemberNSID, tangled.RepoNSID, tangled.RepoCollaboratorNSID, tangled.RepoPullNSID, @@ -279,17 +278,17 @@ func New(ctx context.Context, cfg *config.Config, d *db.DB, engines map[string]m } spindle.jc = jc jc.AddDid(cfg.Server.Owner) - // pull (status) records are created by arbitrary users too, same hack as in tap + // repo and pull (status) records are created by arbitrary users too, same hack as in tap + jc.ExemptCollection(tangled.RepoNSID) jc.ExemptCollection(tangled.RepoPullNSID) jc.ExemptCollection(tangled.RepoPullStatusNSID) - // Check if the spindle knows about any Dids; - dids, err := d.GetAllDids() + allowlisted, err := db.ListAllowlist(d) if err != nil { - return nil, fmt.Errorf("failed to get all dids: %w", err) + return nil, fmt.Errorf("failed to load the allow-list: %w", err) } - for _, d := range dids { - jc.AddDid(d) + for _, did := range allowlisted { + jc.AddDid(did.String()) } knownRepos, err := d.AllRepos() @@ -545,11 +544,10 @@ func (s *Spindle) declareTapInterest(ctx context.Context) { if reposErr != nil { s.l.Warn("tap declare: failed to load known repos", "err", reposErr) } - members, membersErr := s.db.GetAllDids() + members, membersErr := db.ListAllowlist(s.db) if membersErr != nil { - s.l.Warn("tap declare: failed to load known members", "err", membersErr) + s.l.Warn("tap declare: failed to load the allow-list", "err", membersErr) } - seen := make(map[syntax.DID]struct{}, len(repos)+len(members)+1) dids := make([]syntax.DID, 0, len(repos)+len(members)+1) add := func(did syntax.DID) { @@ -570,12 +568,7 @@ func (s *Spindle) declareTapInterest(ctx context.Context) { add(owner) } for _, member := range members { - did, err := syntax.ParseDID(member) - if err != nil { - s.l.Warn("tap declare: invalid known member", "did", member, "err", err) - continue - } - add(did) + add(member) } for _, repo := range repos { add(repo.Owner) @@ -835,6 +828,7 @@ func (s *Spindle) XrpcRouter() http.Handler { Trigger: s, QuotaStore: db.NewQuotaStore(s.db, quota.Defaults{}), Wiper: s, + Tap: s.tap, } return x.Router() diff --git a/spindle/tapclient.go b/spindle/tapclient.go index 3c46286dc..7667561a3 100644 --- a/spindle/tapclient.go +++ b/spindle/tapclient.go @@ -161,12 +161,12 @@ func (t *Tap) processRepo(ctx context.Context, evt *tapc.RecordEventData) error return nil } - isMember, err := t.spindle.e.IsSpindleMember(ownerDid.String(), rbac.ThisServer) + enrolled, err := db.Enrolls(t.spindle.db, ownerDid) if err != nil { - return fmt.Errorf("checking spindle membership: %w", err) + return fmt.Errorf("checking enrollment: %w", err) } - if !isMember { - l.Warn("rejecting repo record: owner is not a spindle member", "owner", ownerDid) + if !enrolled { + l.Warn("Rejecting this repo record: owner isn't on allow-list.", "owner", ownerDid) return nil } diff --git a/spindle/tapclient_test.go b/spindle/tapclient_test.go index 077a70045..c0a587969 100644 --- a/spindle/tapclient_test.go +++ b/spindle/tapclient_test.go @@ -109,13 +109,9 @@ func TestProcessRepo_MembershipCheck(t *testing.T) { nonMemberDid := syntax.DID("did:plc:nonmemberowner") repoDid := "did:plc:testrepo123" - err := e.AddSpindle(rbac.ThisServer) + err := db.AddToAllowlist(d, ownerDid) if err != nil { - t.Fatalf("AddSpindle: %v", err) - } - err = e.AddSpindleMember(rbac.ThisServer, ownerDid.String()) - if err != nil { - t.Fatalf("AddSpindleMember: %v", err) + t.Fatalf("AddToAllowlist: %v", err) } recNonMember := tangled.Repo{ @@ -334,17 +330,13 @@ func TestProcessRepo_HijackRepoDidCheck(t *testing.T) { bobDid := syntax.DID("did:plc:bob") repoDid := "did:plc:sharedrepo" - err := e.AddSpindle(rbac.ThisServer) - if err != nil { - t.Fatalf("AddSpindle: %v", err) - } - err = e.AddSpindleMember(rbac.ThisServer, aliceDid.String()) + err := db.AddToAllowlist(d, aliceDid) if err != nil { - t.Fatalf("AddSpindleMember alice: %v", err) + t.Fatalf("AddToAllowlist %s: %v", aliceDid, err) } - err = e.AddSpindleMember(rbac.ThisServer, bobDid.String()) + err = db.AddToAllowlist(d, bobDid) if err != nil { - t.Fatalf("AddSpindleMember bob: %v", err) + t.Fatalf("AddToAllowlist %s: %v", bobDid, err) } err = d.AddRepo(db.Repo{ diff --git a/spindle/wipe.go b/spindle/wipe.go index 009494527..ce5aaa5eb 100644 --- a/spindle/wipe.go +++ b/spindle/wipe.go @@ -196,23 +196,19 @@ func (s *Spindle) releaseOwnerInterest(ctx context.Context, ownerDid syntax.DID) if n > 0 { return nil } - members, err := db.CountSpindleMembersBySubject(s.db, ownerDid.String()) + onList, err := db.AllowlistContains(s.db, ownerDid) if err != nil { return err } - if members > 0 { + if onList { return nil } - // the server owner inherits membership via casbin without a members row - if ok, err := s.e.IsSpindleMember(ownerDid.String(), rbac.ThisServer); err != nil { + // the server owner inherits membership via casbin without an allow-list entry + if ok, err := s.e.IsSpindleOwner(ownerDid.String(), rbac.ThisServer); err != nil { return err } else if ok { return nil } - if err := db.RemoveDid(s.db, ownerDid.String()); err != nil { - return err - } - s.jc.RemoveDid(ownerDid.String()) if s.tap != nil { return s.tap.RemoveOwnerDIDs(ctx, []syntax.DID{ownerDid}) } diff --git a/spindle/wipe_test.go b/spindle/wipe_test.go index 3eb070c3a..c1b3fd98d 100644 --- a/spindle/wipe_test.go +++ b/spindle/wipe_test.go @@ -83,9 +83,7 @@ func seedWipeRepo(t *testing.T, s *Spindle, repoDid, owner syntax.DID) { if err := s.db.AddRepo(db.Repo{Knot: "knot.example.com", Owner: owner, Rkey: syntax.RecordKey("r" + sfx), RepoDid: repoDid}); err != nil { t.Fatal(err) } - if err := db.AddDid(s.db, owner.String()); err != nil { - t.Fatal(err) - } + if err := s.db.AddRepoCollaborator(db.RepoCollaborator{OwnerDid: owner, Rkey: syntax.RecordKey("c" + sfx), Subject: "did:plc:collab", RepoDid: repoDid}); err != nil { t.Fatal(err) } @@ -188,32 +186,19 @@ func TestWipeRepoRemovesAllState(t *testing.T) { t.Fatal("expected log artifact gone") } - dids, err := s.db.GetAllDids() - if err != nil { - t.Fatal(err) - } - if len(dids) != 0 { - t.Fatalf("expected owner did released, got %v", dids) - } - if err := s.WipeRepo(ctx, repoDid, "test again"); err != nil { t.Fatalf("second WipeRepo: %v", err) } } -func TestWipeRepoKeepsMemberInterest(t *testing.T) { +func TestWipeShouldKeepAllowListedOwners(t *testing.T) { s := newWipeTestSpindle(t) ctx := context.Background() repoDid := syntax.DID("did:plc:repo1") owner := syntax.DID("did:plc:owner1") seedWipeRepo(t, s, repoDid, owner) - if err := db.AddSpindleMember(s.db, db.SpindleMember{ - Did: owner, - Rkey: "m1", - Instance: "spindle.example.com", - Subject: owner, - }); err != nil { + if err := db.AddToAllowlist(s.db, owner); err != nil { t.Fatal(err) } @@ -221,36 +206,12 @@ func TestWipeRepoKeepsMemberInterest(t *testing.T) { t.Fatalf("WipeRepo: %v", err) } - dids, err := s.db.GetAllDids() - if err != nil { - t.Fatal(err) - } - if len(dids) != 1 || dids[0] != owner.String() { - t.Fatalf("expected member did kept, got %v", dids) - } -} - -func TestWipeRepoKeepsCasbinMemberInterest(t *testing.T) { - s := newWipeTestSpindle(t) - repoDid := syntax.DID("did:plc:repo1") - owner := syntax.DID("did:plc:owner1") - - seedWipeRepo(t, s, repoDid, owner) - // the server owner inherits membership via casbin without a members row - if err := s.e.AddSpindleMember(rbac.ThisServer, owner.String()); err != nil { - t.Fatal(err) - } - - if err := s.WipeRepo(context.Background(), repoDid, "test"); err != nil { - t.Fatalf("WipeRepo: %v", err) - } - - dids, err := s.db.GetAllDids() + onList, err := db.AllowlistContains(s.db, owner) if err != nil { t.Fatal(err) } - if len(dids) != 1 || dids[0] != owner.String() { - t.Fatalf("expected casbin member did kept, got %v", dids) + if !onList { + t.Fatal("A wipe mustn't take the owner off the allow-list.") } } diff --git a/spindle/xrpc/allowlist.go b/spindle/xrpc/allowlist.go new file mode 100644 index 000000000..99cb660ef --- /dev/null +++ b/spindle/xrpc/allowlist.go @@ -0,0 +1,91 @@ +package xrpc + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/org_tangled" + "tangled.org/core/spindle/db" + xrpcerr "tangled.org/core/xrpc/errors" +) + +type TapInterest interface { + AddOwnerDIDs(ctx context.Context, dids []syntax.DID) error + RemoveOwnerDIDs(ctx context.Context, dids []syntax.DID) error +} + +func allowListInput(w http.ResponseWriter, r *http.Request) (syntax.DID, bool) { + var input struct { + Did string `json:"did"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, invalidRequest(err), http.StatusBadRequest) + return "", false + } + did, err := syntax.ParseDID(input.Did) + if err != nil { + writeError(w, invalidRequest(fmt.Errorf("This DID %q deosn't parse: %w", input.Did, err)), http.StatusBadRequest) + return "", false + } + return did, true +} + +func (x *Xrpc) allowListEntry(w http.ResponseWriter, r *http.Request) (syntax.DID, bool) { + if _, errObj, status := x.checkSpindleOwner(r); errObj != nil { + writeError(w, *errObj, status) + return "", false + } + return allowListInput(w, r) +} + +func (x *Xrpc) allowListChange( + w http.ResponseWriter, + r *http.Request, + write func(q db.DBTX, did syntax.DID) error, + declare func(tap TapInterest, ctx context.Context, dids []syntax.DID) error, + change string, +) { + did, ok := x.allowListEntry(w, r) + if !ok { + return + } + if err := write(x.Db, did); err != nil { + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + if x.Tap != nil { + if err := declare(x.Tap, r.Context(), []syntax.DID{did}); err != nil { + x.Logger.WarnContext(r.Context(), "Allow-list "+change+": alierns rejected this declaration.", "did", did, "err", err) + } + } + x.Logger.InfoContext(r.Context(), "Allow-list entry "+change+".", "did", did) + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) AllowListAdd(w http.ResponseWriter, r *http.Request) { + x.allowListChange(w, r, db.AddToAllowlist, TapInterest.AddOwnerDIDs, "added") +} + +func (x *Xrpc) AllowListRemove(w http.ResponseWriter, r *http.Request) { + x.allowListChange(w, r, db.RemoveFromAllowlist, TapInterest.RemoveOwnerDIDs, "removed") +} + +func (x *Xrpc) AllowListList(w http.ResponseWriter, r *http.Request) { + if _, errObj, status := x.checkSpindleOwner(r); errObj != nil { + writeError(w, *errObj, status) + return + } + dids, err := db.ListAllowlist(x.Db) + if err != nil { + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + out := org_tangled.TempSpindleAllowListList_Output{Dids: make([]string, 0, len(dids))} + for _, did := range dids { + out.Dids = append(out.Dids, did.String()) + } + x.writeResponse(w, r, http.StatusOK, out) +} diff --git a/spindle/xrpc/allowlist_test.go b/spindle/xrpc/allowlist_test.go new file mode 100644 index 000000000..5fa28d29b --- /dev/null +++ b/spindle/xrpc/allowlist_test.go @@ -0,0 +1,112 @@ +package xrpc + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/org_tangled" + "tangled.org/core/rbac" + "tangled.org/core/spindle/config" + "tangled.org/core/spindle/db" +) + +func newAllowListXrpc(t *testing.T) (*Xrpc, *db.DB, *rbac.Enforcer, syntax.DID) { + t.Helper() + d, e := newTestXrpcDB(t) + owner := syntax.DID("did:plc:instanceowner") + if err := e.AddSpindle(rbac.ThisServer); err != nil { + t.Fatalf("AddSpindle: %v", err) + } + if err := e.AddSpindleOwner(rbac.ThisServer, owner.String()); err != nil { + t.Fatalf("AddSpindleOwner: %v", err) + } + return &Xrpc{ + Logger: slog.Default(), + Db: d, + Enforcer: e, + Config: &config.Config{}, + }, d, e, owner +} + +func allowListCall(x *Xrpc, handler func(http.ResponseWriter, *http.Request), actor syntax.DID, method, query string, body []byte) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, "/"+query, bytes.NewReader(body)) + req = req.WithContext(context.WithValue(req.Context(), ActorDid, actor)) + w := httptest.NewRecorder() + handler(w, req) + return w +} + +func TestAllowListRoutesShouldBeOwnerOnly(t *testing.T) { + x, _, _, owner := newAllowListXrpc(t) + stranger := syntax.DID("did:plc:stranger") + + for _, call := range []struct { + handler func(http.ResponseWriter, *http.Request) + method string + query string + body []byte + }{ + {x.AllowListAdd, http.MethodPost, org_tangled.TempSpindleAllowListAddNSID, []byte(`{"did":"did:plc:nel"}`)}, + {x.AllowListRemove, http.MethodPost, org_tangled.TempSpindleAllowListRemoveNSID, []byte(`{"did":"did:plc:nel"}`)}, + {x.AllowListList, http.MethodGet, org_tangled.TempSpindleAllowListListNSID, nil}, + } { + w := allowListCall(x, call.handler, stranger, call.method, call.query, call.body) + if w.Code != http.StatusUnauthorized { + t.Fatalf("stranger on %s = %d: %s", call.query, w.Code, w.Body.String()) + } + } + + w := allowListCall(x, x.AllowListList, owner, http.MethodGet, org_tangled.TempSpindleAllowListListNSID, nil) + if w.Code != http.StatusOK { + t.Fatalf("owner list = %d: %s", w.Code, w.Body.String()) + } + var out org_tangled.TempSpindleAllowListList_Output + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + if len(out.Dids) != 0 { + t.Fatalf("A fresh instance lists no entries: %v.", out.Dids) + } +} + +func TestAllowListAddAndRemoveShouldRoundTrip(t *testing.T) { + x, d, _, owner := newAllowListXrpc(t) + nel := syntax.DID("did:plc:nel") + squid := syntax.DID("did:plc:squid") + + for _, did := range []syntax.DID{nel, squid} { + body, _ := json.Marshal(map[string]string{"did": did.String()}) + w := allowListCall(x, x.AllowListAdd, owner, http.MethodPost, org_tangled.TempSpindleAllowListAddNSID, body) + if w.Code != http.StatusOK { + t.Fatalf("add %s = %d: %s", did, w.Code, w.Body.String()) + } + } + + w := allowListCall(x, x.AllowListList, owner, http.MethodGet, org_tangled.TempSpindleAllowListListNSID, nil) + var listed org_tangled.TempSpindleAllowListList_Output + if err := json.Unmarshal(w.Body.Bytes(), &listed); err != nil { + t.Fatalf("decode: %v", err) + } + if len(listed.Dids) != 2 || listed.Dids[0] != nel.String() || listed.Dids[1] != squid.String() { + t.Fatalf("List = %+v; both entries should return in lexical order.", listed) + } + + body, _ := json.Marshal(map[string]string{"did": squid.String()}) + w = allowListCall(x, x.AllowListRemove, owner, http.MethodPost, org_tangled.TempSpindleAllowListRemoveNSID, body) + if w.Code != http.StatusOK { + t.Fatalf("remove = %d: %s", w.Code, w.Body.String()) + } + still, err := db.AllowlistContains(d, squid) + if err != nil { + t.Fatal(err) + } + if still { + t.Fatal("Squid is still on the allow-list after removal.") + } +}