detached 12d3d1e

fix(usecase): refuse repository settings changes to branch-limited tokens

Updating a repository (default branch, visibility, description, merge modes,
failing-checks rule) decides what happens to branches a limited token may not
write. canDeleteRepo becomes canAdminRepo and gates Update as well as Delete.
commit
12d3d1ef62ab1348b6337d21fa06304d1047e95a
author
brice · · 2026-10-12 15:16 UTC
parent
cb46f91
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +39 -6
M internal/app/e2e_branches_test.go +9 -0
@@ -10,6 +10,7 @@ import (
1010 "connectrpc.com/connect"
1111 "github.com/stretchr/testify/assert"
1212 "github.com/stretchr/testify/require"
13+ "google.golang.org/protobuf/types/known/fieldmaskpb"
1314
1415 pb "bnema.dev/ermite-api/gen/ermite"
1516 "bnema.dev/ermite/internal/domain"
@@ -121,6 +122,14 @@ func TestAC5BranchLimitedToken(t *testing.T) {
121122 _, err = h.api(admin).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "via-api"}))
122123 assert.Equal(t, "permission_denied", codeName(err))
123124 assert.DirExists(t, h.repoDir("via-api"))
125+ // ... nor change its settings (default branch, visibility, ...).
126+ _, err = h.api(admin).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
127+ Repo: "via-api", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
128+ }))
129+ assert.Equal(t, "permission_denied", codeName(err))
130+ got, err := h.api(adminToken).repo.GetRepo(h.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "via-api"}))
131+ require.NoError(t, err)
132+ assert.Equal(t, pb.Visibility_VISIBILITY_PRIVATE, got.Msg.GetRepo().GetVisibility())
124133
125134 // It never has owner rights, whatever its scopes.
126135 all, _ := h.branchToken(domain.AllScopes(), []string{"agent/claude/"})
@@ -10,6 +10,7 @@ import (
10 "connectrpc.com/connect"10 "connectrpc.com/connect"
11 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13+ "google.golang.org/protobuf/types/known/fieldmaskpb"
13 14
14 pb "bnema.dev/ermite-api/gen/ermite"15 pb "bnema.dev/ermite-api/gen/ermite"
15 "bnema.dev/ermite/internal/domain"16 "bnema.dev/ermite/internal/domain"
@@ -121,6 +122,14 @@ func TestAC5BranchLimitedToken(t *testing.T) {
121 _, err = h.api(admin).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "via-api"}))122 _, err = h.api(admin).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "via-api"}))
122 assert.Equal(t, "permission_denied", codeName(err))123 assert.Equal(t, "permission_denied", codeName(err))
123 assert.DirExists(t, h.repoDir("via-api"))124 assert.DirExists(t, h.repoDir("via-api"))
125+ // ... nor change its settings (default branch, visibility, ...).
126+ _, err = h.api(admin).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
127+ Repo: "via-api", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
128+ }))
129+ assert.Equal(t, "permission_denied", codeName(err))
130+ got, err := h.api(adminToken).repo.GetRepo(h.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "via-api"}))
131+ require.NoError(t, err)
132+ assert.Equal(t, pb.Visibility_VISIBILITY_PRIVATE, got.Msg.GetRepo().GetVisibility())
124 133
125 // It never has owner rights, whatever its scopes.134 // It never has owner rights, whatever its scopes.
126 all, _ := h.branchToken(domain.AllScopes(), []string{"agent/claude/"})135 all, _ := h.branchToken(domain.AllScopes(), []string{"agent/claude/"})
M internal/usecase/access.go +7 -4
@@ -55,10 +55,13 @@ func canCreateRepo(p domain.Principal, name domain.RepoName) bool {
5555 return canWrite(p, name) && len(p.BranchPrefixes()) == 0
5656 }
5757
58-// canDeleteRepo is the access rule for deleting a repository on top of
59-// adminAccess: no branch prefixes. Deleting a repository deletes every branch,
60-// those outside the prefixes of a limited token included.
61-func canDeleteRepo(p domain.Principal) bool {
58+// canAdminRepo is the access rule for changing the settings of a repository
59+// or deleting it, on top of adminAccess: no branch prefixes. A token limited to
60+// some branches may not delete the repository (that deletes every branch, those
61+// outside its prefixes included), nor change its default branch, visibility,
62+// description, merge modes or failing-checks rule: these settings decide what
63+// happens to branches the token may not write.
64+func canAdminRepo(p domain.Principal) bool {
6265 return len(p.BranchPrefixes()) == 0
6366 }
6467
@@ -55,10 +55,13 @@ func canCreateRepo(p domain.Principal, name domain.RepoName) bool {
55 return canWrite(p, name) && len(p.BranchPrefixes()) == 055 return canWrite(p, name) && len(p.BranchPrefixes()) == 0
56 }56 }
57 57
58-// canDeleteRepo is the access rule for deleting a repository on top of58+// canAdminRepo is the access rule for changing the settings of a repository
59-// adminAccess: no branch prefixes. Deleting a repository deletes every branch,59+// or deleting it, on top of adminAccess: no branch prefixes. A token limited to
60-// those outside the prefixes of a limited token included.60+// some branches may not delete the repository (that deletes every branch, those
61-func canDeleteRepo(p domain.Principal) bool {61+// outside its prefixes included), nor change its default branch, visibility,
62+// description, merge modes or failing-checks rule: these settings decide what
63+// happens to branches the token may not write.
64+func canAdminRepo(p domain.Principal) bool {
62 return len(p.BranchPrefixes()) == 065 return len(p.BranchPrefixes()) == 0
63 }66 }
64 67
M internal/usecase/repos_write.go +5 -2
@@ -46,7 +46,7 @@ func (r *Repos) View(ctx context.Context, repo domain.Repo) (ports.RepoView, err
4646
4747 // Update changes the description, the visibility, the default branch and the
4848 // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it
49-// (see adminAccess for what the others learn). At least one field must be set,
49+// (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set,
5050 // else domain.ErrInvalid. A default branch that does not exist gives
5151 // domain.ErrPreconditionFailed; nothing is written before every check passed.
5252 // The metadata is written first, then HEAD, and RepoUpdated is published after
@@ -60,6 +60,9 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
6060 if err := adminAccess(p, repo); err != nil {
6161 return domain.Repo{}, err
6262 }
63+ if !canAdminRepo(p) {
64+ return domain.Repo{}, fmt.Errorf("%w: a token limited to branch prefixes may not change repository settings", domain.ErrForbidden)
65+ }
6366 before := repo
6467 if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil &&
6568 params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {
@@ -184,7 +187,7 @@ func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.Repo
184187 if err := adminAccess(p, repo); err != nil {
185188 return err
186189 }
187- if !canDeleteRepo(p) {
190+ if !canAdminRepo(p) {
188191 return fmt.Errorf("%w: a token limited to branch prefixes may not delete a repository", domain.ErrForbidden)
189192 }
190193 log := zerowrap.FromCtx(ctx)
@@ -46,7 +46,7 @@ func (r *Repos) View(ctx context.Context, repo domain.Repo) (ports.RepoView, err
46 46
47 // Update changes the description, the visibility, the default branch and the47 // Update changes the description, the visibility, the default branch and the
48 // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it48 // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it
49-// (see adminAccess for what the others learn). At least one field must be set,49+// (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set,
50 // else domain.ErrInvalid. A default branch that does not exist gives50 // else domain.ErrInvalid. A default branch that does not exist gives
51 // domain.ErrPreconditionFailed; nothing is written before every check passed.51 // domain.ErrPreconditionFailed; nothing is written before every check passed.
52 // The metadata is written first, then HEAD, and RepoUpdated is published after52 // The metadata is written first, then HEAD, and RepoUpdated is published after
@@ -60,6 +60,9 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
60 if err := adminAccess(p, repo); err != nil {60 if err := adminAccess(p, repo); err != nil {
61 return domain.Repo{}, err61 return domain.Repo{}, err
62 }62 }
63+ if !canAdminRepo(p) {
64+ return domain.Repo{}, fmt.Errorf("%w: a token limited to branch prefixes may not change repository settings", domain.ErrForbidden)
65+ }
63 before := repo66 before := repo
64 if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil &&67 if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65 params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {68 params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {
@@ -184,7 +187,7 @@ func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.Repo
184 if err := adminAccess(p, repo); err != nil {187 if err := adminAccess(p, repo); err != nil {
185 return err188 return err
186 }189 }
187- if !canDeleteRepo(p) {190+ if !canAdminRepo(p) {
188 return fmt.Errorf("%w: a token limited to branch prefixes may not delete a repository", domain.ErrForbidden)191 return fmt.Errorf("%w: a token limited to branch prefixes may not delete a repository", domain.ErrForbidden)
189 }192 }
190 log := zerowrap.FromCtx(ctx)193 log := zerowrap.FromCtx(ctx)
M internal/usecase/repos_write_test.go +18 -0
@@ -165,6 +165,24 @@ func TestUpdate(t *testing.T) {
165165 })
166166 }
167167
168+ t.Run("a token limited to branches may not change any setting", func(t *testing.T) {
169+ t.Parallel()
170+ modes := []domain.MergeMode{domain.MergeModeSquash}
171+ block := true
172+ for name, params := range map[string]ports.UpdateRepoParams{
173+ "default branch": {DefaultBranch: ptr("dev")},
174+ "visibility": {Visibility: ptr(domain.VisibilityPublic)},
175+ "description": {Description: ptr("x")},
176+ "merge modes": {AllowedMergeModes: &modes},
177+ "checks rule": {FailingChecksBlock: &block},
178+ } {
179+ f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
180+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
181+ _, err := f.repos.Update(ctx, branchLimited(domain.ScopeRepoAdmin), n, params)
182+ assert.ErrorIs(t, err, domain.ErrForbidden, name)
183+ }
184+ })
185+
168186 t.Run("a token limited to branches may not delete a repository", func(t *testing.T) {
169187 t.Parallel()
170188 f := newFixture(t) // no Trash, no Delete
@@ -165,6 +165,24 @@ func TestUpdate(t *testing.T) {
165 })165 })
166 }166 }
167 167
168+ t.Run("a token limited to branches may not change any setting", func(t *testing.T) {
169+ t.Parallel()
170+ modes := []domain.MergeMode{domain.MergeModeSquash}
171+ block := true
172+ for name, params := range map[string]ports.UpdateRepoParams{
173+ "default branch": {DefaultBranch: ptr("dev")},
174+ "visibility": {Visibility: ptr(domain.VisibilityPublic)},
175+ "description": {Description: ptr("x")},
176+ "merge modes": {AllowedMergeModes: &modes},
177+ "checks rule": {FailingChecksBlock: &block},
178+ } {
179+ f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
180+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
181+ _, err := f.repos.Update(ctx, branchLimited(domain.ScopeRepoAdmin), n, params)
182+ assert.ErrorIs(t, err, domain.ErrForbidden, name)
183+ }
184+ })
185+
168 t.Run("a token limited to branches may not delete a repository", func(t *testing.T) {186 t.Run("a token limited to branches may not delete a repository", func(t *testing.T) {
169 t.Parallel()187 t.Parallel()
170 f := newFixture(t) // no Trash, no Delete188 f := newFixture(t) // no Trash, no Delete

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close