detached 9dac236

feat(usecase): limit tokens to branch prefixes

A token with branch prefixes pushes, force-pushes and deletes only
refs/heads/<prefix>*: any other ref refuses the whole push in pre-receive.
It cannot create a repository (push-to-create or API), merge into or delete
a branch outside its prefixes through a merge request, delete a repository,
or create a token wider than itself.
commit
9dac2367ab840cfb4e29a1e41fdea8ae928de427
author
brice · · 2026-10-12 14:53 UTC
parent
e0bea3d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +386 -13
M internal/ports/dto.go +3 -0
@@ -61,6 +61,9 @@ type CreateTokenParams struct {
6161 Scopes []domain.Scope
6262 // Repos limits the token to these repositories; empty means every one.
6363 Repos []domain.RepoName
64+ // BranchPrefixes limits the token to the branches under these prefixes
65+ // (see domain.ParseBranchPrefix); empty means every branch.
66+ BranchPrefixes []string
6467 // ExpiresAt is the zero time for a token that never expires.
6568 ExpiresAt time.Time
6669 }
@@ -61,6 +61,9 @@ type CreateTokenParams struct {
61 Scopes []domain.Scope61 Scopes []domain.Scope
62 // Repos limits the token to these repositories; empty means every one.62 // Repos limits the token to these repositories; empty means every one.
63 Repos []domain.RepoName63 Repos []domain.RepoName
64+ // BranchPrefixes limits the token to the branches under these prefixes
65+ // (see domain.ParseBranchPrefix); empty means every branch.
66+ BranchPrefixes []string
64 // ExpiresAt is the zero time for a token that never expires.67 // ExpiresAt is the zero time for a token that never expires.
65 ExpiresAt time.Time68 ExpiresAt time.Time
66 }69 }
M internal/usecase/access.go +14 -0
@@ -48,6 +48,20 @@ func canWrite(p domain.Principal, name domain.RepoName) bool {
4848 return p.Allows(domain.ScopeRepoWrite, name)
4949 }
5050
51+// canCreateRepo is the access rule for creating a repository, by the API or on
52+// first push: repo:write on the name, and no branch prefixes. A token limited
53+// to some branches may push to a repository that exists; it never makes one.
54+func canCreateRepo(p domain.Principal, name domain.RepoName) bool {
55+ return canWrite(p, name) && len(p.BranchPrefixes()) == 0
56+}
57+
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 {
62+ return len(p.BranchPrefixes()) == 0
63+}
64+
5165 // canManageTokens is the access rule for creating, listing and revoking
5266 // stored tokens: token:admin, which root holds.
5367 func canManageTokens(p domain.Principal) bool {
@@ -48,6 +48,20 @@ func canWrite(p domain.Principal, name domain.RepoName) bool {
48 return p.Allows(domain.ScopeRepoWrite, name)48 return p.Allows(domain.ScopeRepoWrite, name)
49 }49 }
50 50
51+// canCreateRepo is the access rule for creating a repository, by the API or on
52+// first push: repo:write on the name, and no branch prefixes. A token limited
53+// to some branches may push to a repository that exists; it never makes one.
54+func canCreateRepo(p domain.Principal, name domain.RepoName) bool {
55+ return canWrite(p, name) && len(p.BranchPrefixes()) == 0
56+}
57+
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 {
62+ return len(p.BranchPrefixes()) == 0
63+}
64+
51 // canManageTokens is the access rule for creating, listing and revoking65 // canManageTokens is the access rule for creating, listing and revoking
52 // stored tokens: token:admin, which root holds.66 // stored tokens: token:admin, which root holds.
53 func canManageTokens(p domain.Principal) bool {67 func canManageTokens(p domain.Principal) bool {
M internal/usecase/mergerequests.go +8 -0
@@ -710,6 +710,14 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
710710 if err != nil {
711711 return domain.MergeRequest{}, false, err
712712 }
713+ // A merge moves the target and may delete the source: a token limited to
714+ // branch prefixes may do so only within them.
715+ if !p.AllowsRef("refs/heads/" + mr.Target) {
716+ return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
717+ }
718+ if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) {
719+ return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
720+ }
713721 if !mr.IsOpen() {
714722 return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
715723 }
@@ -710,6 +710,14 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
710 if err != nil {710 if err != nil {
711 return domain.MergeRequest{}, false, err711 return domain.MergeRequest{}, false, err
712 }712 }
713+ // A merge moves the target and may delete the source: a token limited to
714+ // branch prefixes may do so only within them.
715+ if !p.AllowsRef("refs/heads/" + mr.Target) {
716+ return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
717+ }
718+ if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) {
719+ return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
720+ }
713 if !mr.IsOpen() {721 if !mr.IsOpen() {
714 return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)722 return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
715 }723 }
A internal/usecase/mergerequests_branches_test.go +113 -0
@@ -0,0 +1,113 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+// branchMerger holds mr:merge but may only write the branches under
16+// agent/claude/.
17+var branchMerger = domain.TokenPrincipal(domain.Token{
18+ ID: "bm", Label: "claude", BranchPrefixes: []string{"agent/claude/"},
19+ Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge},
20+})
21+
22+// AC5: a merge writes the target, and the source when it is deleted; a token
23+// limited to branch prefixes may do neither outside them.
24+func TestMergeWithBranchLimitedToken(t *testing.T) {
25+ t.Parallel()
26+
27+ t.Run("into main is forbidden and nothing moves", func(t *testing.T) {
28+ t.Parallel()
29+ r := newMRRig(t)
30+ main := r.seed()
31+ head := r.feature("agent/claude/feat")
32+ mr := r.open("agent/claude/feat") // targets main
33+ r.drain()
34+
35+ for _, del := range []bool{false, true} {
36+ _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: del})
37+ require.ErrorIs(t, err, domain.ErrForbidden)
38+ assert.Contains(t, err.Error(), "this token may not write main")
39+ }
40+ assert.Equal(t, main, r.branchHead("main"), "main did not move")
41+ assert.Equal(t, head, r.branchHead("agent/claude/feat"), "the source stays")
42+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
43+ require.NoError(t, err)
44+ assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
45+ assert.Empty(t, r.drain(), "no event")
46+
47+ // The same merge by an unlimited token works.
48+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
49+ require.NoError(t, err)
50+ assert.Equal(t, head, r.branchHead("main"))
51+ })
52+
53+ t.Run("into a branch under the prefixes is allowed, deleting the source too", func(t *testing.T) {
54+ t.Parallel()
55+ r := newMRRig(t)
56+ r.seed()
57+ r.sh("checkout", "-q", "-B", "agent/claude/base", "main")
58+ r.push("agent/claude/base")
59+ r.sh("checkout", "-q", "-B", "agent/claude/feat", "agent/claude/base")
60+ head := r.commit(author, "f.txt", "f\n", "add f")
61+ r.push("agent/claude/feat")
62+ mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{
63+ Title: "feat", Source: "agent/claude/feat", Target: "agent/claude/base",
64+ })
65+ require.NoError(t, err)
66+
67+ res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
68+ require.NoError(t, err)
69+ assert.True(t, res.SourceDeleted)
70+ assert.Equal(t, head, r.branchHead("agent/claude/base"))
71+ _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat")
72+ assert.ErrorIs(t, err, domain.ErrNotFound)
73+ })
74+
75+ t.Run("deleting a source outside the prefixes is forbidden, merging without deleting is not", func(t *testing.T) {
76+ t.Parallel()
77+ r := newMRRig(t)
78+ r.seed()
79+ r.sh("checkout", "-q", "-B", "agent/claude/base", "main")
80+ r.push("agent/claude/base")
81+ base := r.branchHead("agent/claude/base")
82+ r.sh("checkout", "-q", "-B", "other", "agent/claude/base")
83+ head := r.commit(author, "o.txt", "o\n", "add o")
84+ r.push("other")
85+ mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{
86+ Title: "other", Source: "other", Target: "agent/claude/base",
87+ })
88+ require.NoError(t, err)
89+
90+ _, err = r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
91+ require.ErrorIs(t, err, domain.ErrForbidden)
92+ assert.Contains(t, err.Error(), "this token may not write other")
93+ assert.Equal(t, base, r.branchHead("agent/claude/base"), "not merged")
94+ assert.Equal(t, head, r.branchHead("other"), "the source stays")
95+
96+ res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head})
97+ require.NoError(t, err)
98+ assert.False(t, res.SourceDeleted)
99+ assert.Equal(t, head, r.branchHead("agent/claude/base"))
100+ assert.Equal(t, head, r.branchHead("other"), "the source stays")
101+ })
102+
103+ t.Run("a squash into main is forbidden too", func(t *testing.T) {
104+ t.Parallel()
105+ r := newMRRig(t)
106+ main := r.seed()
107+ head := r.feature("agent/claude/feat")
108+ mr := r.open("agent/claude/feat")
109+ _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
110+ require.ErrorIs(t, err, domain.ErrForbidden)
111+ assert.Equal(t, main, r.branchHead("main"))
112+ })
113+}
@@ -0,0 +1,113 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+// branchMerger holds mr:merge but may only write the branches under
16+// agent/claude/.
17+var branchMerger = domain.TokenPrincipal(domain.Token{
18+ ID: "bm", Label: "claude", BranchPrefixes: []string{"agent/claude/"},
19+ Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge},
20+})
21+
22+// AC5: a merge writes the target, and the source when it is deleted; a token
23+// limited to branch prefixes may do neither outside them.
24+func TestMergeWithBranchLimitedToken(t *testing.T) {
25+ t.Parallel()
26+
27+ t.Run("into main is forbidden and nothing moves", func(t *testing.T) {
28+ t.Parallel()
29+ r := newMRRig(t)
30+ main := r.seed()
31+ head := r.feature("agent/claude/feat")
32+ mr := r.open("agent/claude/feat") // targets main
33+ r.drain()
34+
35+ for _, del := range []bool{false, true} {
36+ _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: del})
37+ require.ErrorIs(t, err, domain.ErrForbidden)
38+ assert.Contains(t, err.Error(), "this token may not write main")
39+ }
40+ assert.Equal(t, main, r.branchHead("main"), "main did not move")
41+ assert.Equal(t, head, r.branchHead("agent/claude/feat"), "the source stays")
42+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
43+ require.NoError(t, err)
44+ assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
45+ assert.Empty(t, r.drain(), "no event")
46+
47+ // The same merge by an unlimited token works.
48+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
49+ require.NoError(t, err)
50+ assert.Equal(t, head, r.branchHead("main"))
51+ })
52+
53+ t.Run("into a branch under the prefixes is allowed, deleting the source too", func(t *testing.T) {
54+ t.Parallel()
55+ r := newMRRig(t)
56+ r.seed()
57+ r.sh("checkout", "-q", "-B", "agent/claude/base", "main")
58+ r.push("agent/claude/base")
59+ r.sh("checkout", "-q", "-B", "agent/claude/feat", "agent/claude/base")
60+ head := r.commit(author, "f.txt", "f\n", "add f")
61+ r.push("agent/claude/feat")
62+ mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{
63+ Title: "feat", Source: "agent/claude/feat", Target: "agent/claude/base",
64+ })
65+ require.NoError(t, err)
66+
67+ res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
68+ require.NoError(t, err)
69+ assert.True(t, res.SourceDeleted)
70+ assert.Equal(t, head, r.branchHead("agent/claude/base"))
71+ _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat")
72+ assert.ErrorIs(t, err, domain.ErrNotFound)
73+ })
74+
75+ t.Run("deleting a source outside the prefixes is forbidden, merging without deleting is not", func(t *testing.T) {
76+ t.Parallel()
77+ r := newMRRig(t)
78+ r.seed()
79+ r.sh("checkout", "-q", "-B", "agent/claude/base", "main")
80+ r.push("agent/claude/base")
81+ base := r.branchHead("agent/claude/base")
82+ r.sh("checkout", "-q", "-B", "other", "agent/claude/base")
83+ head := r.commit(author, "o.txt", "o\n", "add o")
84+ r.push("other")
85+ mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{
86+ Title: "other", Source: "other", Target: "agent/claude/base",
87+ })
88+ require.NoError(t, err)
89+
90+ _, err = r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
91+ require.ErrorIs(t, err, domain.ErrForbidden)
92+ assert.Contains(t, err.Error(), "this token may not write other")
93+ assert.Equal(t, base, r.branchHead("agent/claude/base"), "not merged")
94+ assert.Equal(t, head, r.branchHead("other"), "the source stays")
95+
96+ res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head})
97+ require.NoError(t, err)
98+ assert.False(t, res.SourceDeleted)
99+ assert.Equal(t, head, r.branchHead("agent/claude/base"))
100+ assert.Equal(t, head, r.branchHead("other"), "the source stays")
101+ })
102+
103+ t.Run("a squash into main is forbidden too", func(t *testing.T) {
104+ t.Parallel()
105+ r := newMRRig(t)
106+ main := r.seed()
107+ head := r.feature("agent/claude/feat")
108+ mr := r.open("agent/claude/feat")
109+ _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
110+ require.ErrorIs(t, err, domain.ErrForbidden)
111+ assert.Equal(t, main, r.branchHead("main"))
112+ })
113+}
M internal/usecase/pushes.go +13 -4
@@ -69,7 +69,7 @@ func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoN
6969 return fmt.Errorf("get repository %q: %w", name, err)
7070 }
7171
72- if !create || !canWrite(p, name) {
72+ if !create || !canCreateRepo(p, name) {
7373 return domain.ErrNotFound
7474 }
7575 _, err = u.repos.Create(ctx, p, ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate})
@@ -97,9 +97,12 @@ func openExisting(p domain.Principal, repo domain.Repo) error {
9797 return domain.ErrNotFound
9898 }
9999
100-// Check is the pre-receive decision. v1 accepts every push of a principal that
101-// may write the repository; protected branches and limits will refuse here by
102-// returning an error that matches domain.ErrRejected.
100+// Check is the pre-receive decision. It accepts a push of a principal that
101+// may write the repository. A token limited to branch prefixes may only update
102+// (create, move or delete) branches under them: the first ref outside them
103+// refuses the whole push, tags and every other ref included. Further rules
104+// (protected branches, limits) will refuse here too, by returning an error
105+// that matches domain.ErrRejected.
103106 //
104107 // The token of the push was valid when the request began, but a push can run
105108 // for long: it is re-validated here, so a token revoked or expired meanwhile
@@ -114,6 +117,12 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
114117 if !canWrite(p, name) {
115118 return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)
116119 }
120+ for _, up := range updates {
121+ if !p.AllowsRef(up.Ref) {
122+ return fmt.Errorf("%w: %s is outside the branches this token may push (%s)",
123+ domain.ErrRejected, up.Ref, strings.Join(p.BranchPrefixes(), ", "))
124+ }
125+ }
117126 log := zerowrap.FromCtx(ctx)
118127 log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
119128 return nil
@@ -69,7 +69,7 @@ func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoN
69 return fmt.Errorf("get repository %q: %w", name, err)69 return fmt.Errorf("get repository %q: %w", name, err)
70 }70 }
71 71
72- if !create || !canWrite(p, name) {72+ if !create || !canCreateRepo(p, name) {
73 return domain.ErrNotFound73 return domain.ErrNotFound
74 }74 }
75 _, err = u.repos.Create(ctx, p, ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate})75 _, err = u.repos.Create(ctx, p, ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate})
@@ -97,9 +97,12 @@ func openExisting(p domain.Principal, repo domain.Repo) error {
97 return domain.ErrNotFound97 return domain.ErrNotFound
98 }98 }
99 99
100-// Check is the pre-receive decision. v1 accepts every push of a principal that100+// Check is the pre-receive decision. It accepts a push of a principal that
101-// may write the repository; protected branches and limits will refuse here by101+// may write the repository. A token limited to branch prefixes may only update
102-// returning an error that matches domain.ErrRejected.102+// (create, move or delete) branches under them: the first ref outside them
103+// refuses the whole push, tags and every other ref included. Further rules
104+// (protected branches, limits) will refuse here too, by returning an error
105+// that matches domain.ErrRejected.
103 //106 //
104 // The token of the push was valid when the request began, but a push can run107 // The token of the push was valid when the request began, but a push can run
105 // for long: it is re-validated here, so a token revoked or expired meanwhile108 // for long: it is re-validated here, so a token revoked or expired meanwhile
@@ -114,6 +117,12 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
114 if !canWrite(p, name) {117 if !canWrite(p, name) {
115 return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)118 return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)
116 }119 }
120+ for _, up := range updates {
121+ if !p.AllowsRef(up.Ref) {
122+ return fmt.Errorf("%w: %s is outside the branches this token may push (%s)",
123+ domain.ErrRejected, up.Ref, strings.Join(p.BranchPrefixes(), ", "))
124+ }
125+ }
117 log := zerowrap.FromCtx(ctx)126 log := zerowrap.FromCtx(ctx)
118 log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")127 log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
119 return nil128 return nil
M internal/usecase/pushes_test.go +83 -0
@@ -76,6 +76,9 @@ func TestPushesOpen(t *testing.T) {
7676 {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound},
7777 {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound},
7878 {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound},
79+ {"existing, writer limited to branches", branchLimited(domain.ScopeRepoWrite), &priv, false, nil},
80+ {"missing, writer limited to branches may not create", branchLimited(domain.ScopeRepoWrite), nil, true, domain.ErrNotFound},
81+ {"missing, writer limited to branches and to the repo may not create", branchLimited(domain.ScopeRepoWrite, "tools"), nil, true, domain.ErrNotFound},
7982 }
8083 for _, tt := range tests {
8184 t.Run(tt.name, func(t *testing.T) {
@@ -203,6 +206,86 @@ func TestPushesCheck(t *testing.T) {
203206 })
204207 }
205208
209+// AC5: a token limited to branch prefixes pushes only under them.
210+func TestPushesCheckBranchPrefixes(t *testing.T) {
211+ t.Parallel()
212+ ctx := context.Background()
213+ n := name(t, "tools")
214+ limited := domain.TokenPrincipal(domain.Token{
215+ ID: "b1", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"agent/claude/", "bot/"},
216+ })
217+ up := func(old, new, ref string) domain.RefUpdate { return domain.RefUpdate{Old: old, New: new, Ref: ref} }
218+
219+ tests := []struct {
220+ name string
221+ updates []domain.RefUpdate
222+ refused string // the ref named by the error; "" = accepted
223+ }{
224+ {"create under the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/x")}, ""},
225+ {"force-push under the prefix", []domain.RefUpdate{up(oidA, oidB, "refs/heads/agent/claude/x")}, ""},
226+ {"delete under the prefix", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/agent/claude/x")}, ""},
227+ {"second prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/bot/y")}, ""},
228+ {"several under the prefixes", []domain.RefUpdate{
229+ up(domain.ZeroOID, oidA, "refs/heads/agent/claude/a"), up(domain.ZeroOID, oidA, "refs/heads/bot/b"),
230+ }, ""},
231+ {"main", []domain.RefUpdate{up(oidA, oidB, "refs/heads/main")}, "refs/heads/main"},
232+ {"delete main", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/main")}, "refs/heads/main"},
233+ {"the prefix itself without a name", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude")}, "refs/heads/agent/claude"},
234+ {"a sibling that shares the text", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claudex/y")}, "refs/heads/agent/claudex/y"},
235+ {"a tag", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}, "refs/tags/v1"},
236+ {"a tag named like the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/agent/claude/v1")}, "refs/tags/agent/claude/v1"},
237+ {"a notes ref", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/notes/commits")}, "refs/notes/commits"},
238+ {"a ref outside refs/heads", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/agent/claude/x")}, "refs/agent/claude/x"},
239+ {"a path trick", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/../main")}, "refs/heads/agent/claude/../main"},
240+ {"mixed push: one allowed, one forbidden", []domain.RefUpdate{
241+ up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"), up(oidA, oidB, "refs/heads/main"),
242+ }, "refs/heads/main"},
243+ {"mixed push, forbidden first", []domain.RefUpdate{
244+ up(oidA, oidB, "refs/heads/main"), up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"),
245+ }, "refs/heads/main"},
246+ }
247+ for _, tt := range tests {
248+ t.Run(tt.name, func(t *testing.T) {
249+ t.Parallel()
250+ f := newPushFixture(t)
251+ f.tokens.EXPECT().Get(ctx, "b1").Return(domain.Token{ID: "b1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
252+ err := f.pushes.Check(ctx, limited, n, tt.updates)
253+ if tt.refused == "" {
254+ require.NoError(t, err)
255+ return
256+ }
257+ require.ErrorIs(t, err, domain.ErrRejected)
258+ assert.Contains(t, err.Error(), tt.refused+" is outside the branches this token may push (agent/claude/, bot/)")
259+ })
260+ }
261+
262+ t.Run("a token without limit pushes anywhere", func(t *testing.T) {
263+ t.Parallel()
264+ f := newPushFixture(t)
265+ f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
266+ free := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)})
267+ require.NoError(t, f.pushes.Check(ctx, free, n, []domain.RefUpdate{
268+ up(domain.ZeroOID, oidA, "refs/tags/v1"), up(oidA, oidB, "refs/heads/main"),
269+ }))
270+ })
271+
272+ t.Run("root pushes anywhere", func(t *testing.T) {
273+ t.Parallel()
274+ f := newPushFixture(t)
275+ require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}))
276+ })
277+
278+ t.Run("a limited token without repo:write is rejected for the repository first", func(t *testing.T) {
279+ t.Parallel()
280+ f := newPushFixture(t)
281+ f.tokens.EXPECT().Get(ctx, "b2").Return(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead)}, nil)
282+ p := domain.TokenPrincipal(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"a/"}})
283+ err := f.pushes.Check(ctx, p, n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/a/x")})
284+ require.ErrorIs(t, err, domain.ErrRejected)
285+ assert.Contains(t, err.Error(), "no write access")
286+ })
287+}
288+
206289 func TestPushesRecord(t *testing.T) {
207290 t.Parallel()
208291 ctx := context.Background()
@@ -76,6 +76,9 @@ func TestPushesOpen(t *testing.T) {
76 {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound},76 {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound},
77 {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound},77 {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound},
78 {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound},78 {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound},
79+ {"existing, writer limited to branches", branchLimited(domain.ScopeRepoWrite), &priv, false, nil},
80+ {"missing, writer limited to branches may not create", branchLimited(domain.ScopeRepoWrite), nil, true, domain.ErrNotFound},
81+ {"missing, writer limited to branches and to the repo may not create", branchLimited(domain.ScopeRepoWrite, "tools"), nil, true, domain.ErrNotFound},
79 }82 }
80 for _, tt := range tests {83 for _, tt := range tests {
81 t.Run(tt.name, func(t *testing.T) {84 t.Run(tt.name, func(t *testing.T) {
@@ -203,6 +206,86 @@ func TestPushesCheck(t *testing.T) {
203 })206 })
204 }207 }
205 208
209+// AC5: a token limited to branch prefixes pushes only under them.
210+func TestPushesCheckBranchPrefixes(t *testing.T) {
211+ t.Parallel()
212+ ctx := context.Background()
213+ n := name(t, "tools")
214+ limited := domain.TokenPrincipal(domain.Token{
215+ ID: "b1", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"agent/claude/", "bot/"},
216+ })
217+ up := func(old, new, ref string) domain.RefUpdate { return domain.RefUpdate{Old: old, New: new, Ref: ref} }
218+
219+ tests := []struct {
220+ name string
221+ updates []domain.RefUpdate
222+ refused string // the ref named by the error; "" = accepted
223+ }{
224+ {"create under the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/x")}, ""},
225+ {"force-push under the prefix", []domain.RefUpdate{up(oidA, oidB, "refs/heads/agent/claude/x")}, ""},
226+ {"delete under the prefix", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/agent/claude/x")}, ""},
227+ {"second prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/bot/y")}, ""},
228+ {"several under the prefixes", []domain.RefUpdate{
229+ up(domain.ZeroOID, oidA, "refs/heads/agent/claude/a"), up(domain.ZeroOID, oidA, "refs/heads/bot/b"),
230+ }, ""},
231+ {"main", []domain.RefUpdate{up(oidA, oidB, "refs/heads/main")}, "refs/heads/main"},
232+ {"delete main", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/main")}, "refs/heads/main"},
233+ {"the prefix itself without a name", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude")}, "refs/heads/agent/claude"},
234+ {"a sibling that shares the text", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claudex/y")}, "refs/heads/agent/claudex/y"},
235+ {"a tag", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}, "refs/tags/v1"},
236+ {"a tag named like the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/agent/claude/v1")}, "refs/tags/agent/claude/v1"},
237+ {"a notes ref", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/notes/commits")}, "refs/notes/commits"},
238+ {"a ref outside refs/heads", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/agent/claude/x")}, "refs/agent/claude/x"},
239+ {"a path trick", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/../main")}, "refs/heads/agent/claude/../main"},
240+ {"mixed push: one allowed, one forbidden", []domain.RefUpdate{
241+ up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"), up(oidA, oidB, "refs/heads/main"),
242+ }, "refs/heads/main"},
243+ {"mixed push, forbidden first", []domain.RefUpdate{
244+ up(oidA, oidB, "refs/heads/main"), up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"),
245+ }, "refs/heads/main"},
246+ }
247+ for _, tt := range tests {
248+ t.Run(tt.name, func(t *testing.T) {
249+ t.Parallel()
250+ f := newPushFixture(t)
251+ f.tokens.EXPECT().Get(ctx, "b1").Return(domain.Token{ID: "b1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
252+ err := f.pushes.Check(ctx, limited, n, tt.updates)
253+ if tt.refused == "" {
254+ require.NoError(t, err)
255+ return
256+ }
257+ require.ErrorIs(t, err, domain.ErrRejected)
258+ assert.Contains(t, err.Error(), tt.refused+" is outside the branches this token may push (agent/claude/, bot/)")
259+ })
260+ }
261+
262+ t.Run("a token without limit pushes anywhere", func(t *testing.T) {
263+ t.Parallel()
264+ f := newPushFixture(t)
265+ f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
266+ free := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)})
267+ require.NoError(t, f.pushes.Check(ctx, free, n, []domain.RefUpdate{
268+ up(domain.ZeroOID, oidA, "refs/tags/v1"), up(oidA, oidB, "refs/heads/main"),
269+ }))
270+ })
271+
272+ t.Run("root pushes anywhere", func(t *testing.T) {
273+ t.Parallel()
274+ f := newPushFixture(t)
275+ require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}))
276+ })
277+
278+ t.Run("a limited token without repo:write is rejected for the repository first", func(t *testing.T) {
279+ t.Parallel()
280+ f := newPushFixture(t)
281+ f.tokens.EXPECT().Get(ctx, "b2").Return(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead)}, nil)
282+ p := domain.TokenPrincipal(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"a/"}})
283+ err := f.pushes.Check(ctx, p, n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/a/x")})
284+ require.ErrorIs(t, err, domain.ErrRejected)
285+ assert.Contains(t, err.Error(), "no write access")
286+ })
287+}
288+
206 func TestPushesRecord(t *testing.T) {289 func TestPushesRecord(t *testing.T) {
207 t.Parallel()290 t.Parallel()
208 ctx := context.Background()291 ctx := context.Background()
M internal/usecase/repos.go +4 -3
@@ -37,8 +37,9 @@ func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus,
3737 // after the metadata is written removes it again and discards the staged
3838 // repository.
3939 //
40-// The principal needs repo:write on the name: anonymous callers get
41-// domain.ErrUnauthenticated, other callers without it domain.ErrForbidden.
40+// The principal needs repo:write on the name and no branch prefixes: anonymous
41+// callers get domain.ErrUnauthenticated, other callers without it (a token
42+// limited to some branches included) domain.ErrForbidden.
4243 func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) {
4344 if params.Name.IsZero() {
4445 return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
@@ -46,7 +47,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre
4647 if p.IsAnonymous() {
4748 return domain.Repo{}, domain.ErrUnauthenticated
4849 }
49- if !canWrite(p, params.Name) {
50+ if !canCreateRepo(p, params.Name) {
5051 return domain.Repo{}, domain.ErrForbidden
5152 }
5253 if err := validateDescription(params.Description); err != nil {
@@ -37,8 +37,9 @@ func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus,
37 // after the metadata is written removes it again and discards the staged37 // after the metadata is written removes it again and discards the staged
38 // repository.38 // repository.
39 //39 //
40-// The principal needs repo:write on the name: anonymous callers get40+// The principal needs repo:write on the name and no branch prefixes: anonymous
41-// domain.ErrUnauthenticated, other callers without it domain.ErrForbidden.41+// callers get domain.ErrUnauthenticated, other callers without it (a token
42+// limited to some branches included) domain.ErrForbidden.
42 func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) {43 func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) {
43 if params.Name.IsZero() {44 if params.Name.IsZero() {
44 return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)45 return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
@@ -46,7 +47,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre
46 if p.IsAnonymous() {47 if p.IsAnonymous() {
47 return domain.Repo{}, domain.ErrUnauthenticated48 return domain.Repo{}, domain.ErrUnauthenticated
48 }49 }
49- if !canWrite(p, params.Name) {50+ if !canCreateRepo(p, params.Name) {
50 return domain.Repo{}, domain.ErrForbidden51 return domain.Repo{}, domain.ErrForbidden
51 }52 }
52 if err := validateDescription(params.Description); err != nil {53 if err := validateDescription(params.Description); err != nil {
M internal/usecase/repos_test.go +30 -0
@@ -86,6 +86,18 @@ func TestCreate(t *testing.T) {
8686 }
8787 })
8888
89+ t.Run("a token limited to branches may not create a repository", func(t *testing.T) {
90+ t.Parallel()
91+ f := newFixture(t) // the mocks expect no call: nothing is staged
92+ for _, p := range []domain.Principal{
93+ branchLimited(domain.ScopeRepoWrite),
94+ branchLimited(domain.ScopeRepoWrite, "tools"),
95+ } {
96+ _, err := f.repos.Create(ctx, p, ports.CreateRepoParams{Name: name(t, "tools")})
97+ assert.ErrorIs(t, err, domain.ErrForbidden)
98+ }
99+ })
100+
89101 t.Run("token with repo:write creates", func(t *testing.T) {
90102 t.Parallel()
91103 f := newFixture(t)
@@ -191,6 +203,24 @@ func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
191203 return domain.TokenPrincipal(tok)
192204 }
193205
206+// branchLimited is a token with one scope, optionally limited to repositories,
207+// and limited to the branches under "agent/".
208+func branchLimited(s domain.Scope, repos ...string) domain.Principal {
209+ tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}, BranchPrefixes: []string{"agent/"}}
210+ for _, r := range repos {
211+ tok.Repos = append(tok.Repos, name2(r))
212+ }
213+ return domain.TokenPrincipal(tok)
214+}
215+
216+func name2(s string) domain.RepoName {
217+ n, err := domain.ParseRepoName(s)
218+ if err != nil {
219+ panic(err)
220+ }
221+ return n
222+}
223+
194224 func TestGetAndListAccess(t *testing.T) {
195225 t.Parallel()
196226 ctx := context.Background()
@@ -86,6 +86,18 @@ func TestCreate(t *testing.T) {
86 }86 }
87 })87 })
88 88
89+ t.Run("a token limited to branches may not create a repository", func(t *testing.T) {
90+ t.Parallel()
91+ f := newFixture(t) // the mocks expect no call: nothing is staged
92+ for _, p := range []domain.Principal{
93+ branchLimited(domain.ScopeRepoWrite),
94+ branchLimited(domain.ScopeRepoWrite, "tools"),
95+ } {
96+ _, err := f.repos.Create(ctx, p, ports.CreateRepoParams{Name: name(t, "tools")})
97+ assert.ErrorIs(t, err, domain.ErrForbidden)
98+ }
99+ })
100+
89 t.Run("token with repo:write creates", func(t *testing.T) {101 t.Run("token with repo:write creates", func(t *testing.T) {
90 t.Parallel()102 t.Parallel()
91 f := newFixture(t)103 f := newFixture(t)
@@ -191,6 +203,24 @@ func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
191 return domain.TokenPrincipal(tok)203 return domain.TokenPrincipal(tok)
192 }204 }
193 205
206+// branchLimited is a token with one scope, optionally limited to repositories,
207+// and limited to the branches under "agent/".
208+func branchLimited(s domain.Scope, repos ...string) domain.Principal {
209+ tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}, BranchPrefixes: []string{"agent/"}}
210+ for _, r := range repos {
211+ tok.Repos = append(tok.Repos, name2(r))
212+ }
213+ return domain.TokenPrincipal(tok)
214+}
215+
216+func name2(s string) domain.RepoName {
217+ n, err := domain.ParseRepoName(s)
218+ if err != nil {
219+ panic(err)
220+ }
221+ return n
222+}
223+
194 func TestGetAndListAccess(t *testing.T) {224 func TestGetAndListAccess(t *testing.T) {
195 t.Parallel()225 t.Parallel()
196 ctx := context.Background()226 ctx := context.Background()
M internal/usecase/repos_write.go +4 -1
@@ -174,7 +174,7 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
174174
175175 // Delete moves the repository directory to the trash, removes its metadata
176176 // and publishes RepoDeleted. The principal needs repo:admin on it (see
177-// adminAccess). A repository whose directory is already gone, left by a
177+// adminAccess) and no branch prefixes. A repository whose directory is already gone, left by a
178178 // delete that failed half way, loses its metadata.
179179 func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error {
180180 repo, err := lookupRepo(ctx, r.meta, name)
@@ -184,6 +184,9 @@ func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.Repo
184184 if err := adminAccess(p, repo); err != nil {
185185 return err
186186 }
187+ if !canDeleteRepo(p) {
188+ return fmt.Errorf("%w: a token limited to branch prefixes may not delete a repository", domain.ErrForbidden)
189+ }
187190 log := zerowrap.FromCtx(ctx)
188191 switch err := r.git.Trash(ctx, name); {
189192 case errors.Is(err, domain.ErrNotFound):
@@ -174,7 +174,7 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
174 174
175 // Delete moves the repository directory to the trash, removes its metadata175 // Delete moves the repository directory to the trash, removes its metadata
176 // and publishes RepoDeleted. The principal needs repo:admin on it (see176 // and publishes RepoDeleted. The principal needs repo:admin on it (see
177-// adminAccess). A repository whose directory is already gone, left by a177+// adminAccess) and no branch prefixes. A repository whose directory is already gone, left by a
178 // delete that failed half way, loses its metadata.178 // delete that failed half way, loses its metadata.
179 func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error {179 func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error {
180 repo, err := lookupRepo(ctx, r.meta, name)180 repo, err := lookupRepo(ctx, r.meta, name)
@@ -184,6 +184,9 @@ func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.Repo
184 if err := adminAccess(p, repo); err != nil {184 if err := adminAccess(p, repo); err != nil {
185 return err185 return err
186 }186 }
187+ if !canDeleteRepo(p) {
188+ return fmt.Errorf("%w: a token limited to branch prefixes may not delete a repository", domain.ErrForbidden)
189+ }
187 log := zerowrap.FromCtx(ctx)190 log := zerowrap.FromCtx(ctx)
188 switch err := r.git.Trash(ctx, name); {191 switch err := r.git.Trash(ctx, name); {
189 case errors.Is(err, domain.ErrNotFound):192 case errors.Is(err, domain.ErrNotFound):
M internal/usecase/repos_write_test.go +8 -0
@@ -165,6 +165,14 @@ func TestUpdate(t *testing.T) {
165165 })
166166 }
167167
168+ t.Run("a token limited to branches may not delete a repository", func(t *testing.T) {
169+ t.Parallel()
170+ f := newFixture(t) // no Trash, no Delete
171+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
172+ err := f.repos.Delete(ctx, branchLimited(domain.ScopeRepoAdmin), n)
173+ assert.ErrorIs(t, err, domain.ErrForbidden)
174+ })
175+
168176 t.Run("a missing repository is not found", func(t *testing.T) {
169177 t.Parallel()
170178 f := newFixture(t)
@@ -165,6 +165,14 @@ func TestUpdate(t *testing.T) {
165 })165 })
166 }166 }
167 167
168+ t.Run("a token limited to branches may not delete a repository", func(t *testing.T) {
169+ t.Parallel()
170+ f := newFixture(t) // no Trash, no Delete
171+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
172+ err := f.repos.Delete(ctx, branchLimited(domain.ScopeRepoAdmin), n)
173+ assert.ErrorIs(t, err, domain.ErrForbidden)
174+ })
175+
168 t.Run("a missing repository is not found", func(t *testing.T) {176 t.Run("a missing repository is not found", func(t *testing.T) {
169 t.Parallel()177 t.Parallel()
170 f := newFixture(t)178 f := newFixture(t)
M internal/usecase/tokens.go +21 -5
@@ -54,7 +54,8 @@ func NewTokens(store ports.TokenStore, clock ports.Clock, sessions ports.Session
5454 // domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token
5555 // cannot create a token more powerful than itself: every scope must be one it
5656 // holds, the repositories must lie within its list (a limited token cannot
57-// create an unlimited one) and the expiry must not outlast its own. Violations
57+// create an unlimited one), the same for branch prefixes (each must start
58+// with one of its own) and the expiry must not outlast its own. Violations
5859 // give domain.ErrForbidden. Root has no such limits. Invalid settings give
5960 // domain.ErrInvalidToken (this includes the labels "owner" and "root", which
6061 // are reserved, in any case and around spaces), an unknown scope
@@ -88,7 +89,7 @@ func (t *Tokens) Create(ctx context.Context, p domain.Principal, params ports.Cr
8889 log := zerowrap.FromCtx(ctx)
8990 log.Info().
9091 Str("token_id", tok.ID).Str("label", tok.Label).
91- Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).
92+ Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).Int("branch_prefixes", len(tok.BranchPrefixes)).
9293 Str("created_by", p.Label()).Str("created_by_token", p.TokenID()).
9394 Msg("token created")
9495 return ports.CreatedToken{Token: tok, Secret: secret}, nil
@@ -122,10 +123,15 @@ func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, er
122123 if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) {
123124 return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken)
124125 }
126+ prefixes, err := domain.ParseBranchPrefixes(params.BranchPrefixes)
127+ if err != nil {
128+ return domain.Token{}, err
129+ }
125130 tok := domain.Token{
126- Label: params.Label,
127- CreatedAt: now,
128- ExpiresAt: params.ExpiresAt.UTC(),
131+ Label: params.Label,
132+ BranchPrefixes: prefixes,
133+ CreatedAt: now,
134+ ExpiresAt: params.ExpiresAt.UTC(),
129135 }
130136 // Canonical order, no duplicates.
131137 for _, s := range domain.AllScopes() {
@@ -164,6 +170,16 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error {
164170 }
165171 }
166172 }
173+ if own := p.BranchPrefixes(); len(own) > 0 {
174+ if len(tok.BranchPrefixes) == 0 {
175+ return fmt.Errorf("%w: the creating token is limited to branch prefixes, so the new token must be too", domain.ErrForbidden)
176+ }
177+ for _, bp := range tok.BranchPrefixes {
178+ if !slices.ContainsFunc(own, func(o string) bool { return strings.HasPrefix(bp, o) }) {
179+ return fmt.Errorf("%w: branch prefix %s is outside the creating token", domain.ErrForbidden, bp)
180+ }
181+ }
182+ }
167183 if exp := p.ExpiresAt(); !exp.IsZero() {
168184 if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) {
169185 return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden)
@@ -54,7 +54,8 @@ func NewTokens(store ports.TokenStore, clock ports.Clock, sessions ports.Session
54 // domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token54 // domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token
55 // cannot create a token more powerful than itself: every scope must be one it55 // cannot create a token more powerful than itself: every scope must be one it
56 // holds, the repositories must lie within its list (a limited token cannot56 // holds, the repositories must lie within its list (a limited token cannot
57-// create an unlimited one) and the expiry must not outlast its own. Violations57+// create an unlimited one), the same for branch prefixes (each must start
58+// with one of its own) and the expiry must not outlast its own. Violations
58 // give domain.ErrForbidden. Root has no such limits. Invalid settings give59 // give domain.ErrForbidden. Root has no such limits. Invalid settings give
59 // domain.ErrInvalidToken (this includes the labels "owner" and "root", which60 // domain.ErrInvalidToken (this includes the labels "owner" and "root", which
60 // are reserved, in any case and around spaces), an unknown scope61 // are reserved, in any case and around spaces), an unknown scope
@@ -88,7 +89,7 @@ func (t *Tokens) Create(ctx context.Context, p domain.Principal, params ports.Cr
88 log := zerowrap.FromCtx(ctx)89 log := zerowrap.FromCtx(ctx)
89 log.Info().90 log.Info().
90 Str("token_id", tok.ID).Str("label", tok.Label).91 Str("token_id", tok.ID).Str("label", tok.Label).
91- Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).92+ Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).Int("branch_prefixes", len(tok.BranchPrefixes)).
92 Str("created_by", p.Label()).Str("created_by_token", p.TokenID()).93 Str("created_by", p.Label()).Str("created_by_token", p.TokenID()).
93 Msg("token created")94 Msg("token created")
94 return ports.CreatedToken{Token: tok, Secret: secret}, nil95 return ports.CreatedToken{Token: tok, Secret: secret}, nil
@@ -122,10 +123,15 @@ func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, er
122 if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) {123 if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) {
123 return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken)124 return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken)
124 }125 }
126+ prefixes, err := domain.ParseBranchPrefixes(params.BranchPrefixes)
127+ if err != nil {
128+ return domain.Token{}, err
129+ }
125 tok := domain.Token{130 tok := domain.Token{
126- Label: params.Label,131+ Label: params.Label,
127- CreatedAt: now,132+ BranchPrefixes: prefixes,
128- ExpiresAt: params.ExpiresAt.UTC(),133+ CreatedAt: now,
134+ ExpiresAt: params.ExpiresAt.UTC(),
129 }135 }
130 // Canonical order, no duplicates.136 // Canonical order, no duplicates.
131 for _, s := range domain.AllScopes() {137 for _, s := range domain.AllScopes() {
@@ -164,6 +170,16 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error {
164 }170 }
165 }171 }
166 }172 }
173+ if own := p.BranchPrefixes(); len(own) > 0 {
174+ if len(tok.BranchPrefixes) == 0 {
175+ return fmt.Errorf("%w: the creating token is limited to branch prefixes, so the new token must be too", domain.ErrForbidden)
176+ }
177+ for _, bp := range tok.BranchPrefixes {
178+ if !slices.ContainsFunc(own, func(o string) bool { return strings.HasPrefix(bp, o) }) {
179+ return fmt.Errorf("%w: branch prefix %s is outside the creating token", domain.ErrForbidden, bp)
180+ }
181+ }
182+ }
167 if exp := p.ExpiresAt(); !exp.IsZero() {183 if exp := p.ExpiresAt(); !exp.IsZero() {
168 if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) {184 if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) {
169 return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden)185 return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden)
M internal/usecase/tokens_test.go +85 -0
@@ -177,6 +177,14 @@ func TestTokensCreateNoEscalation(t *testing.T) {
177177 })
178178 }
179179
180+ adminOn := func(prefixes []string) domain.Principal {
181+ return domain.TokenPrincipal(domain.Token{
182+ ID: "creator",
183+ Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite),
184+ BranchPrefixes: prefixes,
185+ })
186+ }
187+
180188 tests := []struct {
181189 name string
182190 principal domain.Principal
@@ -211,6 +219,22 @@ func TestTokensCreateNoEscalation(t *testing.T) {
211219 ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil},
212220 {"root: everything", domain.Root(),
213221 ports.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil},
222+ {"branch-limited creator, child within its prefix", adminOn([]string{"agent/claude/"}),
223+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/sub/"}}, nil},
224+ {"branch-limited creator, child with the same prefix", adminOn([]string{"agent/claude/"}),
225+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"}}, nil},
226+ {"branch-limited creator, child with one of two prefixes", adminOn([]string{"a/", "b/"}),
227+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"b/x/", "a/"}}, nil},
228+ {"branch-limited creator, child without prefixes", adminOn([]string{"agent/claude/"}),
229+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
230+ {"branch-limited creator, child with a wider prefix", adminOn([]string{"agent/claude/"}),
231+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}, domain.ErrForbidden},
232+ {"branch-limited creator, child with a sibling prefix", adminOn([]string{"agent/claude/"}),
233+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}}, domain.ErrForbidden},
234+ {"branch-limited creator, one prefix of two outside", adminOn([]string{"agent/claude/"}),
235+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/a/", "main/"}}, domain.ErrForbidden},
236+ {"unlimited creator may limit branches", admin(nil, time.Time{}),
237+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"z/"}}, nil},
214238 }
215239 for _, tt := range tests {
216240 t.Run(tt.name, func(t *testing.T) {
@@ -551,3 +575,64 @@ func TestAuthenticate(t *testing.T) {
551575 assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
552576 })
553577 }
578+
579+func TestTokensCreateBranchPrefixes(t *testing.T) {
580+ t.Parallel()
581+ ctx := context.Background()
582+
583+ t.Run("stored in canonical form: duplicates dropped, order kept", func(t *testing.T) {
584+ t.Parallel()
585+ f := newTokenFixture(t)
586+ f.store.EXPECT().Create(ctx, mock.MatchedBy(func(tok domain.Token) bool {
587+ return assert.ObjectsAreEqual([]string{"b/", "a/c/"}, tok.BranchPrefixes)
588+ })).Return(nil)
589+ got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{
590+ Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"b/", "a/c/", "b/"},
591+ })
592+ require.NoError(t, err)
593+ assert.Equal(t, []string{"b/", "a/c/"}, got.Token.BranchPrefixes)
594+ })
595+
596+ t.Run("no prefixes stays unlimited", func(t *testing.T) {
597+ t.Parallel()
598+ f := newTokenFixture(t)
599+ f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
600+ got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)})
601+ require.NoError(t, err)
602+ assert.Empty(t, got.Token.BranchPrefixes)
603+ })
604+
605+ t.Run("invalid prefixes are refused with ErrInvalidToken", func(t *testing.T) {
606+ t.Parallel()
607+ nine := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/", "i/"}
608+ for _, bad := range [][]string{{"agent"}, {"/"}, {""}, {"a b/"}, {"../"}, {"refs/heads/a/"}, {strings.Repeat("a", 100) + "/"}, {"a/", "bad"}, nine} {
609+ f := newTokenFixture(t) // the store expects no Create
610+ _, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{
611+ Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: bad,
612+ })
613+ assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad)
614+ }
615+ })
616+
617+ t.Run("a token with every scope but limited branches has no owner rights", func(t *testing.T) {
618+ t.Parallel()
619+ p := domain.TokenPrincipal(domain.Token{ID: "t", Scopes: domain.AllScopes(), BranchPrefixes: []string{"a/"}})
620+ assert.False(t, p.HasOwnerRights())
621+ })
622+
623+ t.Run("listing hides tokens a limited creator could not have made", func(t *testing.T) {
624+ t.Parallel()
625+ f := newTokenFixture(t)
626+ mine := domain.Token{ID: "mine", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}}
627+ wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}
628+ free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)}
629+ f.store.EXPECT().List(ctx).Return([]domain.Token{mine, wide, free}, nil)
630+ p := domain.TokenPrincipal(domain.Token{
631+ ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"},
632+ })
633+ got, err := f.tokens.List(ctx, p)
634+ require.NoError(t, err)
635+ require.Len(t, got, 1)
636+ assert.Equal(t, "mine", got[0].ID)
637+ })
638+}
@@ -177,6 +177,14 @@ func TestTokensCreateNoEscalation(t *testing.T) {
177 })177 })
178 }178 }
179 179
180+ adminOn := func(prefixes []string) domain.Principal {
181+ return domain.TokenPrincipal(domain.Token{
182+ ID: "creator",
183+ Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite),
184+ BranchPrefixes: prefixes,
185+ })
186+ }
187+
180 tests := []struct {188 tests := []struct {
181 name string189 name string
182 principal domain.Principal190 principal domain.Principal
@@ -211,6 +219,22 @@ func TestTokensCreateNoEscalation(t *testing.T) {
211 ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil},219 ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil},
212 {"root: everything", domain.Root(),220 {"root: everything", domain.Root(),
213 ports.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil},221 ports.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil},
222+ {"branch-limited creator, child within its prefix", adminOn([]string{"agent/claude/"}),
223+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/sub/"}}, nil},
224+ {"branch-limited creator, child with the same prefix", adminOn([]string{"agent/claude/"}),
225+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"}}, nil},
226+ {"branch-limited creator, child with one of two prefixes", adminOn([]string{"a/", "b/"}),
227+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"b/x/", "a/"}}, nil},
228+ {"branch-limited creator, child without prefixes", adminOn([]string{"agent/claude/"}),
229+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
230+ {"branch-limited creator, child with a wider prefix", adminOn([]string{"agent/claude/"}),
231+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}, domain.ErrForbidden},
232+ {"branch-limited creator, child with a sibling prefix", adminOn([]string{"agent/claude/"}),
233+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}}, domain.ErrForbidden},
234+ {"branch-limited creator, one prefix of two outside", adminOn([]string{"agent/claude/"}),
235+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/a/", "main/"}}, domain.ErrForbidden},
236+ {"unlimited creator may limit branches", admin(nil, time.Time{}),
237+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"z/"}}, nil},
214 }238 }
215 for _, tt := range tests {239 for _, tt := range tests {
216 t.Run(tt.name, func(t *testing.T) {240 t.Run(tt.name, func(t *testing.T) {
@@ -551,3 +575,64 @@ func TestAuthenticate(t *testing.T) {
551 assert.NotErrorIs(t, err, domain.ErrUnauthenticated)575 assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
552 })576 })
553 }577 }
578+
579+func TestTokensCreateBranchPrefixes(t *testing.T) {
580+ t.Parallel()
581+ ctx := context.Background()
582+
583+ t.Run("stored in canonical form: duplicates dropped, order kept", func(t *testing.T) {
584+ t.Parallel()
585+ f := newTokenFixture(t)
586+ f.store.EXPECT().Create(ctx, mock.MatchedBy(func(tok domain.Token) bool {
587+ return assert.ObjectsAreEqual([]string{"b/", "a/c/"}, tok.BranchPrefixes)
588+ })).Return(nil)
589+ got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{
590+ Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"b/", "a/c/", "b/"},
591+ })
592+ require.NoError(t, err)
593+ assert.Equal(t, []string{"b/", "a/c/"}, got.Token.BranchPrefixes)
594+ })
595+
596+ t.Run("no prefixes stays unlimited", func(t *testing.T) {
597+ t.Parallel()
598+ f := newTokenFixture(t)
599+ f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
600+ got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)})
601+ require.NoError(t, err)
602+ assert.Empty(t, got.Token.BranchPrefixes)
603+ })
604+
605+ t.Run("invalid prefixes are refused with ErrInvalidToken", func(t *testing.T) {
606+ t.Parallel()
607+ nine := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/", "i/"}
608+ for _, bad := range [][]string{{"agent"}, {"/"}, {""}, {"a b/"}, {"../"}, {"refs/heads/a/"}, {strings.Repeat("a", 100) + "/"}, {"a/", "bad"}, nine} {
609+ f := newTokenFixture(t) // the store expects no Create
610+ _, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{
611+ Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: bad,
612+ })
613+ assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad)
614+ }
615+ })
616+
617+ t.Run("a token with every scope but limited branches has no owner rights", func(t *testing.T) {
618+ t.Parallel()
619+ p := domain.TokenPrincipal(domain.Token{ID: "t", Scopes: domain.AllScopes(), BranchPrefixes: []string{"a/"}})
620+ assert.False(t, p.HasOwnerRights())
621+ })
622+
623+ t.Run("listing hides tokens a limited creator could not have made", func(t *testing.T) {
624+ t.Parallel()
625+ f := newTokenFixture(t)
626+ mine := domain.Token{ID: "mine", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}}
627+ wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}
628+ free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)}
629+ f.store.EXPECT().List(ctx).Return([]domain.Token{mine, wide, free}, nil)
630+ p := domain.TokenPrincipal(domain.Token{
631+ ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"},
632+ })
633+ got, err := f.tokens.List(ctx, p)
634+ require.NoError(t, err)
635+ require.Len(t, got, 1)
636+ assert.Equal(t, "mine", got[0].ID)
637+ })
638+}

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 4

Tags 45

↑↓ moveenter openesc close