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" }}
-
-{{ 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.")
+ }
+}