4f07b43ed37faea22624e1f330f4554fdedb99f5

Author
Matthew Greenwald <mdgreenwald@gmail.com>
Committer
Christian Rocha <christian@rocha.is>
Date

Message

feat: anon and keyless access

Signed-off-by: Matthew Greenwald <mdgreenwald@gmail.com>

Diff

This diff is truncated to protect this page.

  1diff --git a/README.md b/README.md
  2index cfe4043523bc139b5c5f0a1f22356c6d6d10689c..4b30c24454f3c88e6012e701949aea12fd0637eb 100644
  3--- a/README.md
  4+++ b/README.md
  5@@ -260,6 +260,15 @@ stats:
  6 # Additional admin keys.
  7 #initial_admin_keys:
  8 #  - "ssh-rsa AAAAB3NzaC1yc2..."
  9+
 10+# Override the anon-access and allow-keyless settings below (normally
 11+# managed at runtime via the `settings` command and stored in the
 12+# database). If set, these always take precedence over the database, for
 13+# as long as they remain set -- not just on first run. This is intended for
 14+# scripted, non-production bootstrapping (e.g. spinning up a throwaway
 15+# server for local dev tooling), not for production use.
 16+#anon_access: "admin-access"
 17+#allow_keyless: true
 18 ```
 19 
 20 You can also use environment variables, to override these settings. All server
 21@@ -272,6 +281,8 @@ name all in uppercase. Here are some examples:
 22 - `SOFT_SERVE_HTTP_LISTEN_ADDR`: HTTP listen address
 23 - `SOFT_SERVE_HTTP_PUBLIC_URL`: HTTP public URL used for cloning
 24 - `SOFT_SERVE_GIT_MAX_CONNECTIONS`: The number of simultaneous connections to git daemon
 25+- `SOFT_SERVE_ANON_ACCESS`: Overrides the `anon-access` setting (see [Authentication](#authentication))
 26+- `SOFT_SERVE_ALLOW_KEYLESS`: Overrides the `allow-keyless` setting (see [Authentication](#authentication))
 27 
 28 #### Database Configuration
 29 
 30@@ -396,6 +407,37 @@ SSH Public Key authentication `read-only` access to public repos.
 31 `anon-access` is also used in combination with `allow-keyless` to determine the
 32 access level for HTTP(s) and git:// clone requests.
 33 
 34+#### Local/dev bootstrap
 35+
 36+`anon-access` and `allow-keyless` are normally admin-only runtime settings,
 37+which means scripting a fully open server from a cold start would otherwise
 38+require booting with an admin key, waiting for the server to come up, then
 39+SSHing in as that admin to run `settings` commands. For local development
 40+tooling (e.g. standing up a throwaway Git remote for something like Argo CD
 41+to pull from in a dev environment), you can instead set the `SOFT_SERVE_ANON_ACCESS`
 42+and `SOFT_SERVE_ALLOW_KEYLESS` environment variables (or the equivalent
 43+`anon_access`/`allow_keyless` `config.yaml` fields) to grant full,
 44+unauthenticated access from the moment the server starts, with no admin key
 45+and no `settings` command required:
 46+
 47+```sh
 48+SOFT_SERVE_ANON_ACCESS=admin-access \
 49+SOFT_SERVE_ALLOW_KEYLESS=true \
 50+  soft serve
 51+```
 52+
 53+> **Warning** This grants anyone who can reach the server full admin access
 54+> with no authentication at all. It is intended strictly for local/dev use on
 55+> a machine or network you trust — never expose a server configured this way
 56+> to an untrusted network. The server logs a warning banner on startup
 57+> whenever this combination is active.
 58+>
 59+> Unlike the `settings` command, these overrides always take precedence over
 60+> the database for as long as they're set — not just on first run. If you
 61+> also try to tighten access via `ssh soft settings ...` while an override is
 62+> active, the change is saved but has no effect until the override is
 63+> removed; the `settings` command will warn you when this happens.
 64+
 65 #### SSH
 66 
 67 Soft Serve doesn't allow duplicate SSH public keys for users. A public key can be associated with one user only. This makes SSH authentication simple and straight forward, add your public key to your Soft Serve user to be able to access Soft Serve.
 68diff --git a/cmd/soft/serve/server.go b/cmd/soft/serve/server.go
 69index fda09005097dce89960b6cd6af26431ceca54c32..2fb8e02225bbca5ed9cce01badcb348a3618d22a 100644
 70--- a/cmd/soft/serve/server.go
 71+++ b/cmd/soft/serve/server.go
 72@@ -9,6 +9,7 @@ import (
 73 
 74 	"charm.land/log/v2"
 75 
 76+	"github.com/charmbracelet/soft-serve/pkg/access"
 77 	"github.com/charmbracelet/soft-serve/pkg/backend"
 78 	"github.com/charmbracelet/soft-serve/pkg/config"
 79 	"github.com/charmbracelet/soft-serve/pkg/cron"
 80@@ -100,9 +101,30 @@ func NewServer(ctx context.Context) (*Server, error) {
 81 		})
 82 	}
 83 
 84+	warnIfAnonAdminAccess(ctx, be, logger)
 85+
 86 	return srv, nil
 87 }
 88 
 89+// warnIfAnonAdminAccess logs a loud warning if the server's effective,
 90+// post-override settings grant unauthenticated (keyless) connections admin
 91+// access. This checks effective runtime state via the backend, not just the
 92+// new config-override fields, since the same risk exists whether the
 93+// dangerous combination came from config or was set via `ssh soft settings`
 94+// on a previous run.
 95+func warnIfAnonAdminAccess(ctx context.Context, be *backend.Backend, logger *log.Logger) {
 96+	if !be.AllowKeyless(ctx) || be.AnonAccess(ctx) < access.AdminAccess {
 97+		return
 98+	}
 99+
100+	logger.Warn("################################################################")
101+	logger.Warn("# WARNING: anonymous keyless connections have ADMIN access.     #")
102+	logger.Warn("# Anyone who can reach this server has full control, no auth.   #")
103+	logger.Warn("# This is intended for local/dev use only. Do not expose this   #")
104+	logger.Warn("# server to an untrusted network.                               #")
105+	logger.Warn("################################################################")
106+}
107+
108 // ReloadCertificates reloads the TLS certificates for the HTTP server.
109 func (s *Server) ReloadCertificates() error {
110 	if s.CertLoader == nil {
111diff --git a/pkg/backend/repo.go b/pkg/backend/repo.go
112index d92ac4d094114bcd54f5e84d87b63b483f5426d7..f151b7110476faa85de3e95982691112ee4deea2 100644
113--- a/pkg/backend/repo.go
114+++ b/pkg/backend/repo.go
115@@ -51,6 +51,20 @@ func (d *Backend) CreateRepository(ctx context.Context, name string, user proto.
116 	}
117 
118 	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
119+		if userID == 0 {
120+			// Anonymous callers (e.g. an anon-access/allow-keyless
121+			// override) have no user of their own, but repos.user_id is
122+			// NOT NULL. Fall back to the default admin as owner, the same
123+			// "ownerless repos belong to the first admin" idiom already
124+			// used when migrating repos from the pre-user schema (see
125+			// 0001_create_tables.go).
126+			adminID, err := d.defaultAdminUserID(ctx, tx)
127+			if err != nil {
128+				return err
129+			}
130+			userID = adminID
131+		}
132+
133 		if err := d.store.CreateRepo(
134 			ctx,
135 			tx,
136@@ -97,6 +111,31 @@ func (d *Backend) CreateRepository(ctx context.Context, name string, user proto.
137 	return d.Repository(ctx, name)
138 }
139 
140+// defaultAdminUserID returns the ID of the lowest-ID admin user, used to
141+// own repositories created by callers with no user of their own (anonymous
142+// access via an anon-access/allow-keyless override). The migration that
143+// seeds the initial database always creates an admin user, so this should
144+// never fail to find one in practice.
145+func (d *Backend) defaultAdminUserID(ctx context.Context, tx *db.Tx) (int64, error) {
146+	users, err := d.store.GetAllUsers(ctx, tx)
147+	if err != nil {
148+		return 0, err
149+	}
150+
151+	var adminID int64
152+	for _, u := range users {
153+		if u.Admin && (adminID == 0 || u.ID < adminID) {
154+			adminID = u.ID
155+		}
156+	}
157+
158+	if adminID == 0 {
159+		return 0, errors.New("no admin user found to own anonymous repository")
160+	}
161+
162+	return adminID, nil
163+}
164+
165 // ImportRepository imports a repository from remote.
166 // XXX: This a expensive operation and should be run in a goroutine.
167 func (d *Backend) ImportRepository(_ context.Context, name string, user proto.User, remote string, opts proto.RepositoryOptions) (proto.Repository, error) {
168diff --git a/pkg/backend/settings.go b/pkg/backend/settings.go
169index 3b7ddbf186e3450747d67475e536b8c296545a32..c78cf25f4c9044b632fa9c391a769be4494e8c1a 100644
170--- a/pkg/backend/settings.go
171+++ b/pkg/backend/settings.go
172@@ -9,8 +9,18 @@ import (
173 
174 // AllowKeyless returns whether or not keyless access is allowed.
175 //
176+// If the server config sets AllowKeyless, that value always takes
177+// precedence over the database — it is a live override, not a one-time
178+// default, matching how InitialAdminKeys behaves. Do not cache this: a
179+// memoized/TTL'd copy would delay an admin's `settings allow-keyless false`
180+// from taking effect, which is a fail-open risk on an access-control check.
181+//
182 // It implements backend.Backend.
183 func (b *Backend) AllowKeyless(ctx context.Context) bool {
184+	if b.cfg.AllowKeyless != nil {
185+		return *b.cfg.AllowKeyless
186+	}
187+
188 	var allow bool
189 	if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error {
190 		var err error
191@@ -34,8 +44,18 @@ func (b *Backend) SetAllowKeyless(ctx context.Context, allow bool) error {
192 
193 // AnonAccess returns the level of anonymous access.
194 //
195+// If the server config sets AnonAccess, that value always takes precedence
196+// over the database — it is a live override, not a one-time default,
197+// matching how InitialAdminKeys behaves. Do not cache this: a memoized/TTL'd
198+// copy would delay an admin's `settings anon-access` change from taking
199+// effect, which is a fail-open risk on an access-control check.
200+//
201 // It implements backend.Backend.
202 func (b *Backend) AnonAccess(ctx context.Context) access.AccessLevel {
203+	if b.cfg.AnonAccess != "" {
204+		return access.ParseAccessLevel(b.cfg.AnonAccess)
205+	}
206+
207 	var level access.AccessLevel
208 	if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error {
209 		var err error
210diff --git a/pkg/backend/settings_test.go b/pkg/backend/settings_test.go
211new file mode 100644
212index 0000000000000000000000000000000000000000..bba2b93fb62f743dfc88c06fb5aef328083ec47c
213--- /dev/null
214+++ b/pkg/backend/settings_test.go
215@@ -0,0 +1,94 @@
216+package backend
217+
218+import (
219+	"context"
220+	"testing"
221+
222+	"github.com/charmbracelet/soft-serve/pkg/access"
223+	"github.com/charmbracelet/soft-serve/pkg/config"
224+	"github.com/charmbracelet/soft-serve/pkg/db"
225+	"github.com/charmbracelet/soft-serve/pkg/db/migrate"
226+	"github.com/charmbracelet/soft-serve/pkg/store"
227+	"github.com/charmbracelet/soft-serve/pkg/store/database"
228+	"github.com/matryer/is"
229+	_ "modernc.org/sqlite"
230+)
231+
232+// newTestBackend returns a Backend backed by a real, freshly migrated SQLite
233+// database, along with the *config.Config it was constructed with (so tests
234+// can mutate AnonAccess/AllowKeyless directly to simulate config overrides).
235+func newTestBackend(t *testing.T) (*Backend, *config.Config) {
236+	t.Helper()
237+	is := is.New(t)
238+	ctx := context.Background()
239+
240+	dp := t.TempDir()
241+	cfg := config.DefaultConfig()
242+	cfg.DataPath = dp
243+	cfg.DB.Driver = "sqlite"
244+	cfg.DB.DataSource = dp + "/test.db"
245+
246+	ctx = config.WithContext(ctx, cfg)
247+	dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
248+	is.NoErr(err)
249+	t.Cleanup(func() { dbx.Close() }) //nolint:errcheck
250+
251+	is.NoErr(migrate.Migrate(ctx, dbx))
252+	dbstore := database.New(ctx, dbx)
253+	ctx = store.WithContext(ctx, dbstore)
254+	be := New(ctx, cfg, dbx, dbstore)
255+
256+	return be, cfg
257+}
258+
259+func TestAnonAccessConfigOverride(t *testing.T) {
260+	is := is.New(t)
261+	be, cfg := newTestBackend(t)
262+	ctx := context.Background()
263+
264+	// Seeded default from migration, no override set.
265+	is.Equal(be.AnonAccess(ctx), access.ReadOnlyAccess)
266+
267+	// With no override, DB writes via SetAnonAccess are reflected.
268+	is.NoErr(be.SetAnonAccess(ctx, access.ReadWriteAccess))
269+	is.Equal(be.AnonAccess(ctx), access.ReadWriteAccess)
270+
271+	// A config override takes precedence over whatever is in the DB.
272+	cfg.AnonAccess = access.AdminAccess.String()
273+	is.Equal(be.AnonAccess(ctx), access.AdminAccess)
274+
275+	// The DB value is unchanged underneath the override: once the override
276+	// is cleared, the last DB write is what's returned again.
277+	cfg.AnonAccess = ""
278+	is.Equal(be.AnonAccess(ctx), access.ReadWriteAccess)
279+}
280+
281+func TestAllowKeylessConfigOverride(t *testing.T) {
282+	is := is.New(t)
283+	be, cfg := newTestBackend(t)
284+	ctx := context.Background()
285+
286+	// Seeded default from migration, no override set.
287+	is.Equal(be.AllowKeyless(ctx), true)
288+
289+	// With no override, DB writes via SetAllowKeyless are reflected.
290+	is.NoErr(be.SetAllowKeyless(ctx, false))
291+	is.Equal(be.AllowKeyless(ctx), false)
292+
293+	// A config override of true takes precedence over a DB value of false.
294+	allow := true
295+	cfg.AllowKeyless = &allow
296+	is.Equal(be.AllowKeyless(ctx), true)
297+
298+	// A config override of false takes precedence over a DB value of true
299+	// too — this is a real tri-state override, not just a way to force
300+	// things open.
301+	is.NoErr(be.SetAllowKeyless(ctx, true))
302+	disallow := false
303+	cfg.AllowKeyless = &disallow
304+	is.Equal(be.AllowKeyless(ctx), false)
305+
306+	// Clearing the override falls back to the DB value again.
307+	cfg.AllowKeyless = nil
308+	is.Equal(be.AllowKeyless(ctx), true)
309+}
310diff --git a/pkg/config/config.go b/pkg/config/config.go
311index 97a5dc43d41bebfee0cffe915da41f4148ece95f..288c831ce307979ad8892fd135570285116a99c9 100644
312--- a/pkg/config/config.go
313+++ b/pkg/config/config.go
314@@ -9,6 +9,7 @@ import (
315 	"time"
316 
317 	"github.com/caarlos0/env/v11"
318+	"github.com/charmbracelet/soft-serve/pkg/access"
319 	"github.com/charmbracelet/soft-serve/pkg/sshutils"
320 	"golang.org/x/crypto/ssh"
321 	"gopkg.in/yaml.v3"
322@@ -171,6 +172,24 @@ type Config struct {
323 	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
324 	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
325 
326+	// AnonAccess overrides the access level for anonymous users.
327+	//
328+	// If set, this takes precedence over the "anon-access" value stored in
329+	// the database (see the `settings` command) on every read, for as long
330+	// as it remains set. It is not a one-time default: it behaves like
331+	// InitialAdminKeys, not like a seed value. This is intended for
332+	// scripted, non-production bootstrapping only; it is not validated for
333+	// safety beyond being a recognized access level.
334+	AnonAccess string `env:"ANON_ACCESS" yaml:"anon_access"`
335+
336+	// AllowKeyless overrides whether keyless (no public key) connections
337+	// are allowed.
338+	//
339+	// If set (non-nil), this takes precedence over the "allow-keyless"
340+	// value stored in the database on every read, same as AnonAccess above.
341+	// A nil value means "no override": fall back to the database.
342+	AllowKeyless *bool `env:"ALLOW_KEYLESS" yaml:"allow_keyless"`
343+
344 	// DataPath is the path to the directory where Soft Serve will store its data.
345 	DataPath string `env:"DATA_PATH" yaml:"-"`
346 }
347@@ -190,6 +209,7 @@ func (c *Config) Environ() []string {
348 		fmt.Sprintf("SOFT_SERVE_DATA_PATH=%s", c.DataPath),
349 		fmt.Sprintf("SOFT_SERVE_NAME=%s", c.Name),
350 		fmt.Sprintf("SOFT_SERVE_INITIAL_ADMIN_KEYS=%s", strings.Join(c.InitialAdminKeys, "\n")),
351+		fmt.Sprintf("SOFT_SERVE_ANON_ACCESS=%s", c.AnonAccess),
352 		fmt.Sprintf("SOFT_SERVE_SSH_ENABLED=%t", c.SSH.Enabled),
353 		fmt.Sprintf("SOFT_SERVE_SSH_LISTEN_ADDR=%s", c.SSH.ListenAddr),
354 		fmt.Sprintf("SOFT_SERVE_SSH_PUBLIC_URL=%s", c.SSH.PublicURL),
355@@ -222,6 +242,13 @@ func (c *Config) Environ() []string {
356 		fmt.Sprintf("SOFT_SERVE_JOBS_MIRROR_PULL=%s", c.Jobs.MirrorPull),
357 	}...)
358 
359+	// AllowKeyless is a tri-state override: only emit it when explicitly
360+	// set, so a subprocess parsing these envs sees the same "unset" state
361+	// (rather than an empty string coercing to false).
362+	if c.AllowKeyless != nil {
363+		envs = append(envs, fmt.Sprintf("SOFT_SERVE_ALLOW_KEYLESS=%t", *c.AllowKeyless))
364+	}
365+
366 	return envs
367 }
368 
369@@ -445,6 +472,10 @@ func (c *Config) Validate() error {
370 
371 	c.HTTP.CORS.AllowedOrigins = append([]string{c.HTTP.PublicURL}, c.HTTP.CORS.AllowedOrigins...)
372 
373+	if c.AnonAccess != "" && access.ParseAccessLevel(c.AnonAccess) < 0 {
374+		return fmt.Errorf("invalid anon-access level %q", c.AnonAccess)
375+	}
376+
377 	return nil
378 }
379 
380diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
381index 27c033ccbe253260d7f991a6bc9efc94132c9549..b7cdc52b8fe802530941d64ee004c54fe5a4ea2b 100644
382--- a/pkg/config/config_test.go
383+++ b/pkg/config/config_test.go
384@@ -4,6 +4,7 @@ import (
385 	"os"
386 	"testing"
387 
388+	"github.com/charmbracelet/soft-serve/pkg/access"
389 	"github.com/matryer/is"
390 )
391 
392@@ -124,3 +125,66 @@ func TestParseMultipleMethods(t *testing.T) {
393 		"PUT",
394 	})
395 }
396+
397+func TestAnonAccessEnvUnsetByDefault(t *testing.T) {
398+	is := is.New(t)
399+	cfg := DefaultConfig()
400+	is.NoErr(cfg.ParseEnv())
401+	// Empty string is the "no override" sentinel.
402+	is.Equal(cfg.AnonAccess, "")
403+}
404+
405+func TestParseAnonAccessEnv(t *testing.T) {
406+	is := is.New(t)
407+	is.NoErr(os.Setenv("SOFT_SERVE_ANON_ACCESS", access.AdminAccess.String()))
408+	t.Cleanup(func() {
409+		is.NoErr(os.Unsetenv("SOFT_SERVE_ANON_ACCESS"))
410+	})
411+	cfg := DefaultConfig()
412+	is.NoErr(cfg.ParseEnv())
413+	is.Equal(cfg.AnonAccess, access.AdminAccess.String())
414+}
415+
416+func TestValidateRejectsInvalidAnonAccess(t *testing.T) {
417+	is := is.New(t)
418+	cfg := &Config{
419+		DataPath:   t.TempDir(),
420+		AnonAccess: "not-a-real-access-level",
421+	}
422+	err := cfg.Validate()
423+	is.True(err != nil)
424+}
425+
426+func TestAllowKeylessEnvUnsetByDefault(t *testing.T) {
427+	is := is.New(t)
428+	cfg := DefaultConfig()
429+	is.NoErr(cfg.ParseEnv())
430+	// nil is the "no override" sentinel — distinct from an explicit false.
431+	is.True(cfg.AllowKeyless == nil)
432+}
433+
434+func TestParseAllowKeylessEnvTrue(t *testing.T) {
435+	is := is.New(t)
436+	is.NoErr(os.Setenv("SOFT_SERVE_ALLOW_KEYLESS", "true"))
437+	t.Cleanup(func() {
438+		is.NoErr(os.Unsetenv("SOFT_SERVE_ALLOW_KEYLESS"))
439+	})
440+	cfg := DefaultConfig()
441+	is.NoErr(cfg.ParseEnv())
442+	is.True(cfg.AllowKeyless != nil)
443+	is.Equal(*cfg.AllowKeyless, true)
444+}
445+
446+func TestParseAllowKeylessEnvFalse(t *testing.T) {
447+	is := is.New(t)
448+	is.NoErr(os.Setenv("SOFT_SERVE_ALLOW_KEYLESS", "false"))
449+	t.Cleanup(func() {
450+		is.NoErr(os.Unsetenv("SOFT_SERVE_ALLOW_KEYLESS"))
451+	})
452+	cfg := DefaultConfig()
453+	is.NoErr(cfg.ParseEnv())
454+	// Explicit false must survive as a real override, not collapse back to
455+	// "unset" — that's the whole reason this field is a *bool.
456+	is.True(cfg.AllowKeyless != nil)
457+	is.Equal(*cfg.AllowKeyless, false)
458+}
459diff --git a/pkg/ssh/cmd/settings.go b/pkg/ssh/cmd/settings.go
460index cf0ffe9e070ce56df86e36c86ecd4bfa717197e2..c9db032b3640641cec5ecd6d11f21f62fb235c66 100644
461--- a/pkg/ssh/cmd/settings.go
462+++ b/pkg/ssh/cmd/settings.go
463@@ -6,6 +6,7 @@ import (
464 
465 	"github.com/charmbracelet/soft-serve/pkg/access"
466 	"github.com/charmbracelet/soft-serve/pkg/backend"
467+	"github.com/charmbracelet/soft-serve/pkg/config"
468 	"github.com/spf13/cobra"
469 )
470 
471@@ -25,6 +26,7 @@ func SettingsCommand() *cobra.Command {
472 			RunE: func(cmd *cobra.Command, args []string) error {
473 				ctx := cmd.Context()
474 				be := backend.FromContext(ctx)
475+				cfg := config.FromContext(ctx)
476 				switch len(args) {
477 				case 0:
478 					cmd.Println(be.AllowKeyless(ctx))
479@@ -33,6 +35,7 @@ func SettingsCommand() *cobra.Command {
480 					if err := be.SetAllowKeyless(ctx, v); err != nil {
481 						return err
482 					}
483+					warnIfAllowKeylessOverridden(cmd, cfg)
484 				}
485 
486 				return nil
487@@ -51,6 +54,7 @@ func SettingsCommand() *cobra.Command {
488 			RunE: func(cmd *cobra.Command, args []string) error {
489 				ctx := cmd.Context()
490 				be := backend.FromContext(ctx)
491+				cfg := config.FromContext(ctx)
492 				switch len(args) {
493 				case 0:
494 					cmd.Println(be.AnonAccess(ctx))
495@@ -62,6 +66,7 @@ func SettingsCommand() *cobra.Command {
496 					if err := be.SetAnonAccess(ctx, al); err != nil {
497 						return err
498 					}
499+					warnIfAnonAccessOverridden(cmd, cfg)
500 				}
501 
502 				return nil
503@@ -71,3 +76,33 @@ func SettingsCommand() *cobra.Command {
504 
505 	return cmd
506 }
507+
508+// warnIfAllowKeylessOverridden warns on the command's stderr if a server
509+// config override is masking the allow-keyless value that was just written
510+// to the database. Without this, an admin changing the setting via this
511+// command would have no way to know their change has no effect.
512+func warnIfAllowKeylessOverridden(cmd *cobra.Command, cfg *config.Config) {
513+	if cfg == nil || cfg.AllowKeyless == nil {
514+		return
515+	}
516+
517+	fmt.Fprintf(cmd.ErrOrStderr(),
518+		"Warning: allow-keyless is set to %t by server config and takes precedence over this change. "+
519+			"The database was updated, but it will have no effect until the config override is removed.\n",
520+		*cfg.AllowKeyless)
521+}
522+
523+// warnIfAnonAccessOverridden warns on the command's stderr if a server
524+// config override is masking the anon-access value that was just written to
525+// the database. Without this, an admin changing the setting via this
526+// command would have no way to know their change has no effect.
527+func warnIfAnonAccessOverridden(cmd *cobra.Command, cfg *config.Config) {
528+	if cfg == nil || cfg.AnonAccess == "" {
529+		return
530+	}
531+
532+	fmt.Fprintf(cmd.ErrOrStderr(),
533+		"Warning: anon-access is set to %q by server config and takes precedence over this change. "+
534+			"The database was updated, but it will have no effect until the config override is removed.\n",
535+		cfg.AnonAccess)
536+}
537diff --git a/pkg/ssh/cmd/settings_test.go b/pkg/ssh/cmd/settings_test.go
538new file mode 100644
539index 0000000000000000000000000000000000000000..59eba202287429b938ec229b9a0788b993fa0777
540--- /dev/null
541+++ b/pkg/ssh/cmd/settings_test.go
542@@ -0,0 +1,106 @@
543+package cmd
544+
545+import (
546+	"bytes"
547+	"context"
548+	"strings"
549+	"testing"
550+
551+	"github.com/charmbracelet/soft-serve/pkg/backend"
552+	"github.com/charmbracelet/soft-serve/pkg/config"
553+	"github.com/charmbracelet/soft-serve/pkg/db"
554+	"github.com/charmbracelet/soft-serve/pkg/db/migrate"
555+	"github.com/charmbracelet/soft-serve/pkg/proto"
556+	"github.com/charmbracelet/soft-serve/pkg/store"
557+	"github.com/charmbracelet/soft-serve/pkg/store/database"
558+	"github.com/matryer/is"
559+	_ "modernc.org/sqlite"
560+)
561+
562+// newSettingsTestContext returns a context wired with a config, a real
563+// migrated SQLite-backed backend, and an authenticated admin user, ready to
564+// execute SettingsCommand() against. Returns the config too, so tests can
565+// mutate AnonAccess/AllowKeyless to simulate a config override.
566+func newSettingsTestContext(t *testing.T) (context.Context, *config.Config) {
567+	t.Helper()
568+	is := is.New(t)
569+	ctx := context.Background()
570+
571+	dp := t.TempDir()
572+	cfg := config.DefaultConfig()
573+	cfg.DataPath = dp
574+	cfg.DB.Driver = "sqlite"
575+	cfg.DB.DataSource = dp + "/test.db"
576+
577+	ctx = config.WithContext(ctx, cfg)
578+	dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
579+	is.NoErr(err)
580+	t.Cleanup(func() { dbx.Close() }) //nolint:errcheck
581+
582+	is.NoErr(migrate.Migrate(ctx, dbx))
583+	dbstore := database.New(ctx, dbx)
584+	ctx = store.WithContext(ctx, dbstore)
585+	be := backend.New(ctx, cfg, dbx, dbstore)
586+	ctx = backend.WithContext(ctx, be)
587+
588+	// "admin" is already taken by the default user the migration seeds, so
589+	// use a distinct username for the test's admin.
590+	admin, err := be.CreateUser(ctx, "testadmin", proto.UserOptions{Admin: true})
591+	is.NoErr(err)
592+	ctx = proto.WithUserContext(ctx, admin)
593+
594+	return ctx, cfg
595+}
596+
597+func runSettings(t *testing.T, ctx context.Context, args ...string) (stdout, stderr string, err error) {
598+	t.Helper()
599+	c := SettingsCommand()
600+	var outBuf, errBuf bytes.Buffer
601+	c.SetOut(&outBuf)
602+	c.SetErr(&errBuf)
603+	c.SetArgs(args)
604+	err = c.ExecuteContext(ctx)
605+	return outBuf.String(), errBuf.String(), err
606+}
607+
608+func TestSettingsAnonAccessWarnsOnConfigOverride(t *testing.T) {
609+	is := is.New(t)
610+	ctx, cfg := newSettingsTestContext(t)
611+
612+	// No override active: setting anon-access should succeed silently.
613+	_, stderr, err := runSettings(t, ctx, "anon-access", "read-write")
614+	is.NoErr(err)
615+	is.Equal(stderr, "")
616+
617+	// With a config override active, the write still succeeds (so it takes
618+	// effect if the override is later removed), but must warn loudly that
619+	// it currently has no effect.
620+	cfg.AnonAccess = "admin-access"
621+	_, stderr, err = runSettings(t, ctx, "anon-access", "no-access")
622+	is.NoErr(err)
623+	if !strings.Contains(stderr, "override") {
624+		t.Fatalf("expected override warning on stderr, got: %q", stderr)
625+	}
626+}
627+
628+func TestSettingsAllowKeylessWarnsOnConfigOverride(t *testing.T) {
629+	is := is.New(t)
630+	ctx, cfg := newSettingsTestContext(t)
631+
632+	// No override active: setting allow-keyless should succeed silently.
633+	_, stderr, err := runSettings(t, ctx, "allow-keyless", "false")
634+	is.NoErr(err)
635+	is.Equal(stderr, "")
636+
637+	// With a config override active, the write still succeeds, but must
638+	// warn that it currently has no effect. This is the dangerous
639+	// direction: an admin trying to lock things down (false) while a
640+	// config override forces it open (true).
641+	allow := true
642diff --git a/testscript/script_test.go b/testscript/script_test.go
643index 550a3b3e06f9b386e6bf588b6836847c53ef0187..2687dba7e894cc5f59fa67714acdc77633e16a12 100644
644--- a/testscript/script_test.go
645+++ b/testscript/script_test.go
646@@ -95,6 +95,7 @@ func TestScript(t *testing.T) {
647 			"soft":                   cmdSoft("admin", admin1.Signer()),
648 			"usoft":                  cmdSoft("user1", user1.Signer()),
649 			"attacksoft":             cmdSoft("attacker", attackerSigner, attacker.Signer()),
650+			"ksoft":                  cmdKeylessSoft,
651 			"git":                    cmdGit(admin1Key),
652 			"ugit":                   cmdGit(user1Key),
653 			"agit":                   cmdGit(attackerKey),
654@@ -221,6 +222,44 @@ func cmdSoft(user string, keys ...ssh.Signer) func(ts *testscript.TestScript, ne
655 	}
656 }
657 
658+// cmdKeylessSoft is like cmdSoft, but authenticates with zero public keys,
659+// forcing keyboard-interactive auth -- the actual "no key offered at all"
660+// path that allow-keyless gates. This is distinct from cmdSoft/cmdUsoft
661+// style helpers, which always offer a real key (registered or not) and so
662+// only ever exercise the anon-access path, not allow-keyless.
663+//
664+// A real ssh(1) binary can't reliably be used for this in a non-interactive
665+// test harness: OpenSSH's client refuses to send a keyboard-interactive
666+// request at all when stdin isn't a tty ("we did not send a packet, disable
667+// method"), regardless of BatchMode. golang.org/x/crypto/ssh has no such
668+// restriction, so it's used directly here instead of shelling out.
669+func cmdKeylessSoft(ts *testscript.TestScript, neg bool, args []string) {
670+	cli, err := ssh.Dial(
671+		"tcp",
672+		net.JoinHostPort("localhost", ts.Getenv("SSH_PORT")),
673+		&ssh.ClientConfig{
674+			User: "keyless",
675+			Auth: []ssh.AuthMethod{
676+				ssh.KeyboardInteractive(func(_, _ string, _ []string, _ []bool) ([]string, error) {
677+					return nil, nil
678+				}),
679+			},
680+			HostKeyCallback: ssh.InsecureIgnoreHostKey(),
681+		},
682+	)
683+	ts.Check(err)
684+	defer cli.Close()
685+
686+	sess, err := cli.NewSession()
687+	ts.Check(err)
688+	defer sess.Close()
689+
690+	sess.Stdout = ts.Stdout()
691+	sess.Stderr = ts.Stderr()
692+
693+	check(ts, sess.Run(strings.Join(args, " ")), neg)
694+}
695+
696 func cmdUI(key ssh.Signer) func(ts *testscript.TestScript, neg bool, args []string) {
697 	return func(ts *testscript.TestScript, neg bool, args []string) {
698 		if len(args) < 1 {
699diff --git a/testscript/testdata/anon-access-env.txtar b/testscript/testdata/anon-access-env.txtar
700new file mode 100644
701index 0000000000000000000000000000000000000000..f71e21559e51220e096461c60bb4b7d479756017
702--- /dev/null
703+++ b/testscript/testdata/anon-access-env.txtar
704@@ -0,0 +1,48 @@
705+# vi: set ft=conf
706+
707+# Proves the zero-touch dev/local bootstrap: SOFT_SERVE_ANON_ACCESS and
708+# SOFT_SERVE_ALLOW_KEYLESS grant a fully anonymous, keyless connection
709+# admin-level access from the moment the server starts -- no `settings`
710+# command, and no admin SSH key at all. This is the whole point of the
711+# feature: standing up a throwaway, fully open server from zero for local
712+# dev tooling, scriptably.
713+
714+# Override away the admin key the test harness normally provisions, and set
715+# the two bootstrap overrides under test.
716+env SOFT_SERVE_INITIAL_ADMIN_KEYS=
717+env SOFT_SERVE_ANON_ACCESS=admin-access
718+env SOFT_SERVE_ALLOW_KEYLESS=true
719+
720+# start soft serve
721+exec soft serve &
722+ensureserverrunning SSH_PORT
723+ensureserverrunning HTTP_PORT
724+
725+# --- SSH, with zero credentials: no key offered at all ---
726+# ksoft authenticates via keyboard-interactive with no public key at all --
727+# the exact "no key at all" path that allow-keyless gates. This is distinct
728+# from offering an unregistered key, which anon-access alone already
729+# governs regardless of allow-keyless -- see anon-access.txtar.
730+ksoft repo create keyless-repo
731+stderr 'Created repository keyless-repo'
732+
733+# --- HTTP, with zero credentials: no Authorization header at all ---
734+mkdir httprepo
735+git -c init.defaultBranch=main -C httprepo init
736+mkfile ./httprepo/README.md '# hello'
737+git -C httprepo remote add origin http://localhost:$HTTP_PORT/httprepo
738+git -C httprepo add -A
739+git -C httprepo commit -m 'first'
740+git -C httprepo push origin HEAD
741+
742+git clone http://localhost:$HTTP_PORT/httprepo httprepo_clone
743+cmp httprepo_clone/README.md httprepo/README.md
744+
745+# both repos -- the one created via keyless SSH and the one auto-created by
746+# a keyless HTTP push -- are visible with zero credentials.
747+ksoft repo list
748+stdout 'keyless-repo'
749+stdout 'httprepo'
750+
751+# stop the server
752+[windows] stopserver