diff --git a/spindle/config/config.go b/spindle/config/config.go index 634c0f7c2..47255a1ef 100644 --- a/spindle/config/config.go +++ b/spindle/config/config.go @@ -24,6 +24,7 @@ type Server struct { QueueSize int `env:"QUEUE_SIZE, default=100"` MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of pipelines that run at a time DockerSocket string `env:"DOCKER_SOCKET"` // path to a docker socket to expose to workflow containers + InviteOnly bool `env:"INVITE_ONLY, default=true"` } type Tap struct { diff --git a/spindle/db/pipelines_test.go b/spindle/db/pipelines_test.go index 71e9bddcb..2441cbff5 100644 --- a/spindle/db/pipelines_test.go +++ b/spindle/db/pipelines_test.go @@ -3,6 +3,7 @@ package db import ( "context" "encoding/json" + "path/filepath" "slices" "testing" "time" @@ -10,6 +11,16 @@ import ( "tangled.org/core/api/tangled" ) +func newTestDB(t *testing.T) *DB { + t.Helper() + d, err := Make(context.Background(), filepath.Join(t.TempDir(), "spindle.db")) + if err != nil { + t.Fatalf("Make: %v", err) + } + t.Cleanup(func() { d.Close() }) + return d +} + func seedPipelineEvent(t *testing.T, d *DB, rkey, repoDid, kind string, created int64) { t.Helper() repo := repoDid diff --git a/spindle/db/repos.go b/spindle/db/repos.go index 5c37f2a0e..997992b45 100644 --- a/spindle/db/repos.go +++ b/spindle/db/repos.go @@ -15,47 +15,37 @@ type Repo struct { CreatedAt string } -func (d *DB) AddRepo(repo Repo) error { +func (d *DB) UpsertRepo(repo Repo) error { var createdAt sql.NullString if repo.CreatedAt != "" { createdAt = sql.NullString{String: repo.CreatedAt, Valid: true} } _, err := d.Exec( - `insert into repos (knot, owner, rkey, repo_did, created_at) - values (?, ?, ?, ?, ?) - on conflict(owner, rkey) do update set - knot = excluded.knot, - repo_did = excluded.repo_did, - created_at = coalesce(excluded.created_at, repos.created_at)`, - repo.Knot, repo.Owner.String(), repo.Rkey.String(), repo.RepoDid.String(), createdAt, + `insert or replace into repos (repo_did, knot, owner, rkey, created_at) + values (?, ?, ?, ?, ?)`, + repo.RepoDid, repo.Knot, repo.Owner, repo.Rkey, createdAt, ) return err } -func (d *DB) CollapseRepoSiblings(owner, repoDid syntax.DID) (int64, error) { - res, err := d.Exec( - `delete from repos - where owner = ? - and repo_did = ? - and ( - (created_at is null and exists ( - select 1 from repos r2 - where r2.owner = repos.owner - and r2.repo_did = repos.repo_did - and r2.created_at is not null - and r2.rkey <> repos.rkey - )) - or (created_at is not null and created_at < ( - select max(created_at) from repos - where owner = ? and repo_did = ? and created_at is not null - )) - )`, - owner.String(), repoDid.String(), owner.String(), repoDid.String(), - ) +func (d *DB) RepoOwners() ([]syntax.DID, error) { + repos, err := d.AllRepos() if err != nil { - return 0, err + return nil, err + } + seen := make(map[syntax.DID]struct{}, len(repos)) + dids := make([]syntax.DID, 0, len(repos)) + for _, r := range repos { + if r.Owner == "" { + continue + } + if _, ok := seen[r.Owner]; ok { + continue + } + seen[r.Owner] = struct{}{} + dids = append(dids, r.Owner) } - return res.RowsAffected() + return dids, nil } func (d *DB) Knots() ([]string, error) { @@ -94,33 +84,6 @@ func scanRepo(row interface{ Scan(...any) error }) (*Repo, error) { }, nil } -func (d *DB) SiblingRkeysForRepoDid(owner, repoDid syntax.DID, excludeRkey syntax.RecordKey) ([]string, error) { - rows, err := d.Query( - `select rkey from repos - where owner = ? - and coalesce(repo_did, '') = ? - and rkey <> ?`, - owner.String(), repoDid.String(), excludeRkey.String(), - ) - if err != nil { - return nil, err - } - defer rows.Close() - - var collect func(acc []string) ([]string, error) - collect = func(acc []string) ([]string, error) { - if !rows.Next() { - return acc, rows.Err() - } - var r string - if err := rows.Scan(&r); err != nil { - return acc, err - } - return collect(append(acc, r)) - } - return collect(nil) -} - func (d *DB) GetRepoByDid(repoDid syntax.DID) (*Repo, error) { return scanRepo(d.QueryRow( `select knot, owner, rkey, repo_did from repos where repo_did = ?`, @@ -128,13 +91,6 @@ func (d *DB) GetRepoByDid(repoDid syntax.DID) (*Repo, error) { )) } -func (d *DB) GetRepoByOwnerRkey(owner syntax.DID, rkey syntax.RecordKey) (*Repo, error) { - return scanRepo(d.QueryRow( - `select knot, owner, rkey, repo_did from repos where owner = ? and rkey = ?`, - owner.String(), rkey.String(), - )) -} - func (d *DB) AllRepos() ([]Repo, error) { rows, err := d.Query(`select knot, owner, rkey, repo_did from repos`) if err != nil { diff --git a/spindle/db/repos_test.go b/spindle/db/repos_test.go deleted file mode 100644 index d631c29cf..000000000 --- a/spindle/db/repos_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package db - -import ( - "context" - "path/filepath" - "testing" - - "github.com/bluesky-social/indigo/atproto/syntax" -) - -func newTestDB(t *testing.T) *DB { - t.Helper() - d, err := Make(context.Background(), filepath.Join(t.TempDir(), "spindle.db")) - if err != nil { - t.Fatalf("Make: %v", err) - } - t.Cleanup(func() { d.Close() }) - return d -} - -func TestCollapseRepoSiblings_DeletesStaleNullCreatedAtWithDifferentRkey(t *testing.T) { - d := newTestDB(t) - owner := syntax.DID("did:plc:akshay") - repoDid := syntax.DID("did:plc:boltless") - - if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values - ('k', ?, 'stale-bogus-rkey', ?, null), - ('k', ?, 'fresh-pds-rkey', ?, '2024-06-01T00:00:00Z')`, - owner.String(), repoDid.String(), - owner.String(), repoDid.String()); err != nil { - t.Fatalf("seed: %v", err) - } - - n, err := d.CollapseRepoSiblings(owner, repoDid) - if err != nil { - t.Fatalf("CollapseRepoSiblings: %v", err) - } - if n != 1 { - t.Errorf("expected 1 stale row deleted, got %d", n) - } - - var rkey string - if err := d.QueryRow(`select rkey from repos where owner = ? and repo_did = ?`, - owner.String(), repoDid.String()).Scan(&rkey); err != nil { - t.Fatalf("query: %v", err) - } - if rkey != "fresh-pds-rkey" { - t.Errorf("expected fresh row preserved, got rkey=%q", rkey) - } -} - -func TestCollapseRepoSiblings_KeepsNullCreatedAtWhenAlone(t *testing.T) { - d := newTestDB(t) - owner := syntax.DID("did:plc:akshay") - repoDid := syntax.DID("did:plc:boltless") - - if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values - ('k', ?, 'sole-row', ?, null)`, - owner.String(), repoDid.String()); err != nil { - t.Fatalf("seed: %v", err) - } - - n, err := d.CollapseRepoSiblings(owner, repoDid) - if err != nil { - t.Fatalf("CollapseRepoSiblings: %v", err) - } - if n != 0 { - t.Errorf("expected 0 deletions when only NULL row exists, got %d", n) - } - - var count int - if err := d.QueryRow(`select count(*) from repos where owner = ?`, owner.String()).Scan(&count); err != nil { - t.Fatalf("count: %v", err) - } - if count != 1 { - t.Errorf("sole NULL row should survive, got %d remaining", count) - } -} - -func TestCollapseRepoSiblings_OlderTimestampLoses(t *testing.T) { - d := newTestDB(t) - owner := syntax.DID("did:plc:akshay") - repoDid := syntax.DID("did:plc:boltless") - - if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values - ('k', ?, 'older-rkey', ?, '2024-01-01T00:00:00Z'), - ('k', ?, 'newer-rkey', ?, '2024-06-01T00:00:00Z')`, - owner.String(), repoDid.String(), - owner.String(), repoDid.String()); err != nil { - t.Fatalf("seed: %v", err) - } - - n, err := d.CollapseRepoSiblings(owner, repoDid) - if err != nil { - t.Fatalf("CollapseRepoSiblings: %v", err) - } - if n != 1 { - t.Errorf("expected older row collapsed, got %d", n) - } - - var rkey string - if err := d.QueryRow(`select rkey from repos where owner = ? and repo_did = ?`, - owner.String(), repoDid.String()).Scan(&rkey); err != nil { - t.Fatalf("query: %v", err) - } - if rkey != "newer-rkey" { - t.Errorf("expected newer row preserved, got rkey=%q", rkey) - } -} - -func TestCollapseRepoSiblings_KeepsNullRowWithMatchingRkey(t *testing.T) { - d := newTestDB(t) - owner := syntax.DID("did:plc:akshay") - repoDid := syntax.DID("did:plc:boltless") - - if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values - ('k', ?, 'matched-rkey', ?, null)`, - owner.String(), repoDid.String()); err != nil { - t.Fatalf("seed: %v", err) - } - - if err := d.AddRepo(Repo{ - Knot: "k", - Owner: owner, - Rkey: "matched-rkey", - RepoDid: repoDid, - CreatedAt: "2024-06-01T00:00:00Z", - }); err != nil { - t.Fatalf("AddRepo upsert: %v", err) - } - - if _, err := d.CollapseRepoSiblings(owner, repoDid); err != nil { - t.Fatalf("CollapseRepoSiblings: %v", err) - } - - var count int - if err := d.QueryRow(`select count(*) from repos where owner = ?`, owner.String()).Scan(&count); err != nil { - t.Fatalf("count: %v", err) - } - if count != 1 { - t.Errorf("upserted row should be the single survivor, got %d", count) - } -} diff --git a/spindle/embedtap.go b/spindle/embedtap.go index f1cc11d68..7d29f58ef 100644 --- a/spindle/embedtap.go +++ b/spindle/embedtap.go @@ -63,7 +63,8 @@ func newEmbeddedTapConfig(cfg *config.Config) tap.Config { RepoFetchTimeout: 5 * time.Minute, IdentityCacheSize: 50_000, EventCacheSize: 10_000, - CollectionFilters: []string{tangled.RepoNSID, tangled.RepoCollaboratorNSID}, + FullNetworkMode: !cfg.Server.InviteOnly, + CollectionFilters: []string{tangled.RepoNSID}, AdminPassword: cfg.Server.Tap.AdminPassword, RetryTimeout: 60 * time.Second, } diff --git a/spindle/server.go b/spindle/server.go index 0b5f01116..9c28208c3 100644 --- a/spindle/server.go +++ b/spindle/server.go @@ -11,6 +11,7 @@ import ( "maps" "net/http" "path/filepath" + "slices" "sync" "time" @@ -119,23 +120,18 @@ func New(ctx context.Context, cfg *config.Config, d *db.DB, engines map[string]m // pull records are created by arbitrary users too, same hack as in tap jc.ExemptCollection(tangled.RepoPullNSID) - // Check if the spindle knows about any Dids; - dids, err := d.ListAllowedMembers() - if err != nil { - return nil, fmt.Errorf("failed to get all dids: %w", err) - } - for _, d := range dids { - jc.AddDid(d.String()) - } - - knownRepos, err := d.AllRepos() - if err != nil { - return nil, fmt.Errorf("failed to get known repos: %w", err) - } - for _, r := range knownRepos { - if r.Owner != "" { - jc.AddDid(r.Owner.String()) + if cfg.Server.InviteOnly { + // listen to members and the collaborators on the repos we host + dids, err := subscribedDids(d, e) + if err != nil { + return nil, fmt.Errorf("failed to build jetstream did filter: %w", err) + } + for _, did := range dids { + jc.AddDid(did.String()) } + } else { + // public spindle. listen to full network + jc.ExemptCollection(tangled.RepoNSID) } resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) @@ -236,6 +232,39 @@ func (s *Spindle) VerifyRepo(ctx context.Context, repo syntax.DID) (repoverify.R return s.verify(ctx, repoident.RepoDid(repo)) } +// subscribedDids lists all allowed spindle members & all collaborators of registered repos +func subscribedDids(d *db.DB, e *rbac.Enforcer) ([]syntax.DID, error) { + members, err := d.ListAllowedMembers() + if err != nil { + return nil, fmt.Errorf("list members: %w", err) + } + repos, err := d.AllRepos() + if err != nil { + return nil, fmt.Errorf("list repos: %w", err) + } + + dids := slices.Clone(members) + for _, r := range repos { + // includes the repo owner, via the repo:owner -> repo:collaborator grouping + collaborators, err := e.GetRepoCollaborators(r.RepoDid) + if err != nil { + return nil, fmt.Errorf("list collaborators of %s: %w", r.RepoDid, err) + } + dids = append(dids, collaborators...) + } + + slices.Sort(dids) + return slices.Compact(dids), nil +} + +func (s *Spindle) grantCollaborator(subject, repo syntax.DID) error { + if err := s.e.AddRepoCollaborator(subject, repo); err != nil { + return err + } + s.jc.AddDid(subject.String()) + return nil +} + // SetMotdContent sets custom MOTD content, replacing the embedded default. func (s *Spindle) SetMotdContent(content []byte) { s.motdMu.Lock() @@ -291,31 +320,6 @@ func (s *Spindle) Start(ctx context.Context) error { return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router()) } -func (s *Spindle) declareTapInterest(ctx context.Context) { - repos, err := s.db.AllRepos() - if err != nil { - s.l.Warn("tap declare: failed to load known repos", "err", err) - return - } - seen := make(map[syntax.DID]struct{}, len(repos)) - dids := make([]syntax.DID, 0, len(repos)) - for _, r := range repos { - if r.Owner == "" { - continue - } - if _, ok := seen[r.Owner]; ok { - continue - } - seen[r.Owner] = struct{}{} - dids = append(dids, r.Owner) - } - if err := s.tap.AddOwnerDIDs(ctx, dids); err != nil { - s.l.Warn("tap declare: AddRepos rejected", "count", len(dids), "err", err) - return - } - s.l.Info("tap declare: known owner DIDs registered", "count", len(dids)) -} - func Run(ctx context.Context) error { cfg, err := config.Load(ctx) if err != nil { @@ -442,7 +446,7 @@ func (s *Spindle) processKnotStream(ctx context.Context, src eventconsumer.Sourc return nil } -func (s *Spindle) ingestKnotCollaborator(ctx context.Context, l *slog.Logger, src eventconsumer.Source, msg eventstream.Event) error { +func (s *Spindle) ingestKnotCollaborator(_ context.Context, l *slog.Logger, src eventconsumer.Source, msg eventstream.Event) error { var rec knotdb.RepoCollaboratorUpdate if err := json.Unmarshal(msg.EventJson, &rec); err != nil { l.Error("error unmarshalling collaboratorUpdate", "err", err) @@ -475,11 +479,14 @@ func (s *Spindle) ingestKnotCollaborator(ctx context.Context, l *slog.Logger, sr switch rec.Op { case knotdb.AclOpAdd: - if err := s.e.AddRepoCollaborator(subject, repoDid); err != nil { + if err := s.grantCollaborator(subject, repoDid); err != nil { return fmt.Errorf("add collaborator policy: %w", err) } l.Info("added knot-managed collaborator", "subject", subject, "repo", repoDid) case knotdb.AclOpRemove: + // ponytail: no jc.RemoveDid here - the subject may still be a member or a + // collaborator elsewhere, and a stale filter entry is harmless (processRepo still + // rejects non-members). Add refcounting across members + acl_2 if the filter grows. if err := s.e.RemoveRepoCollaborator(subject, repoDid); err != nil { return fmt.Errorf("remove collaborator policy: %w", err) } @@ -853,17 +860,3 @@ func ensureGitVersion() error { } return nil } - -func (s *Spindle) resolvePipelineRepoDid(repo *tangled.Pipeline_TriggerRepo) (syntax.DID, error) { - if repo.RepoDid == nil || *repo.RepoDid == "" { - return "", fmt.Errorf("pipeline trigger missing repoDid") - } - repoDid, err := syntax.ParseDID(*repo.RepoDid) - if err != nil { - return "", fmt.Errorf("parse repoDid %s: %w", *repo.RepoDid, err) - } - if _, err := s.db.GetRepoByDid(repoDid); err != nil { - return "", fmt.Errorf("unknown repoDid %s: %w", repoDid, err) - } - return repoDid, nil -} diff --git a/spindle/server_test.go b/spindle/server_test.go index 03b1efc4a..4f54f8807 100644 --- a/spindle/server_test.go +++ b/spindle/server_test.go @@ -1,9 +1,12 @@ package spindle import ( + "slices" "testing" + "github.com/bluesky-social/indigo/atproto/syntax" kgit "tangled.org/core/knotserver/git" + "tangled.org/core/spindle/db" ) func TestHasSkipCIPushOption(t *testing.T) { @@ -53,3 +56,50 @@ func TestHasSkipCIPushOption(t *testing.T) { }) } } + +func TestSubscribedDids(t *testing.T) { + d, e := newTestSpindleDB(t) + + var ( + member = syntax.DID("did:plc:member") + owner = syntax.DID("did:plc:owner") + collab = syntax.DID("did:plc:collab") + repoDid = syntax.DID("did:plc:repo") + repo2Did = syntax.DID("did:plc:repo2") + ) + + // the repo owner is also a member, so the union has to dedupe + for _, did := range []syntax.DID{member, owner} { + if err := d.AllowMember(t.Context(), did); err != nil { + t.Fatalf("AllowMember(%s): %v", did, err) + } + } + + for _, r := range []syntax.DID{repoDid, repo2Did} { + if err := d.UpsertRepo(db.Repo{ + Knot: "knot.test", + Owner: owner, + Rkey: syntax.RecordKey("rkey-" + r.String()), + RepoDid: r, + }); err != nil { + t.Fatalf("UpsertRepo(%s): %v", r, err) + } + if err := e.SetRepoOwner(owner, r); err != nil { + t.Fatalf("SetRepoOwner(%s): %v", r, err) + } + } + // a collaborator who is not a member - the case the members-only filter dropped + if err := e.AddRepoCollaborator(collab, repoDid); err != nil { + t.Fatalf("AddRepoCollaborator: %v", err) + } + + got, err := subscribedDids(d, e) + if err != nil { + t.Fatalf("subscribedDids: %v", err) + } + + want := []syntax.DID{collab, member, owner} // sorted and deduped + if !slices.Equal(got, want) { + t.Errorf("subscribedDids() = %v, want %v", got, want) + } +} diff --git a/spindle/tapclient.go b/spindle/tapclient.go index 8b9b7d07c..63d863559 100644 --- a/spindle/tapclient.go +++ b/spindle/tapclient.go @@ -44,13 +44,6 @@ func NewTapClient(s *Spindle) *Tap { } } -func (t *Tap) AddOwnerDIDs(ctx context.Context, dids []syntax.DID) error { - if len(dids) == 0 { - return nil - } - return t.tap.AddRepos(ctx, dids) -} - func (t *Tap) Start(connCtx context.Context) { go t.tap.Connect(connCtx, &tapc.SimpleIndexer{ EventHandler: t.processEvent, @@ -59,7 +52,23 @@ func (t *Tap) Start(connCtx context.Context) { } func (t *Tap) onConnect(ctx context.Context) { - t.spindle.declareTapInterest(ctx) + l := t.logger + if t.spindle.cfg.Server.InviteOnly { + // listen to owners of registered repositories + owners, err := t.spindle.db.RepoOwners() + if err != nil { + l.Warn("tap declare: failed to load known repos", "err", err) + return + } + if err := t.tap.AddRepos(ctx, owners); err != nil { + l.Warn("tap declare: AddRepos rejected", "count", len(owners), "err", err) + return + } + l.Info("tap declare: known owner DIDs registered", "count", len(owners)) + } else { + // public spindle. listen to full network + l.Info("tap declare: listening to full network") + } } func (t *Tap) processEvent(ctx context.Context, evt tapc.Event) error { @@ -98,7 +107,7 @@ func (t *Tap) processRepo(ctx context.Context, evt *tapc.RecordEventData) error return nil } - isMember, err := t.spindle.e.IsSpindleMember(ownerDid.String(), rbac.ThisServer) + isMember, err := t.spindle.db.IsAllowedMember(ctx, ownerDid, !t.spindle.cfg.Server.InviteOnly) if err != nil { return fmt.Errorf("checking spindle membership: %w", err) } @@ -150,7 +159,7 @@ func (t *Tap) processRepo(ctx context.Context, evt *tapc.RecordEventData) error CreatedAt: record.CreatedAt, } - if err := t.spindle.db.AddRepo(repo); err != nil { + if err := t.spindle.db.UpsertRepo(repo); err != nil { l.Error("failed to add repo row", "err", err) return fmt.Errorf("add repo: %w", err) } @@ -171,12 +180,6 @@ func (t *Tap) processRepo(ctx context.Context, evt *tapc.RecordEventData) error } migrateLegacyRepoSecrets(ctx, t.spindle.db, t.spindle.vault, l, ownerDid, legacyName, rkey, repoDid) - if removed, err := t.spindle.db.CollapseRepoSiblings(ownerDid, repoDid); err != nil { - l.Warn("collapse rename siblings failed", "err", err) - } else if removed > 0 { - l.Info("collapsed rename leftovers", "owner", ownerDid, "repo_did", repoDid, "removed", removed) - } - if e := t.spindle.embedTap; e == nil || !e.closed.Load() { if err := t.tap.AddRepos(ctx, []syntax.DID{ownerDid}); err != nil { l.Warn("tap AddRepos rejected", "did", ownerDid, "err", err) diff --git a/spindle/tapclient_test.go b/spindle/tapclient_test.go index a46a9d3c1..97ddcc5c8 100644 --- a/spindle/tapclient_test.go +++ b/spindle/tapclient_test.go @@ -4,17 +4,23 @@ import ( "context" "encoding/json" "log/slog" + "net/http" + "net/http/httptest" + "net/url" "strings" - "tangled.org/core/jetstream" "testing" "time" + "tangled.org/core/jetstream" + "tangled.org/core/repoident" + "tangled.org/core/repoverify" + "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/stretchr/testify/assert" "tangled.org/core/api/tangled" "tangled.org/core/eventconsumer" "tangled.org/core/idresolver" - "tangled.org/core/rbac" "tangled.org/core/spindle/config" "tangled.org/core/spindle/db" @@ -41,11 +47,44 @@ func (m *mockDirectory) Purge(ctx context.Context, id syntax.AtIdentifier) error return nil } +func mockRepoVerifier(res repoverify.Result) repoverify.Verifier { + return func(ctx context.Context, repoDid repoident.RepoDid) (repoverify.Result, error) { + return res, nil + } +} + +func TestProcessRepo_MembershipChange(t *testing.T) { + member := syntax.DID("did:example:foo") + + var ok bool + var err error + d, _ := newTestSpindleDB(t) + + ok, err = d.IsAllowedMember(t.Context(), member, false) + assert.NoError(t, err) + assert.False(t, ok) + + ok, err = d.IsAllowedMember(t.Context(), member, true) + assert.NoError(t, err) + assert.True(t, ok) + + assert.NoError(t, d.AllowMember(t.Context(), member)) + + ok, err = d.IsAllowedMember(t.Context(), member, false) + assert.NoError(t, err) + assert.True(t, ok) + + ok, err = d.IsAllowedMember(t.Context(), member, true) + assert.NoError(t, err) + assert.True(t, ok) +} + func TestProcessRepo_MembershipCheck(t *testing.T) { d, e := newTestSpindleDB(t) cfg := &config.Config{} cfg.Server.Hostname = "spindle.test" + cfg.Server.InviteOnly = true ccfg := eventconsumer.NewConsumerConfig() ccfg.Logger = slog.Default() @@ -63,6 +102,16 @@ func TestProcessRepo_MembershipCheck(t *testing.T) { ks: ks, jc: jc, rootCtx: context.Background(), + verify: mockRepoVerifier(repoverify.Result{ + RepoDid: "did:plc:testrepo123", + OwnerDid: "did:plc:memberowner", + Rkey: "test-repo-rkey", + KnotURL: func() *url.URL { + u, err := url.Parse("https://knot.test") + assert.NoError(t, err) + return u + }(), + }), } tap := &Tap{ @@ -72,20 +121,16 @@ func TestProcessRepo_MembershipCheck(t *testing.T) { ownerDid := syntax.DID("did:plc:memberowner") nonMemberDid := syntax.DID("did:plc:nonmemberowner") - repoDid := "did:plc:testrepo123" + repoDid := syntax.DID("did:plc:testrepo123") - err := e.AddSpindle(rbac.ThisServer) - if err != nil { - t.Fatalf("AddSpindle: %v", err) - } - err = e.AddSpindleMember(rbac.ThisServer, ownerDid.String()) + err := d.AllowMember(t.Context(), ownerDid) if err != nil { t.Fatalf("AddSpindleMember: %v", err) } recNonMember := tangled.Repo{ Knot: "knot.test", - RepoDid: &repoDid, + RepoDid: (*string)(&repoDid), Spindle: &cfg.Server.Hostname, CreatedAt: time.Now().Format(time.RFC3339), } @@ -103,14 +148,14 @@ func TestProcessRepo_MembershipCheck(t *testing.T) { t.Fatalf("processRepo returned error for non-member: %v", err) } - _, err = d.GetRepoByOwnerRkey(nonMemberDid, "test-repo-rkey") + _, err = d.GetRepoByDid(repoDid) if err == nil { t.Fatal("repo for non-member was registered in DB, expected rejection") } recMember := tangled.Repo{ Knot: "knot.test", - RepoDid: &repoDid, + RepoDid: (*string)(&repoDid), Spindle: &cfg.Server.Hostname, CreatedAt: time.Now().Format(time.RFC3339), } @@ -133,7 +178,7 @@ func TestProcessRepo_MembershipCheck(t *testing.T) { } } -func TestProcessPull_PushAllowedCheck(t *testing.T) { +func TestProcessPull_IsCollaboratorCheck(t *testing.T) { d, e := newTestSpindleDB(t) cfg := &config.Config{} @@ -151,6 +196,15 @@ func TestProcessPull_PushAllowedCheck(t *testing.T) { res: idresolver.DefaultResolver("https://plc.test"), jc: jc, rootCtx: context.Background(), + verify: mockRepoVerifier(repoverify.Result{ + RepoDid: "did:plc:testrepo123", + OwnerDid: "did:plc:repoowner", + Rkey: "test-repo-rkey", + KnotURL: func() *url.URL { + u, _ := url.Parse("knot.test") + return u + }(), + }), } repoOwnerDid := syntax.DID("did:plc:repoowner") @@ -158,7 +212,7 @@ func TestProcessPull_PushAllowedCheck(t *testing.T) { pusherDid := syntax.DID("did:plc:pusher") repoDid := syntax.DID("did:plc:testrepo123") - err := d.AddRepo(db.Repo{ + err := d.UpsertRepo(db.Repo{ Knot: "knot.test", Owner: repoOwnerDid, Rkey: "test-repo-rkey", @@ -169,11 +223,11 @@ func TestProcessPull_PushAllowedCheck(t *testing.T) { t.Fatalf("AddRepo: %v", err) } - err = e.AddRepo(repoOwnerDid.String(), rbac.ThisServer, repoDid.String()) + err = e.SetRepoOwner(repoOwnerDid, repoDid) if err != nil { t.Fatalf("AddRepo permissions: %v", err) } - err = e.AddCollaborator(pusherDid.String(), rbac.ThisServer, repoDid.String()) + err = e.AddRepoCollaborator(pusherDid, repoDid) if err != nil { t.Fatalf("AddCollaborator: %v", err) } @@ -242,6 +296,15 @@ func TestProcessRepo_HijackRepoDidCheck(t *testing.T) { ks: ks, jc: jc, rootCtx: context.Background(), + verify: mockRepoVerifier(repoverify.Result{ + RepoDid: "did:plc:sharedrepo", + OwnerDid: "did:plc:alice", + Rkey: "alice-repo", + KnotURL: func() *url.URL { + u, _ := url.Parse("knot.test") + return u + }(), + }), } tap := &Tap{ @@ -251,22 +314,18 @@ func TestProcessRepo_HijackRepoDidCheck(t *testing.T) { aliceDid := syntax.DID("did:plc:alice") bobDid := syntax.DID("did:plc:bob") - repoDid := "did:plc:sharedrepo" + repoDid := syntax.DID("did:plc:sharedrepo") - err := e.AddSpindle(rbac.ThisServer) - if err != nil { - t.Fatalf("AddSpindle: %v", err) - } - err = e.AddSpindleMember(rbac.ThisServer, aliceDid.String()) + err := d.AllowMember(t.Context(), aliceDid) if err != nil { t.Fatalf("AddSpindleMember alice: %v", err) } - err = e.AddSpindleMember(rbac.ThisServer, bobDid.String()) + err = d.AllowMember(t.Context(), bobDid) if err != nil { t.Fatalf("AddSpindleMember bob: %v", err) } - err = d.AddRepo(db.Repo{ + err = d.UpsertRepo(db.Repo{ Knot: "knot.test", Owner: aliceDid, Rkey: "alice-repo", @@ -277,10 +336,14 @@ func TestProcessRepo_HijackRepoDidCheck(t *testing.T) { t.Fatalf("d.AddRepo: %v", err) } + if err := e.SetRepoOwner(aliceDid, repoDid); err != nil { + t.Fatalf("SetRepoOwner: %v", err) + } + // bob tries to register alice's repo did, must reject the hijack recBob := tangled.Repo{ Knot: "knot.test", - RepoDid: &repoDid, + RepoDid: (*string)(&repoDid), Spindle: &cfg.Server.Hostname, CreatedAt: time.Now().Format(time.RFC3339), } @@ -298,151 +361,42 @@ func TestProcessRepo_HijackRepoDidCheck(t *testing.T) { t.Fatalf("processRepo returned error on duplicate repoDid hijack attempt: %v", err) } - _, err = d.GetRepoByOwnerRkey(bobDid, "bob-repo") - if err == nil { - t.Fatal("bob successfully hijacked alice's repoDid in DB, expected rejection") - } -} - -func TestProcessCollaborator_RBAC(t *testing.T) { - d, e := newTestSpindleDB(t) - - cfg := &config.Config{} - cfg.Server.Hostname = "spindle.test" - - ownerDid := syntax.DID("did:plc:repoowner") - otherDid := syntax.DID("did:plc:otheractor") - subjectDid := syntax.DID("did:plc:collabsubject") - repoDid := syntax.DID("did:plc:testrepo123") - - h, err := syntax.ParseHandle("collabsubject.test") + stored, err := d.GetRepoByDid(repoDid) if err != nil { - t.Fatalf("syntax.ParseHandle: %v", err) - } - mockIdent := &identity.Identity{ - DID: subjectDid, - Handle: h, - } - resolver := idresolver.NewMockResolver(&mockDirectory{ident: mockIdent}) - - 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, - res: resolver, - jc: jc, - rootCtx: context.Background(), + t.Fatalf("alice's repo row was destroyed by bob's hijack attempt: %v", err) } - - tap := &Tap{ - spindle: s, - logger: slog.Default(), + if stored.Owner != aliceDid || stored.Rkey != "alice-repo" { + t.Fatalf("bob hijacked alice's repoDid: owner=%s rkey=%s", stored.Owner, stored.Rkey) } - err = d.AddRepo(db.Repo{ + // bob points the same repoDid at another spindle, which must not tear alice's repo down + otherSpindle := "other.test" + recTeardown := tangled.Repo{ Knot: "knot.test", - Owner: ownerDid, - Rkey: "test-repo-rkey", - RepoDid: repoDid, + RepoDid: (*string)(&repoDid), + Spindle: &otherSpindle, CreatedAt: time.Now().Format(time.RFC3339), - }) - if err != nil { - t.Fatalf("AddRepo: %v", err) - } - - collabRecord := tangled.RepoCollaborator{ - Subject: subjectDid.String(), - Repo: repoDid.String(), - } - collabRecordJson, _ := json.Marshal(collabRecord) - - err = tap.processCollaborator(context.Background(), &tapc.RecordEventData{ - Live: true, - Did: otherDid, - Rkey: "collab-rkey-1", - Collection: syntax.NSID(tangled.RepoCollaboratorNSID), - Action: tapc.RecordCreateAction, - Record: collabRecordJson, - }) - if err != nil { - t.Fatalf("processCollaborator returned error: %v", err) - } - - _, err = d.GetRepoCollaborator(otherDid, "collab-rkey-1") - if err == nil { - t.Fatal("collaborator from non-owner was registered in DB") - } - - err = tap.processCollaborator(context.Background(), &tapc.RecordEventData{ - Live: true, - Did: ownerDid, - Rkey: "collab-rkey-2", - Collection: syntax.NSID(tangled.RepoCollaboratorNSID), - Action: tapc.RecordCreateAction, - Record: collabRecordJson, - }) - if err != nil { - t.Fatalf("processCollaborator returned error: %v", err) - } - _, err = d.GetRepoCollaborator(ownerDid, "collab-rkey-2") - if err == nil { - t.Fatal("collaborator registered despite missing Casbin invite permission") - } - - err = e.AddRepo(ownerDid.String(), rbac.ThisServer, repoDid.String()) - if err != nil { - t.Fatalf("AddRepo permissions: %v", err) } + recTeardownJson, _ := json.Marshal(recTeardown) - err = tap.processCollaborator(context.Background(), &tapc.RecordEventData{ + err = tap.processRepo(context.Background(), &tapc.RecordEventData{ Live: true, - Did: ownerDid, - Rkey: "collab-rkey-3", - Collection: syntax.NSID(tangled.RepoCollaboratorNSID), - Action: tapc.RecordCreateAction, - Record: collabRecordJson, + Did: bobDid, + Rkey: "bob-repo", + Collection: syntax.NSID(tangled.RepoNSID), + Action: tapc.RecordUpdateAction, + Record: recTeardownJson, }) if err != nil { - t.Fatalf("processCollaborator failed for authorized owner: %v", err) + t.Fatalf("processRepo returned error on forged teardown: %v", err) } - c, err := d.GetRepoCollaborator(ownerDid, "collab-rkey-3") - if err != nil { - t.Fatalf("GetRepoCollaborator error: %v", err) - } - if c.Subject != subjectDid || c.RepoDid != repoDid { - t.Fatalf("unexpected collaborator: %+v", c) + if _, err := d.GetRepoByDid(repoDid); err != nil { + t.Fatalf("bob tore down alice's repo by naming her repoDid: %v", err) } - - ok, err := e.IsRepoCollaborator(subjectDid.String(), rbac.ThisServer, repoDid.String()) + ok, err := e.IsRepoOwner(aliceDid, repoDid) if err != nil || !ok { - t.Fatalf("Casbin policy for collaborator missing or err: %v", err) - } - - err = tap.processCollaborator(context.Background(), &tapc.RecordEventData{ - Live: true, - Did: ownerDid, - Rkey: "collab-rkey-3", - Collection: syntax.NSID(tangled.RepoCollaboratorNSID), - Action: tapc.RecordDeleteAction, - }) - if err != nil { - t.Fatalf("delete collaborator process returned error: %v", err) - } - - _, err = d.GetRepoCollaborator(ownerDid, "collab-rkey-3") - if err == nil { - t.Fatal("collaborator DB row remained after deletion") - } - - ok, err = e.IsRepoCollaborator(subjectDid.String(), rbac.ThisServer, repoDid.String()) - if err != nil || ok { - t.Fatal("Casbin policy for collaborator remained after deletion") + t.Fatal("bob's forged teardown removed alice's owner policy") } } @@ -463,6 +417,15 @@ func TestTeardownRepo_RBAC(t *testing.T) { cfg: cfg, jc: jc, rootCtx: context.Background(), + verify: mockRepoVerifier(repoverify.Result{ + RepoDid: "did:plc:testrepo123", + OwnerDid: "did:plc:repoowner", + Rkey: "test-repo-rkey", + KnotURL: func() *url.URL { + u, _ := url.Parse("knot.test") + return u + }(), + }), } tap := &Tap{ @@ -474,7 +437,7 @@ func TestTeardownRepo_RBAC(t *testing.T) { repoDid := syntax.DID("did:plc:testrepo123") collabDid := syntax.DID("did:plc:collab") - err := d.AddRepo(db.Repo{ + err := d.UpsertRepo(db.Repo{ Knot: "knot.test", Owner: ownerDid, Rkey: "test-repo-rkey", @@ -485,22 +448,12 @@ func TestTeardownRepo_RBAC(t *testing.T) { t.Fatalf("AddRepo DB: %v", err) } - err = e.AddRepo(ownerDid.String(), rbac.ThisServer, repoDid.String()) + err = e.SetRepoOwner(ownerDid, repoDid) if err != nil { t.Fatalf("AddRepo policy: %v", err) } - err = d.AddRepoCollaborator(db.RepoCollaborator{ - OwnerDid: ownerDid, - Rkey: "collab-rkey", - Subject: collabDid, - RepoDid: repoDid, - }) - if err != nil { - t.Fatalf("AddCollaborator DB: %v", err) - } - - err = e.AddCollaborator(collabDid.String(), rbac.ThisServer, repoDid.String()) + err = e.AddRepoCollaborator(collabDid, repoDid) if err != nil { t.Fatalf("AddCollaborator policy: %v", err) } @@ -516,25 +469,17 @@ func TestTeardownRepo_RBAC(t *testing.T) { t.Fatalf("processRepo delete returned error: %v", err) } - _, err = d.GetRepoByOwnerRkey(ownerDid, "test-repo-rkey") + _, err = d.GetRepoByDid(repoDid) if err == nil { t.Fatal("repo remained in DB after delete") } - collabs, err := d.ListCollaboratorsByRepoDid(repoDid) - if err != nil { - t.Fatalf("ListCollaboratorsByRepoDid: %v", err) - } - if len(collabs) > 0 { - t.Fatal("collaborators remained in DB after delete") - } - - ok, err := e.IsRepoOwner(ownerDid.String(), rbac.ThisServer, repoDid.String()) + ok, err := e.IsRepoOwner(ownerDid, repoDid) if err != nil || ok { t.Fatal("repo owner policy remained in Casbin after delete") } - ok, err = e.IsRepoCollaborator(collabDid.String(), rbac.ThisServer, repoDid.String()) + ok, err = e.IsRepoCollaborator(collabDid, repoDid) if err != nil || ok { t.Fatal("collaborator policy remained in Casbin after delete") } @@ -558,6 +503,15 @@ func TestProcessRepo_ForgeDeleteRejection(t *testing.T) { cfg: cfg, jc: jc, rootCtx: context.Background(), + verify: mockRepoVerifier(repoverify.Result{ + RepoDid: "did:plc:sharedrepo", + OwnerDid: "did:plc:alice", + Rkey: "test-repo-rkey", + KnotURL: func() *url.URL { + u, _ := url.Parse("knot.test") + return u + }(), + }), } tap := &Tap{ @@ -569,7 +523,7 @@ func TestProcessRepo_ForgeDeleteRejection(t *testing.T) { bobDid := syntax.DID("did:plc:bob") repoDid := syntax.DID("did:plc:sharedrepo") - err := d.AddRepo(db.Repo{ + err := d.UpsertRepo(db.Repo{ Knot: "knot.test", Owner: aliceDid, Rkey: "test-repo-rkey", @@ -580,7 +534,7 @@ func TestProcessRepo_ForgeDeleteRejection(t *testing.T) { t.Fatalf("AddRepo DB: %v", err) } - err = e.AddRepo(aliceDid.String(), rbac.ThisServer, repoDid.String()) + err = e.SetRepoOwner(aliceDid, repoDid) if err != nil { t.Fatalf("AddRepo policy: %v", err) } @@ -597,98 +551,136 @@ func TestProcessRepo_ForgeDeleteRejection(t *testing.T) { t.Fatalf("processRepo returned error on delete: %v", err) } - _, err = d.GetRepoByOwnerRkey(aliceDid, "test-repo-rkey") + _, err = d.GetRepoByDid(repoDid) if err != nil { t.Fatalf("Alice's repo was deleted or error: %v", err) } - ok, err := e.IsRepoOwner(aliceDid.String(), rbac.ThisServer, repoDid.String()) + ok, err := e.IsRepoOwner(aliceDid, repoDid) if err != nil || !ok { t.Fatal("Alice's owner policy was removed from Casbin by forged delete") } } -func TestProcessCollaborator_ForgeDeleteRejection(t *testing.T) { +func TestReconcileCollaborators(t *testing.T) { d, e := newTestSpindleDB(t) - cfg := &config.Config{} - cfg.Server.Hostname = "spindle.test" + ownerDid := syntax.DID("did:plc:owner") + repoDid := syntax.DID("did:plc:repo") + staleDid := syntax.DID("did:plc:stale") + keptDid := syntax.DID("did:plc:kept") + newDid := syntax.DID("did:plc:new") + + if err := e.SetRepoOwner(ownerDid, repoDid); err != nil { + t.Fatalf("SetRepoOwner: %v", err) + } + // spindle's view: one collaborator the knot dropped, one it still has + for _, did := range []syntax.DID{staleDid, keptDid} { + if err := e.AddRepoCollaborator(did, repoDid); err != nil { + t.Fatalf("AddRepoCollaborator(%s): %v", did, err) + } + } + + // the knot's view: keptDid and a collaborator spindle never saw. Paginated, and it lists + // the owner too - reconcile must not treat that as an explicit grant to remove. + pages := [][]string{ + {ownerDid.String(), keptDid.String()}, + {newDid.String()}, + } + var gotSubject string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/xrpc/"+tangled.RepoListCollaboratorsNSID { + http.NotFound(w, r) + return + } + gotSubject = r.URL.Query().Get("subject") + page := 0 + if c := r.URL.Query().Get("cursor"); c != "" { + page = 1 + } + out := tangled.RepoListCollaborators_Output{} + for _, s := range pages[page] { + out.Items = append(out.Items, &tangled.RepoListCollaborators_ListItem{Subject: s}) + } + if page == 0 { + next := "page2" + out.Cursor = &next + } + json.NewEncoder(w).Encode(out) + })) + defer srv.Close() + cfg := &config.Config{} + cfg.Server.Dev = true // http, not https 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, - res: idresolver.DefaultResolver("https://plc.test"), - jc: jc, - rootCtx: context.Background(), - } - tap := &Tap{ - spindle: s, + spindle: &Spindle{db: d, e: e, l: slog.Default(), cfg: cfg, jc: jc}, logger: slog.Default(), } - ownerDid := syntax.DID("did:plc:repoowner") - bobDid := syntax.DID("did:plc:bob") - collabDid := syntax.DID("did:plc:collab") - repoDid := syntax.DID("did:plc:testrepo123") + knot := strings.TrimPrefix(srv.URL, "http://") + tap.reconcileCollaborators(context.Background(), slog.Default(), knot, repoDid, ownerDid) - err := d.AddRepo(db.Repo{ - Knot: "knot.test", - Owner: ownerDid, - Rkey: "test-repo-rkey", - RepoDid: repoDid, - CreatedAt: time.Now().Format(time.RFC3339), - }) - if err != nil { - t.Fatalf("AddRepo: %v", err) + if gotSubject != repoDid.String() { + t.Errorf("knot queried with subject %q, want %q", gotSubject, repoDid) } - err = e.AddRepo(ownerDid.String(), rbac.ThisServer, repoDid.String()) - if err != nil { - t.Fatalf("AddRepo permissions: %v", err) + for _, tc := range []struct { + did syntax.DID + want bool + }{ + {ownerDid, true}, // owner keeps access via role inheritance + {keptDid, true}, // still on the knot + {newDid, true}, // added from the knot's roster + {staleDid, false}, // removed on the knot while spindle was down + } { + ok, err := e.IsRepoCollaborator(tc.did, repoDid) + if err != nil { + t.Fatalf("IsRepoCollaborator(%s): %v", tc.did, err) + } + if ok != tc.want { + t.Errorf("IsRepoCollaborator(%s) = %v, want %v", tc.did, ok, tc.want) + } } +} - err = d.AddRepoCollaborator(db.RepoCollaborator{ - OwnerDid: ownerDid, - Rkey: "collab-rkey", - Subject: collabDid, - RepoDid: repoDid, - }) - if err != nil { +func TestReconcileCollaboratorsKeepsGrantsOnFetchFailure(t *testing.T) { + d, e := newTestSpindleDB(t) + + ownerDid := syntax.DID("did:plc:owner") + repoDid := syntax.DID("did:plc:repo") + collabDid := syntax.DID("did:plc:collab") + + if err := e.SetRepoOwner(ownerDid, repoDid); err != nil { + t.Fatalf("SetRepoOwner: %v", err) + } + if err := e.AddRepoCollaborator(collabDid, repoDid); err != nil { t.Fatalf("AddRepoCollaborator: %v", err) } - err = e.AddCollaborator(collabDid.String(), rbac.ThisServer, repoDid.String()) - if err != nil { - t.Fatalf("AddCollaborator policy: %v", err) - } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() - // bob tries to delete alice's collaborator, must reject forged delete - err = tap.processCollaborator(context.Background(), &tapc.RecordEventData{ - Live: true, - Did: bobDid, - Rkey: "collab-rkey", - Collection: syntax.NSID(tangled.RepoCollaboratorNSID), - Action: tapc.RecordDeleteAction, - }) - if err != nil { - t.Fatalf("processCollaborator delete returned error: %v", err) + cfg := &config.Config{} + cfg.Server.Dev = true + tap := &Tap{ + spindle: &Spindle{db: d, e: e, l: slog.Default(), cfg: cfg}, + logger: slog.Default(), } - _, err = d.GetRepoCollaborator(ownerDid, "collab-rkey") + tap.reconcileCollaborators(context.Background(), slog.Default(), + strings.TrimPrefix(srv.URL, "http://"), repoDid, ownerDid) + + ok, err := e.IsRepoCollaborator(collabDid, repoDid) if err != nil { - t.Fatalf("collaborator was deleted from DB: %v", err) + t.Fatalf("IsRepoCollaborator: %v", err) } - - ok, err := e.IsRepoCollaborator(collabDid.String(), rbac.ThisServer, repoDid.String()) - if err != nil || !ok { - t.Fatal("collaborator policy was removed from Casbin by forged delete") + if !ok { + t.Error("an unreachable knot wiped the collaborator roster") } } diff --git a/spindle/xrpc/xrpc_test.go b/spindle/xrpc/xrpc_test.go index 9aec07e58..389609595 100644 --- a/spindle/xrpc/xrpc_test.go +++ b/spindle/xrpc/xrpc_test.go @@ -16,7 +16,7 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/tangled" "tangled.org/core/idresolver" - "tangled.org/core/rbac" + "tangled.org/core/rbac/v2" "tangled.org/core/spindle/config" "tangled.org/core/spindle/db" "tangled.org/core/spindle/models" @@ -44,7 +44,6 @@ func newTestXrpcDB(t *testing.T) (*db.DB, *rbac.Enforcer) { if err != nil { t.Fatalf("rbac.NewEnforcer: %v", err) } - e.E.EnableAutoSave(true) return d, e } @@ -56,7 +55,7 @@ func TestTriggerPipeline_RBAC(t *testing.T) { pusherDid := syntax.DID("did:plc:pusher") repoDid := syntax.DID("did:plc:testrepo123") - err := d.AddRepo(db.Repo{ + err := d.UpsertRepo(db.Repo{ Knot: "knot.test", Owner: repoOwnerDid, Rkey: "test-repo-rkey", @@ -67,11 +66,11 @@ func TestTriggerPipeline_RBAC(t *testing.T) { t.Fatalf("AddRepo: %v", err) } - err = e.AddRepo(repoOwnerDid.String(), rbac.ThisServer, repoDid.String()) + err = e.SetRepoOwner(repoOwnerDid, repoDid) if err != nil { t.Fatalf("AddRepo permissions: %v", err) } - err = e.AddCollaborator(pusherDid.String(), rbac.ThisServer, repoDid.String()) + err = e.AddRepoCollaborator(pusherDid, repoDid) if err != nil { t.Fatalf("AddCollaborator: %v", err) } @@ -149,7 +148,7 @@ func TestCancelPipeline_RBAC(t *testing.T) { pusherDid := syntax.DID("did:plc:pusher") repoDid := syntax.DID("did:plc:testrepo123") - err := d.AddRepo(db.Repo{ + err := d.UpsertRepo(db.Repo{ Knot: "knot.test", Owner: repoOwnerDid, Rkey: "test-repo-rkey", @@ -160,11 +159,11 @@ func TestCancelPipeline_RBAC(t *testing.T) { t.Fatalf("AddRepo: %v", err) } - err = e.AddRepo(repoOwnerDid.String(), rbac.ThisServer, repoDid.String()) + err = e.SetRepoOwner(repoOwnerDid, repoDid) if err != nil { t.Fatalf("AddRepo permissions: %v", err) } - err = e.AddCollaborator(pusherDid.String(), rbac.ThisServer, repoDid.String()) + err = e.AddRepoCollaborator(pusherDid, repoDid) if err != nil { t.Fatalf("AddCollaborator: %v", err) } @@ -260,7 +259,7 @@ func TestSecrets_RBAC(t *testing.T) { pusherDid := syntax.DID("did:plc:pusher") repoDid := syntax.DID("did:plc:testrepo123") - err := d.AddRepo(db.Repo{ + err := d.UpsertRepo(db.Repo{ Knot: "knot.test", Owner: repoOwnerDid, Rkey: "test-repo-rkey", @@ -271,11 +270,11 @@ func TestSecrets_RBAC(t *testing.T) { t.Fatalf("AddRepo: %v", err) } - err = e.AddRepo(repoOwnerDid.String(), rbac.ThisServer, repoDid.String()) + err = e.SetRepoOwner(repoOwnerDid, repoDid) if err != nil { t.Fatalf("AddRepo permissions: %v", err) } - err = e.AddCollaborator(pusherDid.String(), rbac.ThisServer, repoDid.String()) + err = e.AddRepoCollaborator(pusherDid, repoDid) if err != nil { t.Fatalf("AddCollaborator: %v", err) } diff --git a/tapc/tap.go b/tapc/tap.go index c44ca19b5..712798089 100644 --- a/tapc/tap.go +++ b/tapc/tap.go @@ -41,6 +41,9 @@ func NewClient(url, adminPassword string) Client { } func (c *Client) AddRepos(ctx context.Context, dids []syntax.DID) error { + if len(dids) == 0 { + return nil + } body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) if err != nil { return err