auth_test.go
6376 bytes
1package cmd
2
3import (
4 "errors"
5 "testing"
6
7 "github.com/charmbracelet/soft-serve/pkg/access"
8 "github.com/charmbracelet/soft-serve/pkg/proto"
9 "github.com/matryer/is"
10)
11
12// TestGlobalCommandsIgnoreRepositoryAccess is the regression test for the
13// privilege escalation where a non-admin could run global admin commands by
14// creating a repository named after the command's first argument. `user
15// set-admin victim true` used to be authorized by the caller's admin access
16// to a repository they happened to own called "victim".
17func TestGlobalCommandsIgnoreRepositoryAccess(t *testing.T) {
18 is := is.New(t)
19 ctx, be := newAuthTestContext(t)
20 attackerCtx := withUser(t, ctx, be, "attacker", false)
21
22 // The attacker owns a repository named "victim", so they have
23 // AdminAccess *to that repository*.
24 attacker := proto.UserFromContext(attackerCtx)
25 _, err := be.CreateRepository(attackerCtx, "victim", attacker, proto.RepositoryOptions{})
26 is.NoErr(err)
27 is.Equal(be.AccessLevelForUser(attackerCtx, "victim", attacker), access.AdminAccess)
28
29 // Repository admin access must not authorize global user commands.
30 for _, args := range [][]string{
31 {"set-admin", "victim", "true"},
32 {"create", "victim2"},
33 {"delete", "victim"},
34 {"list"},
35 {"info", "victim"},
36 {"set-username", "victim", "victim3"},
37 } {
38 if err := runUser(t, attackerCtx, args...); !errors.Is(err, proto.ErrUnauthorized) {
39 t.Errorf("user %v: expected ErrUnauthorized, got %v", args, err)
40 }
41 }
42
43 // The attacker must not have become an admin.
44 reloaded, err := be.User(ctx, "attacker")
45 is.NoErr(err)
46 is.Equal(reloaded.IsAdmin(), false)
47}
48
49// TestGlobalCommandsIgnoreAnonAdminAccess covers the variant that needs no
50// repository at all: with anon-access set to admin-access,
51// AccessLevelForUser returns AdminAccess to any authenticated user for a
52// nonexistent repository name.
53func TestGlobalCommandsIgnoreAnonAdminAccess(t *testing.T) {
54 is := is.New(t)
55 ctx, be := newAuthTestContext(t)
56 is.NoErr(be.SetAnonAccess(ctx, access.AdminAccess))
57
58 attackerCtx := withUser(t, ctx, be, "attacker", false)
59 attacker := proto.UserFromContext(attackerCtx)
60 is.Equal(be.AccessLevelForUser(attackerCtx, "nonexistent", attacker), access.AdminAccess)
61
62 err := runUser(t, attackerCtx, "set-admin", "attacker", "true")
63 if !errors.Is(err, proto.ErrUnauthorized) {
64 t.Fatalf("expected ErrUnauthorized, got %v", err)
65 }
66
67 reloaded, err := be.User(ctx, "attacker")
68 is.NoErr(err)
69 is.Equal(reloaded.IsAdmin(), false)
70}
71
72// TestUserSubcommandsAreGatedByParent verifies the gate lives on the `user`
73// parent command, so subcommands cannot be left unprotected by omission.
74func TestUserSubcommandsAreGatedByParent(t *testing.T) {
75 is := is.New(t)
76 c := UserCommand()
77 is.True(c.PersistentPreRunE != nil)
78
79 for _, sub := range c.Commands() {
80 if sub.PersistentPreRunE != nil {
81 t.Errorf("subcommand %q sets its own PersistentPreRunE; the parent gate is authoritative", sub.Name())
82 }
83 }
84}
85
86// TestSettingsSubcommandsAreGatedByParent is the settings counterpart.
87func TestSettingsSubcommandsAreGatedByParent(t *testing.T) {
88 is := is.New(t)
89 c := SettingsCommand()
90 is.True(c.PersistentPreRunE != nil)
91
92 for _, sub := range c.Commands() {
93 if sub.PersistentPreRunE != nil {
94 t.Errorf("subcommand %q sets its own PersistentPreRunE; the parent gate is authoritative", sub.Name())
95 }
96 }
97}
98
99// TestCollabAddCannotExceedCallerAccess verifies a read-write collaborator
100// cannot grant admin-access, which would escalate beyond their own level.
101func TestCollabAddCannotExceedCallerAccess(t *testing.T) {
102 is := is.New(t)
103 ctx, be := newAuthTestContext(t)
104
105 ownerCtx := withUser(t, ctx, be, "owner", false)
106 owner := proto.UserFromContext(ownerCtx)
107 _, err := be.CreateRepository(ownerCtx, "repo", owner, proto.RepositoryOptions{})
108 is.NoErr(err)
109
110 collabCtx := withUser(t, ctx, be, "collab", false)
111 _ = withUser(t, ctx, be, "puppet", false)
112 is.NoErr(be.AddCollaborator(ownerCtx, "repo", "collab", access.ReadWriteAccess))
113 is.Equal(be.AccessLevelForUser(collabCtx, "repo", proto.UserFromContext(collabCtx)), access.ReadWriteAccess)
114
115 // Granting above the caller's own level must be refused.
116 err = runRepo(t, collabCtx, "collab", "add", "repo", "puppet", "admin-access")
117 if !errors.Is(err, proto.ErrExceedsAccessLevel) {
118 t.Fatalf("expected ErrExceedsAccessLevel, got %v", err)
119 }
120
121 _, isCollab, _ := be.IsCollaborator(ctx, "repo", "puppet")
122 is.Equal(isCollab, false)
123
124 // Granting at or below the caller's own level is still allowed.
125 is.NoErr(runRepo(t, collabCtx, "collab", "add", "repo", "puppet", "read-only"))
126 level, isCollab, err := be.IsCollaborator(ctx, "repo", "puppet")
127 is.NoErr(err)
128 is.True(isCollab)
129 is.Equal(level, access.ReadOnlyAccess)
130}
131
132// TestCollabRemoveCannotDemoteHigherAccess verifies a read-write collaborator
133// cannot remove an admin-access collaborator. Without this, removal plus
134// re-adding at a lower level is a demotion primitive that sidesteps the cap
135// on granting.
136func TestCollabRemoveCannotDemoteHigherAccess(t *testing.T) {
137 is := is.New(t)
138 ctx, be := newAuthTestContext(t)
139
140 ownerCtx := withUser(t, ctx, be, "owner", false)
141 owner := proto.UserFromContext(ownerCtx)
142 _, err := be.CreateRepository(ownerCtx, "repo", owner, proto.RepositoryOptions{})
143 is.NoErr(err)
144
145 collabCtx := withUser(t, ctx, be, "collab", false)
146 _ = withUser(t, ctx, be, "boss", false)
147 is.NoErr(be.AddCollaborator(ownerCtx, "repo", "collab", access.ReadWriteAccess))
148 is.NoErr(be.AddCollaborator(ownerCtx, "repo", "boss", access.AdminAccess))
149
150 err = runRepo(t, collabCtx, "collab", "remove", "repo", "boss")
151 if !errors.Is(err, proto.ErrExceedsAccessLevel) {
152 t.Fatalf("expected ErrExceedsAccessLevel, got %v", err)
153 }
154
155 level, isCollab, err := be.IsCollaborator(ctx, "repo", "boss")
156 is.NoErr(err)
157 is.True(isCollab)
158 is.Equal(level, access.AdminAccess)
159
160 // Overwriting a higher-level collaborator via `add` is refused too.
161 err = runRepo(t, collabCtx, "collab", "add", "repo", "boss", "read-only")
162 if !errors.Is(err, proto.ErrExceedsAccessLevel) {
163 t.Fatalf("expected ErrExceedsAccessLevel on add-overwrite, got %v", err)
164 }
165
166 // Removing a peer at or below the caller's level still works.
167 _ = withUser(t, ctx, be, "peer", false)
168 is.NoErr(be.AddCollaborator(ownerCtx, "repo", "peer", access.ReadOnlyAccess))
169 is.NoErr(runRepo(t, collabCtx, "collab", "remove", "repo", "peer"))
170}