Diff
1diff --git a/pkg/backend/user.go b/pkg/backend/user.go
2index 736842f9fdddbc7fc83501ad02da1c5bd5f063f9..aa93aeb10177eb4500c6f8a9bbe3dd3e3ef7814a 100644
3--- a/pkg/backend/user.go
4+++ b/pkg/backend/user.go
5@@ -70,9 +70,12 @@ func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user prot
6 }
7
8 // If the user is a collaborator, they have return their access level.
9+ // On a private repository the collaborator's level is authoritative;
10+ // the anonymous floor only applies to public repositories, otherwise
11+ // a no-access collaborator would be granted read access.
12 collabAccess, isCollab, _ := d.IsCollaborator(ctx, repo, username)
13 if isCollab {
14- if anon > collabAccess {
15+ if !r.IsPrivate() && anon > collabAccess {
16 return anon
17 }
18 return collabAccess
19diff --git a/pkg/backend/user_test.go b/pkg/backend/user_test.go
20new file mode 100644
21index 0000000000000000000000000000000000000000..7140725d1e06fc8f5ff2193ccfcd251e97d71f38
22--- /dev/null
23+++ b/pkg/backend/user_test.go
24@@ -0,0 +1,69 @@
25+package backend
26+
27+import (
28+ "context"
29+ "testing"
30+
31+ "github.com/charmbracelet/soft-serve/pkg/access"
32+ "github.com/charmbracelet/soft-serve/pkg/db"
33+ "github.com/charmbracelet/soft-serve/pkg/proto"
34+ "github.com/matryer/is"
35+)
36+
37+// TestAccessLevelCollaboratorPrivateRepo verifies that the anonymous access
38+// floor does not raise a collaborator's explicit level on a private
39+// repository, while still applying on public repositories.
40+func TestAccessLevelCollaboratorPrivateRepo(t *testing.T) {
41+ tests := []struct {
42+ name string
43+ private bool
44+ anon access.AccessLevel
45+ collab access.AccessLevel
46+ want access.AccessLevel
47+ }{
48+ {"private no-access collab, anon read-only", true, access.ReadOnlyAccess, access.NoAccess, access.NoAccess},
49+ {"private read-only collab, anon read-write", true, access.ReadWriteAccess, access.ReadOnlyAccess, access.ReadOnlyAccess},
50+ {"private read-write collab, anon read-only", true, access.ReadOnlyAccess, access.ReadWriteAccess, access.ReadWriteAccess},
51+ {"public no-access collab, anon read-only", false, access.ReadOnlyAccess, access.NoAccess, access.ReadOnlyAccess},
52+ {"public read-only collab, anon read-write", false, access.ReadWriteAccess, access.ReadOnlyAccess, access.ReadWriteAccess},
53+ {"public no-access collab, anon no-access", false, access.NoAccess, access.NoAccess, access.NoAccess},
54+ }
55+
56+ for _, tt := range tests {
57+ t.Run(tt.name, func(t *testing.T) {
58+ is := is.New(t)
59+ be, _ := newTestBackend(t)
60+ ctx := context.Background()
61+
62+ is.NoErr(be.SetAnonAccess(ctx, tt.anon))
63+
64+ _, err := be.CreateRepository(ctx, "repo", nil, proto.RepositoryOptions{Private: tt.private})
65+ is.NoErr(err)
66+
67+ user, err := be.CreateUser(ctx, "collab", proto.UserOptions{})
68+ is.NoErr(err)
69+ is.NoErr(be.db.TransactionContext(ctx, func(tx *db.Tx) error {
70+ return be.store.AddCollabByUsernameAndRepo(ctx, tx, "collab", "repo", tt.collab)
71+ }))
72+
73+ is.Equal(be.AccessLevelForUser(ctx, "repo", user), tt.want)
74+ })
75+ }
76+}
77+
78+// TestAccessLevelPrivateRepoNonCollaborator verifies that a signed-in user
79+// who is not a collaborator has no access to a private repository.
80+func TestAccessLevelPrivateRepoNonCollaborator(t *testing.T) {
81+ is := is.New(t)
82+ be, _ := newTestBackend(t)
83+ ctx := context.Background()
84+
85+ _, err := be.CreateRepository(ctx, "repo", nil, proto.RepositoryOptions{Private: true})
86+ is.NoErr(err)
87+
88+ user, err := be.CreateUser(ctx, "stranger", proto.UserOptions{})
89+ is.NoErr(err)
90+
91+ is.Equal(be.AccessLevelForUser(ctx, "repo", user), access.NoAccess)
92+ is.Equal(be.AccessLevelForUser(ctx, "repo", nil), access.NoAccess)
93+}
94diff --git a/testscript/testdata/repo-collab.txtar b/testscript/testdata/repo-collab.txtar
95index 9a1f75eed8d5008aeaec87d26e2ba61f0cf26260..3cc4edf8ea5767ced59f79a75c9eef675d20f282 100644
96--- a/testscript/testdata/repo-collab.txtar
97+++ b/testscript/testdata/repo-collab.txtar
98@@ -35,6 +35,17 @@ stderr '.*already exists.*'
99 # a placeholder to reset stderr
100 soft help
101
102+# a no-access collaborator cannot read a private repo (GHSA-pmgj-8mr6-9rff)
103+soft repo create secret -p
104+! usoft repo description secret
105+stderr '.*repository not found.*'
106+soft repo collab add secret foo no-access
107+! usoft repo description secret
108+stderr '.*repository not found.*'
109+soft repo collab remove secret foo
110+soft repo collab add secret foo read-only
111+usoft repo description secret
112+
113 # stop the server
114 [windows] stopserver
115 [windows] ! stderr .