07d47364a8d7aa09b6a11fa895127d6f2d7b2276

Author
Kieran Klukas <kieran@dunkirk.sh>
Committer
GitHub <noreply@github.com>
Date

Message

Merge commit from fork

* fix: use dedicated server-admin gate for global commands

The per-repository admin check (checkIfAdmin) was reused as the
authorization gate for the global user and settings commands. Since
checkIfAdmin falls through to AccessLevelForUser on args[0], any
authenticated user could create a repository matching the command
argument to satisfy the check and gain unauthorized access to global
admin operations.

Add checkIfServerAdmin, which only verifies IsPublicKeyAdmin and
user.IsAdmin() without consulting repository access levels, and use it
for the user and settings command groups. The repo-scoped webhook
commands correctly remain on checkIfAdmin.

Reported-by: arpitjain099
Reported-by: sondt99

* fix: cap collaborator grant at caller's access level

A read-write collaborator could grant admin-access to any account,
effectively escalating privileges beyond their own level. Cap the
granted collaborator level at the caller's own access level. Server
admins are exempt.

Reported-by: arpitjain099
Reported-by: sondt99

* fix: scope webhook delivery reads to the named repository

The webhook delivery list and get commands checked the caller's
permissions against the repository named on the command line, but then
looked up the delivery using only the numeric webhook ID. Because anyone
who creates a repository becomes its administrator, a user could pass
their own repository name together with another repository's webhook ID
and read that webhook's stored deliveries.

Delivery records hold the full request URL, the signature header, and
the request and response bodies, so this exposed private repository
event payloads and webhook credentials to users with no access to the
repository they belong to.

Look the webhook up scoped to the repository before reading any
deliveries, so a caller authorized for one repository can no longer
reach another repository's data. Redelivery was already scoped this way
and is unchanged.

Reported-by: sondt99

* refactor: make command authorization gates say what they guard

The privilege escalation behind this branch happened because a
permission check written for repository commands was reused to guard the
global user and settings commands. Nothing about the check's name said it
was repository-scoped, so reusing it in the wrong place looked
reasonable.

Rename the repository-scoped checks so their names state their scope,
and keep a single definition of what makes somebody a server
administrator instead of three copies that can drift apart. Route every
repository check through one helper so they all read the repository name
the same way.

Move the gates for the user and settings command groups onto the group
itself rather than repeating them on each subcommand. Subcommands added
later are now guarded by default; previously, forgetting a line left a
new administrative command completely unprotected.

No behavior changes.

* fix: stop collaborators from demoting those ranked above them

Capping what a collaborator can grant left the opposite direction open. A
collaborator with write access could still remove a collaborator who had
administrative access, or overwrite their entry with a lower one. Either
way they could strip permissions from somebody above them, and removal
followed by re-adding gave them the escalation the cap was meant to
prevent.

Refuse to remove or overwrite a collaborator whose access exceeds the
caller's own. Server administrators remain exempt, and granting or
removing at or below the caller's level is unaffected.

Also add regression tests covering the escalation paths fixed on this
branch: reaching global commands by creating a repository named after the
command argument, the same bypass through an admin-level anonymous access
setting, granting above your own level, demoting somebody above you, and
reading another repository's webhook deliveries. Each test was confirmed
to fail before its fix.

Reported-by: arpitjain099
Reported-by: sondt99

Diff

This diff is truncated to protect this page.

   1diff --git a/pkg/backend/webhooks.go b/pkg/backend/webhooks.go
   2index 2fd90c2a505cd0e5b4f9526f6f685f5dae7f6ae0..e60f5439cbd9ca91c068953280618669f25423d8 100644
   3--- a/pkg/backend/webhooks.go
   4+++ b/pkg/backend/webhooks.go
   5@@ -200,13 +200,21 @@ func (b *Backend) DeleteWebhook(ctx context.Context, repo proto.Repository, id i
   6 	})
   7 }
   8 
   9-// ListWebhookDeliveries lists webhook deliveries for a webhook.
  10-func (b *Backend) ListWebhookDeliveries(ctx context.Context, id int64) ([]webhook.Delivery, error) {
  11+// ListWebhookDeliveries lists webhook deliveries for a webhook belonging to
  12+// the given repository.
  13+//
  14+// The webhook is resolved scoped to the repository, so a caller authorized
  15+// only for repo A cannot read deliveries of a webhook owned by repo B.
  16+func (b *Backend) ListWebhookDeliveries(ctx context.Context, repo proto.Repository, id int64) ([]webhook.Delivery, error) {
  17 	dbx := db.FromContext(ctx)
  18 	datastore := store.FromContext(ctx)
  19 
  20 	var deliveries []models.WebhookDelivery
  21 	if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
  22+		if _, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), id); err != nil {
  23+			return db.WrapError(err)
  24+		}
  25+
  26 		var err error
  27 		deliveries, err = datastore.ListWebhookDeliveriesByWebhookID(ctx, tx, id)
  28 		if err != nil {
  29@@ -265,13 +273,23 @@ func (b *Backend) RedeliverWebhookDelivery(ctx context.Context, repo proto.Repos
  30 	return webhook.SendWebhook(ctx, wh, webhook.Event(delivery.Event), payload)
  31 }
  32 
  33-// WebhookDelivery returns a webhook delivery.
  34-func (b *Backend) WebhookDelivery(ctx context.Context, webhookID int64, id uuid.UUID) (webhook.Delivery, error) {
  35+// WebhookDelivery returns a webhook delivery for a webhook belonging to the
  36+// given repository.
  37+//
  38+// The webhook is resolved scoped to the repository, so a caller authorized
  39+// only for repo A cannot read a delivery of a webhook owned by repo B.
  40+// Deliveries expose the full request URL, headers, and body, so this scoping
  41+// is what keeps them from leaking across repositories.
  42+func (b *Backend) WebhookDelivery(ctx context.Context, repo proto.Repository, webhookID int64, id uuid.UUID) (webhook.Delivery, error) {
  43 	dbx := db.FromContext(ctx)
  44 	datastore := store.FromContext(ctx)
  45 
  46 	var delivery webhook.Delivery
  47 	if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
  48+		if _, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), webhookID); err != nil {
  49+			return db.WrapError(err)
  50+		}
  51+
  52 		d, err := datastore.GetWebhookDeliveryByID(ctx, tx, webhookID, id)
  53 		if err != nil {
  54 			return db.WrapError(err)
  55diff --git a/pkg/proto/errors.go b/pkg/proto/errors.go
  56index 1ff116e5ef91dc543b2c3303c7e7e3230e41837d..ef126a3546aeb226b292d2039a3ab4b0af7001a6 100644
  57--- a/pkg/proto/errors.go
  58+++ b/pkg/proto/errors.go
  59@@ -25,4 +25,7 @@ var (
  60 	ErrCollaboratorNotFound = errors.New("collaborator not found")
  61 	// ErrCollaboratorExist is returned when a collaborator already exists.
  62 	ErrCollaboratorExist = errors.New("collaborator already exists")
  63+	// ErrExceedsAccessLevel is returned when an action would grant or revoke
  64+	// access above the caller's own access level.
  65+	ErrExceedsAccessLevel = errors.New("cannot change access above your own access level")
  66 )
  67diff --git a/pkg/ssh/cmd/auth_test.go b/pkg/ssh/cmd/auth_test.go
  68new file mode 100644
  69index 0000000000000000000000000000000000000000..c9253a017b87e1c3e392a8fe382b5bd16913a765
  70--- /dev/null
  71+++ b/pkg/ssh/cmd/auth_test.go
  72@@ -0,0 +1,170 @@
  73+package cmd
  74+
  75+import (
  76+	"errors"
  77+	"testing"
  78+
  79+	"github.com/charmbracelet/soft-serve/pkg/access"
  80+	"github.com/charmbracelet/soft-serve/pkg/proto"
  81+	"github.com/matryer/is"
  82+)
  83+
  84+// TestGlobalCommandsIgnoreRepositoryAccess is the regression test for the
  85+// privilege escalation where a non-admin could run global admin commands by
  86+// creating a repository named after the command's first argument. `user
  87+// set-admin victim true` used to be authorized by the caller's admin access
  88+// to a repository they happened to own called "victim".
  89+func TestGlobalCommandsIgnoreRepositoryAccess(t *testing.T) {
  90+	is := is.New(t)
  91+	ctx, be := newAuthTestContext(t)
  92+	attackerCtx := withUser(t, ctx, be, "attacker", false)
  93+
  94+	// The attacker owns a repository named "victim", so they have
  95+	// AdminAccess *to that repository*.
  96+	attacker := proto.UserFromContext(attackerCtx)
  97+	_, err := be.CreateRepository(attackerCtx, "victim", attacker, proto.RepositoryOptions{})
  98+	is.NoErr(err)
  99+	is.Equal(be.AccessLevelForUser(attackerCtx, "victim", attacker), access.AdminAccess)
 100+
 101+	// Repository admin access must not authorize global user commands.
 102+	for _, args := range [][]string{
 103+		{"set-admin", "victim", "true"},
 104+		{"create", "victim2"},
 105+		{"delete", "victim"},
 106+		{"list"},
 107+		{"info", "victim"},
 108+		{"set-username", "victim", "victim3"},
 109+	} {
 110+		if err := runUser(t, attackerCtx, args...); !errors.Is(err, proto.ErrUnauthorized) {
 111+			t.Errorf("user %v: expected ErrUnauthorized, got %v", args, err)
 112+		}
 113+	}
 114+
 115+	// The attacker must not have become an admin.
 116+	reloaded, err := be.User(ctx, "attacker")
 117+	is.NoErr(err)
 118+	is.Equal(reloaded.IsAdmin(), false)
 119+}
 120+
 121+// TestGlobalCommandsIgnoreAnonAdminAccess covers the variant that needs no
 122+// repository at all: with anon-access set to admin-access,
 123+// AccessLevelForUser returns AdminAccess to any authenticated user for a
 124+// nonexistent repository name.
 125+func TestGlobalCommandsIgnoreAnonAdminAccess(t *testing.T) {
 126+	is := is.New(t)
 127+	ctx, be := newAuthTestContext(t)
 128+	is.NoErr(be.SetAnonAccess(ctx, access.AdminAccess))
 129+
 130+	attackerCtx := withUser(t, ctx, be, "attacker", false)
 131+	attacker := proto.UserFromContext(attackerCtx)
 132+	is.Equal(be.AccessLevelForUser(attackerCtx, "nonexistent", attacker), access.AdminAccess)
 133+
 134+	err := runUser(t, attackerCtx, "set-admin", "attacker", "true")
 135+	if !errors.Is(err, proto.ErrUnauthorized) {
 136+		t.Fatalf("expected ErrUnauthorized, got %v", err)
 137+	}
 138+
 139+	reloaded, err := be.User(ctx, "attacker")
 140+	is.NoErr(err)
 141+	is.Equal(reloaded.IsAdmin(), false)
 142+}
 143+
 144+// TestUserSubcommandsAreGatedByParent verifies the gate lives on the `user`
 145+// parent command, so subcommands cannot be left unprotected by omission.
 146+func TestUserSubcommandsAreGatedByParent(t *testing.T) {
 147+	is := is.New(t)
 148+	c := UserCommand()
 149+	is.True(c.PersistentPreRunE != nil)
 150+
 151+	for _, sub := range c.Commands() {
 152+		if sub.PersistentPreRunE != nil {
 153+			t.Errorf("subcommand %q sets its own PersistentPreRunE; the parent gate is authoritative", sub.Name())
 154+		}
 155+	}
 156+}
 157+
 158+// TestSettingsSubcommandsAreGatedByParent is the settings counterpart.
 159+func TestSettingsSubcommandsAreGatedByParent(t *testing.T) {
 160+	is := is.New(t)
 161+	c := SettingsCommand()
 162+	is.True(c.PersistentPreRunE != nil)
 163+
 164+	for _, sub := range c.Commands() {
 165+		if sub.PersistentPreRunE != nil {
 166+			t.Errorf("subcommand %q sets its own PersistentPreRunE; the parent gate is authoritative", sub.Name())
 167+		}
 168+	}
 169+}
 170+
 171+// TestCollabAddCannotExceedCallerAccess verifies a read-write collaborator
 172diff --git a/pkg/ssh/cmd/branch.go b/pkg/ssh/cmd/branch.go
 173index 85474606ed8f285f12f96aa6520ceced1ee67c8c..008222c2e60c76d491f53cf3d89f77c17b529f47 100644
 174--- a/pkg/ssh/cmd/branch.go
 175+++ b/pkg/ssh/cmd/branch.go
 176@@ -88,7 +88,7 @@ func branchDefaultCommand() *cobra.Command {
 177 
 178 				cmd.Println(head.Name().Short())
 179 			case 2:
 180-				if err := checkIfCollab(cmd, args); err != nil {
 181+				if err := checkIfRepoCollab(cmd, args); err != nil {
 182 					return err
 183 				}
 184 
 185diff --git a/pkg/ssh/cmd/cmd.go b/pkg/ssh/cmd/cmd.go
 186index 2862c5e923da445da6ccd659f5bcb996ed95f8eb..e94f1274a20da6458264db98faebe393e5124090 100644
 187--- a/pkg/ssh/cmd/cmd.go
 188+++ b/pkg/ssh/cmd/cmd.go
 189@@ -1,6 +1,7 @@
 190 package cmd
 191 
 192 import (
 193+	"context"
 194 	"fmt"
 195 	"net/url"
 196 	"strings"
 197@@ -110,17 +111,8 @@ func CommandName(args []string) string {
 198 }
 199 
 200 func checkIfReadable(cmd *cobra.Command, args []string) error {
 201-	var repo string
 202-	if len(args) > 0 {
 203-		repo = args[0]
 204-	}
 205-
 206 	ctx := cmd.Context()
 207-	be := backend.FromContext(ctx)
 208-	rn := utils.SanitizeRepo(repo)
 209-	user := proto.UserFromContext(ctx)
 210-	auth := be.AccessLevelForUser(cmd.Context(), rn, user)
 211-	if auth < access.ReadOnlyAccess {
 212+	if repoAccessLevel(ctx, repoArg(args)) < access.ReadOnlyAccess {
 213 		return proto.ErrRepoNotFound
 214 	}
 215 	return nil
 216@@ -137,50 +129,82 @@ func IsPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
 217 	return false
 218 }
 219 
 220-func checkIfAdmin(cmd *cobra.Command, args []string) error {
 221-	var repo string
 222-	if len(args) > 0 {
 223-		repo = args[0]
 224-	}
 225-
 226-	ctx := cmd.Context()
 227+// isServerAdmin reports whether the caller is a server administrator: either
 228+// their public key is one of the configured admin keys, or their account has
 229+// the admin flag set.
 230+//
 231+// This is the single source of truth for "is this caller a server admin".
 232+// Every authorization gate defers to it so the definition cannot drift.
 233+func isServerAdmin(ctx context.Context) bool {
 234 	cfg := config.FromContext(ctx)
 235-	be := backend.FromContext(ctx)
 236-	rn := utils.SanitizeRepo(repo)
 237 	pk := sshutils.PublicKeyFromContext(ctx)
 238 	if IsPublicKeyAdmin(cfg, pk) {
 239-		return nil
 240+		return true
 241 	}
 242 
 243 	user := proto.UserFromContext(ctx)
 244-	if user == nil {
 245-		return proto.ErrUnauthorized
 246+	return user != nil && user.IsAdmin()
 247+}
 248+
 249+// repoArg returns the repository name from a repo-scoped command's arguments.
 250+// Repo-scoped commands always take the repository as their first argument.
 251+func repoArg(args []string) string {
 252+	if len(args) == 0 {
 253+		return ""
 254 	}
 255+	return utils.SanitizeRepo(args[0])
 256+}
 257 
 258-	if user.IsAdmin() {
 259-		return nil
 260+// repoAccessLevel returns the caller's access level for the named repository.
 261+// The repository name must already be sanitized, e.g. via repoArg.
 262+func repoAccessLevel(ctx context.Context, repo string) access.AccessLevel {
 263+	be := backend.FromContext(ctx)
 264+	return be.AccessLevelForUser(ctx, repo, proto.UserFromContext(ctx))
 265+}
 266+
 267+// checkIfServerAdmin is the authorization gate for global (non-repo-scoped)
 268+// commands such as `user` and `settings`. It allows server admins only.
 269+//
 270+// Unlike checkIfRepoAdmin, it never consults repository access levels, so it
 271+// cannot be bypassed by creating a repository whose name matches the command
 272+// argument. Attach it to the parent command so that every subcommand,
 273+// including ones added later, is gated by default.
 274+func checkIfServerAdmin(cmd *cobra.Command, _ []string) error {
 275+	if !isServerAdmin(cmd.Context()) {
 276+		return proto.ErrUnauthorized
 277 	}
 278+	return nil
 279+}
 280 
 281-	auth := be.AccessLevelForUser(cmd.Context(), rn, user)
 282-	if auth >= access.AdminAccess {
 283+// checkIfRepoAdmin is the authorization gate for repo-scoped commands that
 284+// require admin access to the repository named by the first argument.
 285+//
 286+// Only use this on commands whose first argument is a repository name. For
 287+// global commands, use checkIfServerAdmin instead.
 288+func checkIfRepoAdmin(cmd *cobra.Command, args []string) error {
 289diff --git a/pkg/ssh/cmd/collab.go b/pkg/ssh/cmd/collab.go
 290index df1e059f5ee6820fb9e7774c733dcf9ca37ad7ef..77db6db4a0ef17a3aeebbfbb788ac545b60f7dbf 100644
 291--- a/pkg/ssh/cmd/collab.go
 292+++ b/pkg/ssh/cmd/collab.go
 293@@ -1,8 +1,13 @@
 294 package cmd
 295 
 296 import (
 297+	"context"
 298+	"errors"
 299+
 300 	"github.com/charmbracelet/soft-serve/pkg/access"
 301 	"github.com/charmbracelet/soft-serve/pkg/backend"
 302+	"github.com/charmbracelet/soft-serve/pkg/db"
 303+	"github.com/charmbracelet/soft-serve/pkg/proto"
 304 	"github.com/spf13/cobra"
 305 )
 306 
 307@@ -22,6 +27,67 @@ func collabCommand() *cobra.Command {
 308 	return cmd
 309 }
 310 
 311+// checkCollabGrant reports whether the caller may set a collaborator's access
 312+// level on a repository to level.
 313+//
 314+// Server admins may grant anything. Everyone else is bounded by their own
 315+// access level on the repository, so a read-write collaborator cannot mint an
 316+// admin-access collaborator and escalate beyond their own permissions.
 317+func checkCollabGrant(ctx context.Context, repo string, level access.AccessLevel) error {
 318+	if isServerAdmin(ctx) {
 319+		return nil
 320+	}
 321+
 322+	if proto.UserFromContext(ctx) == nil {
 323+		return proto.ErrUnauthorized
 324+	}
 325+
 326+	caller := repoAccessLevel(ctx, repo)
 327+	if level > caller {
 328+		return proto.ErrExceedsAccessLevel
 329+	}
 330+
 331+	return nil
 332+}
 333+
 334+// checkCollabDemote reports whether the caller may remove or overwrite an
 335+// existing collaborator on a repository.
 336+//
 337+// Removal is a privileged change in the same way granting is: without this,
 338+// a read-write collaborator could remove an admin-access collaborator and
 339+// then re-add them at a lower level, demoting someone above them.
 340+func checkCollabDemote(ctx context.Context, repo string, username string) error {
 341+	if isServerAdmin(ctx) {
 342+		return nil
 343+	}
 344+
 345+	if proto.UserFromContext(ctx) == nil {
 346+		return proto.ErrUnauthorized
 347+	}
 348+
 349+	be := backend.FromContext(ctx)
 350+	current, isCollab, err := be.IsCollaborator(ctx, repo, username)
 351+	if err != nil {
 352+		// A missing row just means the user is not a collaborator yet, which
 353+		// is the common case when adding one. Any other error is real and
 354+		// must fail closed.
 355+		if !errors.Is(err, db.ErrRecordNotFound) {
 356+			return err
 357+		}
 358+		return nil
 359+	}
 360+
 361+	if !isCollab {
 362+		return nil
 363+	}
 364+
 365+	if current > repoAccessLevel(ctx, repo) {
 366+		return proto.ErrExceedsAccessLevel
 367+	}
 368+
 369+	return nil
 370+}
 371+
 372 func collabAddCommand() *cobra.Command {
 373 	cmd := &cobra.Command{
 374 		Use:               "add REPOSITORY USERNAME [LEVEL]",
 375@@ -32,7 +98,7 @@ func collabAddCommand() *cobra.Command {
 376 		RunE: func(cmd *cobra.Command, args []string) error {
 377 			ctx := cmd.Context()
 378 			be := backend.FromContext(ctx)
 379-			repo := args[0]
 380+			repo := repoArg(args)
 381 			username := args[1]
 382 			level := access.ReadWriteAccess
 383 			if len(args) > 2 {
 384@@ -42,6 +108,14 @@ func collabAddCommand() *cobra.Command {
 385 				}
 386 			}
 387 
 388+			if err := checkCollabGrant(ctx, repo, level); err != nil {
 389+				return err
 390+			}
 391+
 392+			if err := checkCollabDemote(ctx, repo, username); err != nil {
 393diff --git a/pkg/ssh/cmd/create.go b/pkg/ssh/cmd/create.go
 394index fa8ea3001a6686f3b1826bf0682efc42e639128f..f4291521fcf03f10bdf278e974d449a8bd88e15f 100644
 395--- a/pkg/ssh/cmd/create.go
 396+++ b/pkg/ssh/cmd/create.go
 397@@ -20,7 +20,7 @@ func createCommand() *cobra.Command {
 398 		Use:               "create REPOSITORY",
 399 		Short:             "Create a new repository",
 400 		Args:              cobra.ExactArgs(1),
 401-		PersistentPreRunE: checkIfCollab,
 402+		PersistentPreRunE: checkIfRepoCollab,
 403 		RunE: func(cmd *cobra.Command, args []string) error {
 404 			ctx := cmd.Context()
 405 			cfg := config.FromContext(ctx)
 406diff --git a/pkg/ssh/cmd/description.go b/pkg/ssh/cmd/description.go
 407index 3e11c61f4e9d60e8725f8ce41cbbbef3f8a56726..f8cf95f7d1d6e198e0cad8e91220ac8840a304a1 100644
 408--- a/pkg/ssh/cmd/description.go
 409+++ b/pkg/ssh/cmd/description.go
 410@@ -27,7 +27,7 @@ func descriptionCommand() *cobra.Command {
 411 
 412 				cmd.Println(desc)
 413 			default:
 414-				if err := checkIfCollab(cmd, args); err != nil {
 415+				if err := checkIfRepoCollab(cmd, args); err != nil {
 416 					return err
 417 				}
 418 				if err := be.SetDescription(ctx, rn, strings.Join(args[1:], " ")); err != nil {
 419diff --git a/pkg/ssh/cmd/helpers_test.go b/pkg/ssh/cmd/helpers_test.go
 420new file mode 100644
 421index 0000000000000000000000000000000000000000..a92699bdba8d859ba41b52154f35ae9fce5750c7
 422--- /dev/null
 423+++ b/pkg/ssh/cmd/helpers_test.go
 424@@ -0,0 +1,90 @@
 425+package cmd
 426+
 427+import (
 428+	"bytes"
 429+	"context"
 430+	"testing"
 431+
 432+	"github.com/charmbracelet/soft-serve/pkg/backend"
 433+	"github.com/charmbracelet/soft-serve/pkg/config"
 434+	"github.com/charmbracelet/soft-serve/pkg/db"
 435+	"github.com/charmbracelet/soft-serve/pkg/db/migrate"
 436+	"github.com/charmbracelet/soft-serve/pkg/proto"
 437+	"github.com/charmbracelet/soft-serve/pkg/store"
 438+	"github.com/charmbracelet/soft-serve/pkg/store/database"
 439+	"github.com/matryer/is"
 440+	_ "modernc.org/sqlite"
 441+)
 442+
 443+// newAuthTestContext returns a context wired with a config and a real
 444+// migrated SQLite-backed backend, plus the backend itself so tests can set up
 445+// users and repositories.
 446+//
 447+// No user is attached to the returned context; use withUser to authenticate
 448+// as somebody.
 449+func newAuthTestContext(t *testing.T) (context.Context, *backend.Backend) {
 450+	t.Helper()
 451+	is := is.New(t)
 452+	ctx := context.Background()
 453+
 454+	dp := t.TempDir()
 455+	cfg := config.DefaultConfig()
 456+	cfg.DataPath = dp
 457+	cfg.DB.Driver = "sqlite"
 458+	cfg.DB.DataSource = dp + "/test.db"
 459+
 460+	ctx = config.WithContext(ctx, cfg)
 461+	dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
 462+	is.NoErr(err)
 463+	t.Cleanup(func() { dbx.Close() }) //nolint:errcheck
 464+
 465+	is.NoErr(migrate.Migrate(ctx, dbx))
 466+	ctx = db.WithContext(ctx, dbx)
 467+	dbstore := database.New(ctx, dbx)
 468+	ctx = store.WithContext(ctx, dbstore)
 469+	be := backend.New(ctx, cfg, dbx, dbstore)
 470+	ctx = backend.WithContext(ctx, be)
 471+
 472+	return ctx, be
 473+}
 474+
 475+// withUser creates a user and returns a context authenticated as them.
 476+func withUser(t *testing.T, ctx context.Context, be *backend.Backend, username string, admin bool) context.Context {
 477+	t.Helper()
 478+	is := is.New(t)
 479+	user, err := be.CreateUser(ctx, username, proto.UserOptions{Admin: admin})
 480+	is.NoErr(err)
 481+	return proto.WithUserContext(ctx, user)
 482+}
 483+
 484+// runRepo runs a `repo` subcommand and returns only its error.
 485+func runRepo(t *testing.T, ctx context.Context, args ...string) error {
 486+	t.Helper()
 487+	_, _, err := runRepoOutput(t, ctx, args...)
 488+	return err
 489+}
 490+
 491+// runRepoOutput runs a `repo` subcommand and returns its stdout and stderr
 492+// alongside the error, so tests can assert that sensitive values were not
 493+// printed even when a command fails.
 494+func runRepoOutput(t *testing.T, ctx context.Context, args ...string) (stdout, stderr string, err error) {
 495+	t.Helper()
 496+	c := RepoCommand()
 497+	var outBuf, errBuf bytes.Buffer
 498+	c.SetOut(&outBuf)
 499+	c.SetErr(&errBuf)
 500+	c.SetArgs(args)
 501+	err = c.ExecuteContext(ctx)
 502+	return outBuf.String(), errBuf.String(), err
 503+}
 504+
 505+// runUser runs a `user` subcommand and returns only its error.
 506+func runUser(t *testing.T, ctx context.Context, args ...string) error {
 507+	t.Helper()
 508+	c := UserCommand()
 509+	var outBuf, errBuf bytes.Buffer
 510+	c.SetOut(&outBuf)
 511+	c.SetErr(&errBuf)
 512+	c.SetArgs(args)
 513+	return c.ExecuteContext(ctx)
 514+}
 515diff --git a/pkg/ssh/cmd/hidden.go b/pkg/ssh/cmd/hidden.go
 516index 82f544307681985935cd9f365a936bf43c096ceb..567ec1e7c3709c49dfa61fb6053d5b45d98434c7 100644
 517--- a/pkg/ssh/cmd/hidden.go
 518+++ b/pkg/ssh/cmd/hidden.go
 519@@ -25,7 +25,7 @@ func hiddenCommand() *cobra.Command {
 520 
 521 				cmd.Println(hidden)
 522 			case 2:
 523-				if err := checkIfCollab(cmd, args); err != nil {
 524+				if err := checkIfRepoCollab(cmd, args); err != nil {
 525 					return err
 526 				}
 527 
 528diff --git a/pkg/ssh/cmd/import.go b/pkg/ssh/cmd/import.go
 529index cb71f3c673df7dd325aaf60bd733130bdf99c0ea..70ee49b76cffc50fb3dd479ea57e2307ab1ca701 100644
 530--- a/pkg/ssh/cmd/import.go
 531+++ b/pkg/ssh/cmd/import.go
 532@@ -23,7 +23,7 @@ func importCommand() *cobra.Command {
 533 		Use:               "import REPOSITORY REMOTE",
 534 		Short:             "Import a new repository from remote",
 535 		Args:              cobra.ExactArgs(2),
 536-		PersistentPreRunE: checkIfCollab,
 537+		PersistentPreRunE: checkIfRepoCollab,
 538 		RunE: func(cmd *cobra.Command, args []string) error {
 539 			ctx := cmd.Context()
 540 			be := backend.FromContext(ctx)
 541diff --git a/pkg/ssh/cmd/mirror.go b/pkg/ssh/cmd/mirror.go
 542index c3e7131262a0aa3589b41dbc5f723782c8df7f3c..ccdc5111197bf4a80ea341c76375c8460c7bda72 100644
 543--- a/pkg/ssh/cmd/mirror.go
 544+++ b/pkg/ssh/cmd/mirror.go
 545@@ -25,7 +25,7 @@ func mirrorCommand() *cobra.Command {
 546 
 547 				cmd.Println(isMirror)
 548 			case 2:
 549-				if err := checkIfCollab(cmd, args); err != nil {
 550+				if err := checkIfRepoCollab(cmd, args); err != nil {
 551 					return err
 552 				}
 553 
 554diff --git a/pkg/ssh/cmd/private.go b/pkg/ssh/cmd/private.go
 555index 9c91dbcda1e8d89f763cbe4e13300848e7389466..b7198f1c4c008a4b82bd227046c4b694e872369e 100644
 556--- a/pkg/ssh/cmd/private.go
 557+++ b/pkg/ssh/cmd/private.go
 558@@ -32,7 +32,7 @@ func privateCommand() *cobra.Command {
 559 				if err != nil {
 560 					return err
 561 				}
 562-				if err := checkIfCollab(cmd, args); err != nil {
 563+				if err := checkIfRepoCollab(cmd, args); err != nil {
 564 					return err
 565 				}
 566 				if err := be.SetPrivate(ctx, rn, isPrivate); err != nil {
 567diff --git a/pkg/ssh/cmd/project_name.go b/pkg/ssh/cmd/project_name.go
 568index d24931f5dcf23af7d8656d5d41c37fbe7f901d37..feb3975642be430dbaf306d5baeb2c7042fd9693 100644
 569--- a/pkg/ssh/cmd/project_name.go
 570+++ b/pkg/ssh/cmd/project_name.go
 571@@ -27,7 +27,7 @@ func projectName() *cobra.Command {
 572 
 573 				cmd.Println(pn)
 574 			default:
 575-				if err := checkIfCollab(cmd, args); err != nil {
 576+				if err := checkIfRepoCollab(cmd, args); err != nil {
 577 					return err
 578 				}
 579 				if err := be.SetProjectName(ctx, rn, strings.Join(args[1:], " ")); err != nil {
 580diff --git a/pkg/ssh/cmd/settings.go b/pkg/ssh/cmd/settings.go
 581index 297ba195a4667a12ea4a7f533eac39f7a252c2cb..86d3c278dfece60794e890e6d503704f5877a3cf 100644
 582--- a/pkg/ssh/cmd/settings.go
 583+++ b/pkg/ssh/cmd/settings.go
 584@@ -15,14 +15,18 @@ func SettingsCommand() *cobra.Command {
 585 	cmd := &cobra.Command{
 586 		Use:   "settings",
 587 		Short: "Manage server settings",
 588+		// Gate the whole command tree rather than each subcommand. Cobra
 589+		// runs the nearest persistent pre-run found when walking up from
 590+		// the invoked command, so subcommands added later are gated by
 591+		// default instead of silently unprotected.
 592+		PersistentPreRunE: checkIfServerAdmin,
 593 	}
 594 
 595 	cmd.AddCommand(
 596 		&cobra.Command{
 597-			Use:               "allow-keyless [true|false]",
 598-			Short:             "Set or get allow keyless access to repositories",
 599-			Args:              cobra.RangeArgs(0, 1),
 600-			PersistentPreRunE: checkIfAdmin,
 601+			Use:   "allow-keyless [true|false]",
 602+			Short: "Set or get allow keyless access to repositories",
 603+			Args:  cobra.RangeArgs(0, 1),
 604 			RunE: func(cmd *cobra.Command, args []string) error {
 605 				ctx := cmd.Context()
 606 				be := backend.FromContext(ctx)
 607@@ -46,11 +50,10 @@ func SettingsCommand() *cobra.Command {
 608 	als := []string{access.NoAccess.String(), access.ReadOnlyAccess.String(), access.ReadWriteAccess.String(), access.AdminAccess.String()}
 609 	cmd.AddCommand(
 610 		&cobra.Command{
 611-			Use:               "anon-access [ACCESS_LEVEL]",
 612-			Short:             "Set or get the default access level for anonymous users",
 613-			Args:              cobra.RangeArgs(0, 1),
 614-			ValidArgs:         als,
 615-			PersistentPreRunE: checkIfAdmin,
 616+			Use:       "anon-access [ACCESS_LEVEL]",
 617+			Short:     "Set or get the default access level for anonymous users",
 618+			Args:      cobra.RangeArgs(0, 1),
 619+			ValidArgs: als,
 620 			RunE: func(cmd *cobra.Command, args []string) error {
 621 				ctx := cmd.Context()
 622 				be := backend.FromContext(ctx)
 623diff --git a/pkg/ssh/cmd/user.go b/pkg/ssh/cmd/user.go
 624index 21981f9cb300e0fee9de4a3dc035b1c796cfafd4..801a9f9f2ad75dae1cd4d2b28b2e26cf2dbce400 100644
 625--- a/pkg/ssh/cmd/user.go
 626+++ b/pkg/ssh/cmd/user.go
 627@@ -17,15 +17,19 @@ func UserCommand() *cobra.Command {
 628 		Use:     "user",
 629 		Aliases: []string{"users"},
 630 		Short:   "Manage users",
 631+		// Gate the whole command tree rather than each subcommand. Cobra
 632+		// runs the nearest persistent pre-run found when walking up from
 633+		// the invoked command, so subcommands added later are gated by
 634+		// default instead of silently unprotected.
 635+		PersistentPreRunE: checkIfServerAdmin,
 636 	}
 637 
 638 	var admin bool
 639 	var key string
 640 	userCreateCommand := &cobra.Command{
 641-		Use:               "create USERNAME",
 642-		Short:             "Create a new user",
 643-		Args:              cobra.ExactArgs(1),
 644-		PersistentPreRunE: checkIfAdmin,
 645+		Use:   "create USERNAME",
 646+		Short: "Create a new user",
 647+		Args:  cobra.ExactArgs(1),
 648 		RunE: func(cmd *cobra.Command, args []string) error {
 649 			var pubkeys []ssh.PublicKey
 650 			ctx := cmd.Context()
 651@@ -54,10 +58,9 @@ func UserCommand() *cobra.Command {
 652 	userCreateCommand.Flags().StringVarP(&key, "key", "k", "", "add a public key to the user")
 653 
 654 	userDeleteCommand := &cobra.Command{
 655-		Use:               "delete USERNAME",
 656-		Short:             "Delete a user",
 657-		Args:              cobra.ExactArgs(1),
 658-		PersistentPreRunE: checkIfAdmin,
 659+		Use:   "delete USERNAME",
 660+		Short: "Delete a user",
 661+		Args:  cobra.ExactArgs(1),
 662 		RunE: func(cmd *cobra.Command, args []string) error {
 663 			ctx := cmd.Context()
 664 			be := backend.FromContext(ctx)
 665@@ -68,11 +71,10 @@ func UserCommand() *cobra.Command {
 666 	}
 667 
 668 	userListCommand := &cobra.Command{
 669-		Use:               "list",
 670-		Aliases:           []string{"ls"},
 671-		Short:             "List users",
 672-		Args:              cobra.NoArgs,
 673-		PersistentPreRunE: checkIfAdmin,
 674+		Use:     "list",
 675+		Aliases: []string{"ls"},
 676+		Short:   "List users",
 677+		Args:    cobra.NoArgs,
 678 		RunE: func(cmd *cobra.Command, _ []string) error {
 679 			ctx := cmd.Context()
 680 			be := backend.FromContext(ctx)
 681@@ -91,10 +93,9 @@ func UserCommand() *cobra.Command {
 682 	}
 683 
 684 	userAddPubkeyCommand := &cobra.Command{
 685-		Use:               "add-pubkey USERNAME AUTHORIZED_KEY",
 686-		Short:             "Add a public key to a user",
 687-		Args:              cobra.MinimumNArgs(2),
 688-		PersistentPreRunE: checkIfAdmin,
 689+		Use:   "add-pubkey USERNAME AUTHORIZED_KEY",
 690+		Short: "Add a public key to a user",
 691+		Args:  cobra.MinimumNArgs(2),
 692 		RunE: func(cmd *cobra.Command, args []string) error {
 693 			ctx := cmd.Context()
 694 			be := backend.FromContext(ctx)
 695@@ -110,10 +111,9 @@ func UserCommand() *cobra.Command {
 696 	}
 697 
 698 	userRemovePubkeyCommand := &cobra.Command{
 699-		Use:               "remove-pubkey USERNAME AUTHORIZED_KEY",
 700-		Short:             "Remove a public key from a user",
 701-		Args:              cobra.MinimumNArgs(2),
 702-		PersistentPreRunE: checkIfAdmin,
 703+		Use:   "remove-pubkey USERNAME AUTHORIZED_KEY",
 704+		Short: "Remove a public key from a user",
 705+		Args:  cobra.MinimumNArgs(2),
 706 		RunE: func(cmd *cobra.Command, args []string) error {
 707 			ctx := cmd.Context()
 708 			be := backend.FromContext(ctx)
 709@@ -129,10 +129,9 @@ func UserCommand() *cobra.Command {
 710 	}
 711 
 712 	userSetAdminCommand := &cobra.Command{
 713-		Use:               "set-admin USERNAME [true|false]",
 714-		Short:             "Make a user an admin",
 715-		Args:              cobra.ExactArgs(2),
 716-		PersistentPreRunE: checkIfAdmin,
 717+		Use:   "set-admin USERNAME [true|false]",
 718+		Short: "Make a user an admin",
 719+		Args:  cobra.ExactArgs(2),
 720 		RunE: func(cmd *cobra.Command, args []string) error {
 721 			ctx := cmd.Context()
 722 			be := backend.FromContext(ctx)
 723@@ -143,10 +142,9 @@ func UserCommand() *cobra.Command {
 724 	}
 725 
 726 	userInfoCommand := &cobra.Command{
 727diff --git a/pkg/ssh/cmd/webhooks.go b/pkg/ssh/cmd/webhooks.go
 728index 6312b909baac7c31fd246683dba33284226778d9..e00effaae538fbf2baa4445481bc1a3b0f07bd78 100644
 729--- a/pkg/ssh/cmd/webhooks.go
 730+++ b/pkg/ssh/cmd/webhooks.go
 731@@ -47,7 +47,7 @@ func webhookListCommand() *cobra.Command {
 732 		Use:               "list REPOSITORY",
 733 		Short:             "List repository webhooks",
 734 		Args:              cobra.ExactArgs(1),
 735-		PersistentPreRunE: checkIfAdmin,
 736+		PersistentPreRunE: checkIfRepoAdmin,
 737 		RunE: func(cmd *cobra.Command, args []string) error {
 738 			ctx := cmd.Context()
 739 			be := backend.FromContext(ctx)
 740@@ -94,7 +94,7 @@ func webhookCreateCommand() *cobra.Command {
 741 		Use:               "create REPOSITORY URL",
 742 		Short:             "Create a repository webhook",
 743 		Args:              cobra.ExactArgs(2),
 744-		PersistentPreRunE: checkIfAdmin,
 745+		PersistentPreRunE: checkIfRepoAdmin,
 746 		RunE: func(cmd *cobra.Command, args []string) error {
 747 			ctx := cmd.Context()
 748 			be := backend.FromContext(ctx)
 749@@ -141,7 +141,7 @@ func webhookDeleteCommand() *cobra.Command {
 750 		Use:               "delete REPOSITORY WEBHOOK_ID",
 751 		Short:             "Delete a repository webhook",
 752 		Args:              cobra.ExactArgs(2),
 753-		PersistentPreRunE: checkIfAdmin,
 754+		PersistentPreRunE: checkIfRepoAdmin,
 755 		RunE: func(cmd *cobra.Command, args []string) error {
 756 			ctx := cmd.Context()
 757 			be := backend.FromContext(ctx)
 758@@ -172,7 +172,7 @@ func webhookUpdateCommand() *cobra.Command {
 759 		Use:               "update REPOSITORY WEBHOOK_ID",
 760 		Short:             "Update a repository webhook",
 761 		Args:              cobra.ExactArgs(2),
 762-		PersistentPreRunE: checkIfAdmin,
 763+		PersistentPreRunE: checkIfRepoAdmin,
 764 		RunE: func(cmd *cobra.Command, args []string) error {
 765 			ctx := cmd.Context()
 766 			be := backend.FromContext(ctx)
 767@@ -274,16 +274,21 @@ func webhookDeliveriesListCommand() *cobra.Command {
 768 		Use:               "list REPOSITORY WEBHOOK_ID",
 769 		Short:             "List webhook deliveries",
 770 		Args:              cobra.ExactArgs(2),
 771-		PersistentPreRunE: checkIfAdmin,
 772+		PersistentPreRunE: checkIfRepoAdmin,
 773 		RunE: func(cmd *cobra.Command, args []string) error {
 774 			ctx := cmd.Context()
 775 			be := backend.FromContext(ctx)
 776+			repo, err := be.Repository(ctx, args[0])
 777+			if err != nil {
 778+				return err
 779+			}
 780+
 781 			id, err := strconv.ParseInt(args[1], 10, 64)
 782 			if err != nil {
 783 				return fmt.Errorf("invalid webhook ID: %w", err)
 784 			}
 785 
 786-			dels, err := be.ListWebhookDeliveries(ctx, id)
 787+			dels, err := be.ListWebhookDeliveries(ctx, repo, id)
 788 			if err != nil {
 789 				return err
 790 			}
 791@@ -314,7 +319,7 @@ func webhookDeliveriesRedeliverCommand() *cobra.Command {
 792 		Use:               "redeliver REPOSITORY WEBHOOK_ID DELIVERY_ID",
 793 		Short:             "Redeliver a webhook delivery",
 794 		Args:              cobra.ExactArgs(3),
 795-		PersistentPreRunE: checkIfAdmin,
 796+		PersistentPreRunE: checkIfRepoAdmin,
 797 		RunE: func(cmd *cobra.Command, args []string) error {
 798 			ctx := cmd.Context()
 799 			be := backend.FromContext(ctx)
 800@@ -345,10 +350,15 @@ func webhookDeliveriesGetCommand() *cobra.Command {
 801 		Use:               "get REPOSITORY WEBHOOK_ID DELIVERY_ID",
 802 		Short:             "Get a webhook delivery",
 803 		Args:              cobra.ExactArgs(3),
 804-		PersistentPreRunE: checkIfAdmin,
 805+		PersistentPreRunE: checkIfRepoAdmin,
 806 		RunE: func(cmd *cobra.Command, args []string) error {
 807 			ctx := cmd.Context()
 808 			be := backend.FromContext(ctx)
 809+			repo, err := be.Repository(ctx, args[0])
 810+			if err != nil {
 811+				return err
 812+			}
 813+
 814 			id, err := strconv.ParseInt(args[1], 10, 64)
 815 			if err != nil {
 816 				return fmt.Errorf("invalid webhook ID: %w", err)
 817@@ -359,7 +369,7 @@ func webhookDeliveriesGetCommand() *cobra.Command {
 818 				return fmt.Errorf("invalid delivery ID: %w", err)
 819 			}
 820 
 821-			del, err := be.WebhookDelivery(ctx, id, delID)
 822+			del, err := be.WebhookDelivery(ctx, repo, id, delID)
 823 			if err != nil {
 824 				return err
 825 			}
 826diff --git a/pkg/ssh/cmd/webhooks_test.go b/pkg/ssh/cmd/webhooks_test.go
 827new file mode 100644
 828index 0000000000000000000000000000000000000000..0be7d69a18881cafe57b764c77decb3a56426d93
 829--- /dev/null
 830+++ b/pkg/ssh/cmd/webhooks_test.go
 831@@ -0,0 +1,100 @@
 832+package cmd
 833+
 834+import (
 835+	"strconv"
 836+	"strings"
 837+	"testing"
 838+
 839+	"github.com/charmbracelet/soft-serve/pkg/db"
 840+	"github.com/charmbracelet/soft-serve/pkg/proto"
 841+	"github.com/charmbracelet/soft-serve/pkg/store"
 842+	"github.com/charmbracelet/soft-serve/pkg/webhook"
 843+	"github.com/google/uuid"
 844+	"github.com/matryer/is"
 845+	_ "modernc.org/sqlite"
 846+)
 847+
 848+// TestWebhookDeliveriesAreScopedToRepository verifies that webhook deliveries
 849+// cannot be read across repositories.
 850+//
 851+// The `repo webhook deliveries list|get` commands authorize the caller against
 852+// the REPOSITORY argument, but the delivery lookup used to be keyed only on
 853+// the numeric webhook ID. Since any user who owns a repository has admin
 854+// access to it, a user could pass their own repository name together with
 855+// another repository's webhook ID and read that webhook's stored deliveries.
 856+//
 857+// Deliveries store the full request URL, headers (including the HMAC
 858+// signature), request body, and response body, so this is a cross-repository
 859+// disclosure of private repository event payloads.
 860+func TestWebhookDeliveriesAreScopedToRepository(t *testing.T) {
 861+	is := is.New(t)
 862+	ctx, be := newAuthTestContext(t)
 863+
 864+	// The victim owns a private repository with a webhook, and that webhook
 865+	// has a recorded delivery containing sensitive request/response data.
 866+	victimCtx := withUser(t, ctx, be, "victim", false)
 867+	victim := proto.UserFromContext(victimCtx)
 868+	victimRepo, err := be.CreateRepository(victimCtx, "victim-repo", victim, proto.RepositoryOptions{Private: true})
 869+	is.NoErr(err)
 870+	is.NoErr(be.CreateWebhook(victimCtx, victimRepo, "http://example.com/hook", webhook.ContentTypeJSON, "s3cret", []webhook.Event{webhook.EventPush}, true))
 871+
 872+	victimHooks, err := be.ListWebhooks(victimCtx, victimRepo)
 873+	is.NoErr(err)
 874+	is.Equal(len(victimHooks), 1)
 875+	victimHookID := victimHooks[0].ID
 876+
 877+	const (
 878+		secretBody      = "private-repo-payload"
 879+		secretSignature = "X-SoftServe-Signature: sha256=leaked-signature\n"
 880+	)
 881+	deliveryID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
 882+	is.NoErr(store.FromContext(ctx).CreateWebhookDelivery(
 883+		ctx, db.FromContext(ctx), deliveryID, victimHookID, int(webhook.EventPush),
 884+		"http://example.com/hook", "POST", nil,
 885+		secretSignature, secretBody, 200, "Content-Type: text/plain\n", "victim response body",
 886+	))
 887+
 888+	// The attacker owns their own repository, so they hold admin access to
 889+	// it, but they have no access at all to the victim's repository.
 890+	attackerCtx := withUser(t, ctx, be, "attacker", false)
 891+	attacker := proto.UserFromContext(attackerCtx)
 892+	_, err = be.CreateRepository(attackerCtx, "attacker-repo", attacker, proto.RepositoryOptions{})
 893+	is.NoErr(err)
 894+
 895+	hookID := strconv.FormatInt(victimHookID, 10)
 896+
 897+	// Listing deliveries by naming their own repository must not enumerate
 898+	// the victim webhook's delivery IDs.
 899+	stdout, _, err := runRepoOutput(t, attackerCtx, "webhook", "deliveries", "list", "attacker-repo", hookID)
 900+	if err == nil {
 901+		t.Error("expected error listing another repository's webhook deliveries")
 902+	}
 903+	if strings.Contains(stdout, deliveryID.String()) {
 904+		t.Errorf("leaked delivery ID in output: %q", stdout)
 905+	}
 906+
 907+	// Reading a specific delivery must not disclose its stored request or
 908+	// response data either.
 909+	stdout, _, err = runRepoOutput(t, attackerCtx, "webhook", "deliveries", "get", "attacker-repo", hookID, deliveryID.String())
 910+	if err == nil {
 911+		t.Error("expected error getting another repository's webhook delivery")
 912+	}
 913+	for _, secret := range []string{secretBody, "leaked-signature", "victim response body"} {
 914+		if strings.Contains(stdout, secret) {
 915+			t.Errorf("leaked %q in output: %q", secret, stdout)
 916+		}
 917+	}
 918+
 919+	// The owner can still read their own webhook's deliveries.
 920+	stdout, _, err = runRepoOutput(t, victimCtx, "webhook", "deliveries", "list", "victim-repo", hookID)
 921+	is.NoErr(err)
 922+	if !strings.Contains(stdout, deliveryID.String()) {
 923+		t.Errorf("owner could not list own deliveries, got: %q", stdout)
 924+	}
 925+
 926+	stdout, _, err = runRepoOutput(t, victimCtx, "webhook", "deliveries", "get", "victim-repo", hookID, deliveryID.String())
 927+	is.NoErr(err)
 928+	if !strings.Contains(stdout, secretBody) {
 929+		t.Errorf("owner could not read own delivery body, got: %q", stdout)
 930+	}
 931diff --git a/testscript/testdata/privilege-escalation-regression.txtar b/testscript/testdata/privilege-escalation-regression.txtar
 932new file mode 100644
 933index 0000000000000000000000000000000000000000..cc0359ea3267ca01001a2edeff7f82398e69d958
 934--- /dev/null
 935+++ b/testscript/testdata/privilege-escalation-regression.txtar
 936@@ -0,0 +1,115 @@
 937+# vi: set ft=conf
 938+# Regression test for privilege escalation via repository-scoped auth checks
 939+#
 940+# VULNERABILITY DESCRIPTION:
 941+# Global admin commands (`user`, `settings`) were gated by a helper written
 942+# for repository-scoped commands. That helper granted access when the caller
 943+# had admin access to a repository *named by the command's first argument*.
 944+# Since any authenticated user can create a repository, and the owner of a
 945+# repository has admin access to it, a regular user could satisfy the check
 946+# for a global command simply by creating a repository with the right name.
 947+#
 948+# ATTACK SCENARIO:
 949+# 1. Attacker registers an SSH key (or is given any normal account)
 950+# 2. Attacker runs `repo create victim`, becoming owner and therefore
 951+#    repo-admin of "victim"
 952+# 3. Attacker runs `user set-admin victim true`. The auth check reads the
 953+#    first argument "victim" as a repository name, sees repo-admin, allows it
 954+# 4. The account "victim" is now a global server administrator
 955+#
 956+# A related issue let a read-write collaborator grant admin-access to any
 957+# account, and remove collaborators ranked above themselves.
 958+#
 959+# THIS TEST VERIFIES:
 960+# - A non-admin cannot reach `user` or `settings` commands by creating a
 961+#   repository named after the command argument
 962+# - A read-write collaborator cannot grant access above their own level
 963+# - A read-write collaborator cannot remove or demote a higher-ranked
 964+#   collaborator
 965+# - Webhook deliveries cannot be read across repositories
 966+
 967+# start soft serve
 968+exec soft serve &
 969+# wait for SSH server to start
 970+ensureserverrunning SSH_PORT
 971+
 972+# create regular, non-admin users
 973+soft user create user1 --key "$USER1_AUTHORIZED_KEY"
 974+soft user create boss --key "$ATTACKER_AUTHORIZED_KEY"
 975+soft user info user1
 976+stdout 'Admin: false'
 977+
 978+# The attacker creates repositories named after the arguments they intend to
 979+# pass to the global commands. They own these, so they are repo-admin of them.
 980+usoft repo create user1
 981+usoft repo create victim
 982+usoft repo create true
 983+usoft repo create admin-access
 984+usoft repo create admin
 985+
 986+# Repo-admin access must not authorize global user commands.
 987+! usoft user set-admin user1 true
 988+! usoft user set-admin victim true
 989+! usoft user create victim
 990+! usoft user delete admin
 991+! usoft user list
 992+! usoft user info admin
 993+! usoft user set-username admin victim
 994+! usoft user add-pubkey admin "$ADMIN2_AUTHORIZED_KEY"
 995+! usoft user remove-pubkey admin "$ADMIN1_AUTHORIZED_KEY"
 996+
 997+# Repo-admin access must not authorize global settings commands.
 998+! usoft settings anon-access admin-access
 999+! usoft settings anon-access read-write
1000+! usoft settings allow-keyless true
1001+
1002+# a placeholder to reset stderr
1003+soft help
1004+
1005+# The attacker is still not an admin, and settings are unchanged.
1006+soft user info user1
1007+stdout 'Admin: false'
1008+soft settings anon-access
1009+stdout 'read-only.*'
1010+
1011+# Collaborator grants are bounded by the caller's own access level.
1012+soft repo create shared
1013+soft repo collab add shared user1 read-write
1014+soft repo collab add shared boss admin-access
1015+
1016+# user1 is read-write on "shared" and must not be able to grant admin-access.
1017+! usoft repo collab add shared user1 admin-access
1018+# ...nor remove a collaborator ranked above them.
1019+! usoft repo collab remove shared boss
1020+# ...nor demote one by overwriting the grant.
1021+! usoft repo collab add shared boss read-only
1022+
1023+# a placeholder to reset stderr
1024+soft help
1025+
1026+# boss is still an admin-access collaborator, and user1 did not escalate.
1027+soft repo collab list shared
1028+stdout 'boss'
1029+
1030+# Granting at or below the caller's own level still works.
1031+soft user create peer
1032+usoft repo collab add shared peer read-only
1033+soft repo collab list shared
1034+stdout 'peer'
1035+