Parent directory

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}