detached 2bea74e

feat(app): add token use cases, authentication and token-aware access rule

commit
2bea74e367e573503537a3b68aeea291ea0052f0
author
brice · · 2026-10-10 06:42 UTC
committer
brice · · 2026-10-10 07:03 UTC
parent
607c375
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +662 -12
M internal/adapters/kvstore/tokens.go +6 -1
@@ -139,7 +139,10 @@ func (s *Tokens) Revoke(ctx context.Context, id string) error {
139139 return fmt.Errorf("revoke token %q: %w", id, domain.ErrConflict)
140140 }
141141
142-// Touch sets last_used_at when the given time is later than the stored one.
142+// Touch sets last_used_at when the given time is later than the stored one,
143+// so that racing writers never move it back.
144+//
145+//hexcheck:ignore suspicious-business-logic-in-adapter monotonic write guard, not a policy
143146 func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {
144147 if !validTokenID(id) {
145148 return domain.ErrNotFound
@@ -211,6 +214,8 @@ func encodeToken(t domain.Token) ([]byte, error) {
211214
212215 // decodeToken drops scopes it does not know (written by a newer ermite): a
213216 // token never gains rights by being read by an older build.
217+//
218+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
214219 func decodeToken(val []byte) (domain.Token, error) {
215220 var pb storagepb.Token
216221 if err := proto.Unmarshal(val, &pb); err != nil {
@@ -139,7 +139,10 @@ func (s *Tokens) Revoke(ctx context.Context, id string) error {
139 return fmt.Errorf("revoke token %q: %w", id, domain.ErrConflict)139 return fmt.Errorf("revoke token %q: %w", id, domain.ErrConflict)
140 }140 }
141 141
142-// Touch sets last_used_at when the given time is later than the stored one.142+// Touch sets last_used_at when the given time is later than the stored one,
143+// so that racing writers never move it back.
144+//
145+//hexcheck:ignore suspicious-business-logic-in-adapter monotonic write guard, not a policy
143 func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {146 func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {
144 if !validTokenID(id) {147 if !validTokenID(id) {
145 return domain.ErrNotFound148 return domain.ErrNotFound
@@ -211,6 +214,8 @@ func encodeToken(t domain.Token) ([]byte, error) {
211 214
212 // decodeToken drops scopes it does not know (written by a newer ermite): a215 // decodeToken drops scopes it does not know (written by a newer ermite): a
213 // token never gains rights by being read by an older build.216 // token never gains rights by being read by an older build.
217+//
218+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
214 func decodeToken(val []byte) (domain.Token, error) {219 func decodeToken(val []byte) (domain.Token, error) {
215 var pb storagepb.Token220 var pb storagepb.Token
216 if err := proto.Unmarshal(val, &pb); err != nil {221 if err := proto.Unmarshal(val, &pb); err != nil {
M internal/app/access.go +17 -7
@@ -5,15 +5,25 @@ package app
55 import "bnema.dev/ermite/internal/domain"
66
77 // canRead is the access rule for reading a repository, the single place that
8-// decides it: root reads everything, anonymous callers read public
9-// repositories only. Callers answer domain.ErrNotFound when it is false, so
10-// a private repository never shows itself.
8+// decides it: root reads everything, anyone reads a public repository, and a
9+// stored token reads a private one only with repo:read on it (the scope, and
10+// the repository within its list). Callers answer domain.ErrNotFound when it
11+// is false, so a private repository never shows itself.
1112 func canRead(p domain.Principal, repo domain.Repo) bool {
12- if p.IsRoot() {
13+ if repo.Visibility == domain.VisibilityPublic {
1314 return true
1415 }
15- return repo.Visibility == domain.VisibilityPublic
16+ return p.Allows(domain.ScopeRepoRead, repo.Name)
1617 }
1718
18-// canCreate is the access rule for creating a repository: root only.
19-func canCreate(p domain.Principal) bool { return p.IsRoot() }
19+// canWrite is the access rule for pushing to a repository, or creating it on
20+// first push: repo:write on it.
21+func canWrite(p domain.Principal, name domain.RepoName) bool {
22+ return p.Allows(domain.ScopeRepoWrite, name)
23+}
24+
25+// canManageTokens is the access rule for creating, listing and revoking
26+// stored tokens: token:admin, which root holds.
27+func canManageTokens(p domain.Principal) bool {
28+ return p.HasScope(domain.ScopeTokenAdmin)
29+}
@@ -5,15 +5,25 @@ package app
5 import "bnema.dev/ermite/internal/domain"5 import "bnema.dev/ermite/internal/domain"
6 6
7 // canRead is the access rule for reading a repository, the single place that7 // canRead is the access rule for reading a repository, the single place that
8-// decides it: root reads everything, anonymous callers read public8+// decides it: root reads everything, anyone reads a public repository, and a
9-// repositories only. Callers answer domain.ErrNotFound when it is false, so9+// stored token reads a private one only with repo:read on it (the scope, and
10-// a private repository never shows itself.10+// the repository within its list). Callers answer domain.ErrNotFound when it
11+// is false, so a private repository never shows itself.
11 func canRead(p domain.Principal, repo domain.Repo) bool {12 func canRead(p domain.Principal, repo domain.Repo) bool {
12- if p.IsRoot() {13+ if repo.Visibility == domain.VisibilityPublic {
13 return true14 return true
14 }15 }
15- return repo.Visibility == domain.VisibilityPublic16+ return p.Allows(domain.ScopeRepoRead, repo.Name)
16 }17 }
17 18
18-// canCreate is the access rule for creating a repository: root only.19+// canWrite is the access rule for pushing to a repository, or creating it on
19-func canCreate(p domain.Principal) bool { return p.IsRoot() }20+// first push: repo:write on it.
21+func canWrite(p domain.Principal, name domain.RepoName) bool {
22+ return p.Allows(domain.ScopeRepoWrite, name)
23+}
24+
25+// canManageTokens is the access rule for creating, listing and revoking
26+// stored tokens: token:admin, which root holds.
27+func canManageTokens(p domain.Principal) bool {
28+ return p.HasScope(domain.ScopeTokenAdmin)
29+}
M internal/app/repos.go +8 -4
@@ -41,14 +41,18 @@ type CreateParams struct {
4141 // after the metadata is written removes it again and discards the staged
4242 // repository.
4343 //
44-// Only root may create; anonymous callers get domain.ErrUnauthenticated.
44+// The principal needs repo:write on the name: anonymous callers get
45+// domain.ErrUnauthenticated, other callers without it domain.ErrForbidden.
4546 func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreateParams) (domain.Repo, error) {
46- if !canCreate(p) {
47- return domain.Repo{}, domain.ErrUnauthenticated
48- }
4947 if params.Name.IsZero() {
5048 return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
5149 }
50+ if p.IsAnonymous() {
51+ return domain.Repo{}, domain.ErrUnauthenticated
52+ }
53+ if !canWrite(p, params.Name) {
54+ return domain.Repo{}, domain.ErrForbidden
55+ }
5256 vis := params.Visibility
5357 if vis == "" {
5458 vis = domain.VisibilityPrivate
@@ -41,14 +41,18 @@ type CreateParams struct {
41 // after the metadata is written removes it again and discards the staged41 // after the metadata is written removes it again and discards the staged
42 // repository.42 // repository.
43 //43 //
44-// Only root may create; anonymous callers get domain.ErrUnauthenticated.44+// The principal needs repo:write on the name: anonymous callers get
45+// domain.ErrUnauthenticated, other callers without it domain.ErrForbidden.
45 func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreateParams) (domain.Repo, error) {46 func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreateParams) (domain.Repo, error) {
46- if !canCreate(p) {
47- return domain.Repo{}, domain.ErrUnauthenticated
48- }
49 if params.Name.IsZero() {47 if params.Name.IsZero() {
50 return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)48 return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
51 }49 }
50+ if p.IsAnonymous() {
51+ return domain.Repo{}, domain.ErrUnauthenticated
52+ }
53+ if !canWrite(p, params.Name) {
54+ return domain.Repo{}, domain.ErrForbidden
55+ }
52 vis := params.Visibility56 vis := params.Visibility
53 if vis == "" {57 if vis == "" {
54 vis = domain.VisibilityPrivate58 vis = domain.VisibilityPrivate
M internal/app/repos_test.go +39 -0
@@ -69,6 +69,27 @@ func TestCreate(t *testing.T) {
6969 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
7070 })
7171
72+ t.Run("token without repo:write or outside its list is forbidden", func(t *testing.T) {
73+ for _, p := range []domain.Principal{
74+ tokenPrincipal(domain.ScopeRepoRead),
75+ tokenPrincipal(domain.ScopeRepoWrite, "other"),
76+ } {
77+ f := newFixture(t)
78+ _, err := f.repos.Create(ctx, p, app.CreateParams{Name: name(t, "tools")})
79+ assert.ErrorIs(t, err, domain.ErrForbidden)
80+ }
81+ })
82+
83+ t.Run("token with repo:write creates", func(t *testing.T) {
84+ f := newFixture(t)
85+ n := name(t, "tools")
86+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
87+ f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
88+ f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
89+ _, err := f.repos.Create(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), app.CreateParams{Name: n})
90+ require.NoError(t, err)
91+ })
92+
7293 t.Run("zero name is invalid", func(t *testing.T) {
7394 f := newFixture(t)
7495 _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{})
@@ -143,6 +164,18 @@ func TestCreate(t *testing.T) {
143164 })
144165 }
145166
167+func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
168+ tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}}
169+ for _, r := range repos {
170+ n, err := domain.ParseRepoName(r)
171+ if err != nil {
172+ panic(err)
173+ }
174+ tok.Repos = append(tok.Repos, n)
175+ }
176+ return domain.TokenPrincipal(tok)
177+}
178+
146179 func TestGetAndListAccess(t *testing.T) {
147180 ctx := context.Background()
148181 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
@@ -158,6 +191,12 @@ func TestGetAndListAccess(t *testing.T) {
158191 {"anonymous cannot see private", domain.Anonymous(), priv, domain.ErrNotFound},
159192 {"root reads public", domain.Root(), pub, nil},
160193 {"root reads private", domain.Root(), priv, nil},
194+ {"token with repo:read reads private", tokenPrincipal(domain.ScopeRepoRead), priv, nil},
195+ {"token limited to the repo reads private", tokenPrincipal(domain.ScopeRepoRead, "priv"), priv, nil},
196+ {"token limited to another repo cannot see private", tokenPrincipal(domain.ScopeRepoRead, "other"), priv, domain.ErrNotFound},
197+ {"token without repo:read cannot see private", tokenPrincipal(domain.ScopeRepoWrite), priv, domain.ErrNotFound},
198+ {"token without repo:read reads public", tokenPrincipal(domain.ScopeRepoWrite), pub, nil},
199+ {"token limited to another repo reads public", tokenPrincipal(domain.ScopeRepoRead, "other"), pub, nil},
161200 }
162201 for _, tt := range tests {
163202 t.Run("get/"+tt.name, func(t *testing.T) {
@@ -69,6 +69,27 @@ func TestCreate(t *testing.T) {
69 assert.ErrorIs(t, err, domain.ErrUnauthenticated)69 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
70 })70 })
71 71
72+ t.Run("token without repo:write or outside its list is forbidden", func(t *testing.T) {
73+ for _, p := range []domain.Principal{
74+ tokenPrincipal(domain.ScopeRepoRead),
75+ tokenPrincipal(domain.ScopeRepoWrite, "other"),
76+ } {
77+ f := newFixture(t)
78+ _, err := f.repos.Create(ctx, p, app.CreateParams{Name: name(t, "tools")})
79+ assert.ErrorIs(t, err, domain.ErrForbidden)
80+ }
81+ })
82+
83+ t.Run("token with repo:write creates", func(t *testing.T) {
84+ f := newFixture(t)
85+ n := name(t, "tools")
86+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
87+ f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
88+ f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
89+ _, err := f.repos.Create(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), app.CreateParams{Name: n})
90+ require.NoError(t, err)
91+ })
92+
72 t.Run("zero name is invalid", func(t *testing.T) {93 t.Run("zero name is invalid", func(t *testing.T) {
73 f := newFixture(t)94 f := newFixture(t)
74 _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{})95 _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{})
@@ -143,6 +164,18 @@ func TestCreate(t *testing.T) {
143 })164 })
144 }165 }
145 166
167+func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
168+ tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}}
169+ for _, r := range repos {
170+ n, err := domain.ParseRepoName(r)
171+ if err != nil {
172+ panic(err)
173+ }
174+ tok.Repos = append(tok.Repos, n)
175+ }
176+ return domain.TokenPrincipal(tok)
177+}
178+
146 func TestGetAndListAccess(t *testing.T) {179 func TestGetAndListAccess(t *testing.T) {
147 ctx := context.Background()180 ctx := context.Background()
148 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}181 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
@@ -158,6 +191,12 @@ func TestGetAndListAccess(t *testing.T) {
158 {"anonymous cannot see private", domain.Anonymous(), priv, domain.ErrNotFound},191 {"anonymous cannot see private", domain.Anonymous(), priv, domain.ErrNotFound},
159 {"root reads public", domain.Root(), pub, nil},192 {"root reads public", domain.Root(), pub, nil},
160 {"root reads private", domain.Root(), priv, nil},193 {"root reads private", domain.Root(), priv, nil},
194+ {"token with repo:read reads private", tokenPrincipal(domain.ScopeRepoRead), priv, nil},
195+ {"token limited to the repo reads private", tokenPrincipal(domain.ScopeRepoRead, "priv"), priv, nil},
196+ {"token limited to another repo cannot see private", tokenPrincipal(domain.ScopeRepoRead, "other"), priv, domain.ErrNotFound},
197+ {"token without repo:read cannot see private", tokenPrincipal(domain.ScopeRepoWrite), priv, domain.ErrNotFound},
198+ {"token without repo:read reads public", tokenPrincipal(domain.ScopeRepoWrite), pub, nil},
199+ {"token limited to another repo reads public", tokenPrincipal(domain.ScopeRepoRead, "other"), pub, nil},
161 }200 }
162 for _, tt := range tests {201 for _, tt := range tests {
163 t.Run("get/"+tt.name, func(t *testing.T) {202 t.Run("get/"+tt.name, func(t *testing.T) {
A internal/app/tokens.go +285 -0
@@ -0,0 +1,285 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "crypto/rand"
8+ "crypto/subtle"
9+ "encoding/base64"
10+ "encoding/hex"
11+ "errors"
12+ "fmt"
13+ "slices"
14+ "time"
15+ "unicode/utf8"
16+
17+ "github.com/bnema/zerowrap"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+// MaxTokenLabelLen is the longest token label, in bytes.
24+const MaxTokenLabelLen = 100
25+
26+// touchInterval is how often last_used_at of a token is written at most.
27+const touchInterval = time.Minute
28+
29+// Tokens are the use cases that manage stored tokens.
30+type Tokens struct {
31+ store ports.TokenStore
32+ clock ports.Clock
33+}
34+
35+// NewTokens builds the token use cases over their ports.
36+func NewTokens(store ports.TokenStore, clock ports.Clock) *Tokens {
37+ return &Tokens{store: store, clock: clock}
38+}
39+
40+// CreateTokenParams are the settings of a new token.
41+type CreateTokenParams struct {
42+ Label string
43+ // Scopes must not be empty.
44+ Scopes []domain.Scope
45+ // Repos limits the token to these repositories; empty means every one.
46+ Repos []domain.RepoName
47+ // ExpiresAt is the zero time for a token that never expires.
48+ ExpiresAt time.Time
49+}
50+
51+// CreatedToken is a new token with its secret, which is shown once and never
52+// stored.
53+type CreatedToken struct {
54+ Token domain.Token
55+ Secret string
56+}
57+
58+// Create makes a stored token and returns it with its secret.
59+//
60+// The principal needs token:admin: anonymous callers get
61+// domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token
62+// cannot create a token more powerful than itself: every scope must be one it
63+// holds, the repositories must lie within its list (a limited token cannot
64+// create an unlimited one) and the expiry must not outlast its own. Violations
65+// give domain.ErrForbidden. Root has no such limits. Invalid settings give
66+// domain.ErrInvalidToken, an unknown scope domain.ErrInvalidScope.
67+func (t *Tokens) Create(ctx context.Context, p domain.Principal, params CreateTokenParams) (CreatedToken, error) {
68+ if p.IsAnonymous() {
69+ return CreatedToken{}, domain.ErrUnauthenticated
70+ }
71+ if !canManageTokens(p) {
72+ return CreatedToken{}, fmt.Errorf("%w: creating tokens needs %s", domain.ErrForbidden, domain.ScopeTokenAdmin)
73+ }
74+ now := t.clock.Now().UTC()
75+ tok, err := buildToken(params, now)
76+ if err != nil {
77+ return CreatedToken{}, err
78+ }
79+ if err := checkNoEscalation(p, tok); err != nil {
80+ return CreatedToken{}, err
81+ }
82+
83+ for range 3 {
84+ secret, err := newSecret()
85+ if err != nil {
86+ return CreatedToken{}, err
87+ }
88+ id, err := newTokenID()
89+ if err != nil {
90+ return CreatedToken{}, err
91+ }
92+ tok.ID, tok.Hash = id, domain.HashTokenSecret(secret)
93+ err = t.store.Create(ctx, tok)
94+ if errors.Is(err, domain.ErrAlreadyExists) {
95+ continue // a random id or secret collided: practically never
96+ }
97+ if err != nil {
98+ return CreatedToken{}, fmt.Errorf("store token: %w", err)
99+ }
100+ log := zerowrap.FromCtx(ctx)
101+ log.Info().
102+ Str("token_id", tok.ID).Str("label", tok.Label).
103+ Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).
104+ Str("created_by", p.Kind().String()).Str("created_by_token", p.TokenID()).
105+ Msg("token created")
106+ return CreatedToken{Token: tok, Secret: secret}, nil
107+ }
108+ return CreatedToken{}, errors.New("store token: could not find a free id")
109+}
110+
111+// buildToken validates the settings and returns the token without id and hash.
112+func buildToken(params CreateTokenParams, now time.Time) (domain.Token, error) {
113+ if params.Label == "" || len(params.Label) > MaxTokenLabelLen || !utf8.ValidString(params.Label) {
114+ return domain.Token{}, fmt.Errorf("%w: label must be 1 to %d bytes of UTF-8", domain.ErrInvalidToken, MaxTokenLabelLen)
115+ }
116+ if len(params.Scopes) == 0 {
117+ return domain.Token{}, fmt.Errorf("%w: at least one scope is required", domain.ErrInvalidToken)
118+ }
119+ for _, s := range params.Scopes {
120+ if _, err := domain.ParseScope(s.String()); err != nil {
121+ return domain.Token{}, err
122+ }
123+ }
124+ for _, r := range params.Repos {
125+ if r.IsZero() {
126+ return domain.Token{}, fmt.Errorf("%w: empty repository name", domain.ErrInvalidToken)
127+ }
128+ }
129+ if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) {
130+ return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken)
131+ }
132+ tok := domain.Token{
133+ Label: params.Label,
134+ CreatedAt: now,
135+ ExpiresAt: params.ExpiresAt.UTC(),
136+ }
137+ // Canonical order, no duplicates.
138+ for _, s := range domain.AllScopes() {
139+ if slices.Contains(params.Scopes, s) {
140+ tok.Scopes = append(tok.Scopes, s)
141+ }
142+ }
143+ for _, r := range params.Repos {
144+ if !slices.Contains(tok.Repos, r) {
145+ tok.Repos = append(tok.Repos, r)
146+ }
147+ }
148+ slices.SortFunc(tok.Repos, func(a, b domain.RepoName) int { return compareNames(a, b) })
149+ return tok, nil
150+}
151+
152+func compareNames(a, b domain.RepoName) int {
153+ switch {
154+ case a.String() < b.String():
155+ return -1
156+ case a.String() > b.String():
157+ return 1
158+ }
159+ return 0
160+}
161+
162+// checkNoEscalation enforces that tok is within the rights of its creator.
163+func checkNoEscalation(p domain.Principal, tok domain.Token) error {
164+ if p.IsRoot() {
165+ return nil
166+ }
167+ for _, s := range tok.Scopes {
168+ if !p.HasScope(s) {
169+ return fmt.Errorf("%w: scope %s exceeds the creating token", domain.ErrForbidden, s)
170+ }
171+ }
172+ if own := p.Repos(); len(own) > 0 {
173+ if len(tok.Repos) == 0 {
174+ return fmt.Errorf("%w: the creating token is limited to a repository list, so the new token must be too", domain.ErrForbidden)
175+ }
176+ for _, r := range tok.Repos {
177+ if !slices.Contains(own, r) {
178+ return fmt.Errorf("%w: repository %s is outside the creating token", domain.ErrForbidden, r)
179+ }
180+ }
181+ }
182+ if exp := p.ExpiresAt(); !exp.IsZero() {
183+ if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) {
184+ return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden)
185+ }
186+ }
187+ return nil
188+}
189+
190+// List returns the stored tokens, oldest first. It needs token:admin.
191+func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) {
192+ if err := requireTokenAdmin(p); err != nil {
193+ return nil, err
194+ }
195+ return t.store.List(ctx)
196+}
197+
198+// Revoke deletes a stored token; it stops working at once. It needs
199+// token:admin and returns domain.ErrNotFound for an unknown id.
200+func (t *Tokens) Revoke(ctx context.Context, p domain.Principal, id string) error {
201+ if err := requireTokenAdmin(p); err != nil {
202+ return err
203+ }
204+ if err := t.store.Revoke(ctx, id); err != nil {
205+ return err
206+ }
207+ log := zerowrap.FromCtx(ctx)
208+ log.Info().Str("token_id", id).Str("revoked_by", p.Kind().String()).Str("revoked_by_token", p.TokenID()).Msg("token revoked")
209+ return nil
210+}
211+
212+func requireTokenAdmin(p domain.Principal) error {
213+ if p.IsAnonymous() {
214+ return domain.ErrUnauthenticated
215+ }
216+ if !canManageTokens(p) {
217+ return fmt.Errorf("%w: needs %s", domain.ErrForbidden, domain.ScopeTokenAdmin)
218+ }
219+ return nil
220+}
221+
222+// newSecret returns "ermite_" and 32 random bytes in base64url.
223+func newSecret() (string, error) {
224+ var raw [32]byte
225+ if _, err := rand.Read(raw[:]); err != nil {
226+ return "", fmt.Errorf("random token secret: %w", err)
227+ }
228+ return domain.TokenSecretPrefix + base64.RawURLEncoding.EncodeToString(raw[:]), nil
229+}
230+
231+// newTokenID returns 16 hex characters.
232+func newTokenID() (string, error) {
233+ var raw [8]byte
234+ if _, err := rand.Read(raw[:]); err != nil {
235+ return "", fmt.Errorf("random token id: %w", err)
236+ }
237+ return hex.EncodeToString(raw[:]), nil
238+}
239+
240+// Auth authenticates secrets presented by callers.
241+type Auth struct {
242+ root domain.TokenHash
243+ tokens ports.TokenStore
244+ clock ports.Clock
245+}
246+
247+// NewAuth builds the authentication use case. rootHash is the SHA-256 of the
248+// admin token.
249+func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clock) *Auth {
250+ return &Auth{root: rootHash, tokens: tokens, clock: clock}
251+}
252+
253+// Authenticate resolves a token secret to its principal: root when it is the
254+// admin token (constant-time comparison of the hashes), else the stored token
255+// with that hash if it has not expired. Any other secret, including the empty
256+// one, gives domain.ErrUnauthenticated. last_used_at of a stored token is
257+// written at most once a minute; a failure to write it is logged, not
258+// returned.
259+func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principal, error) {
260+ if secret == "" {
261+ return domain.Principal{}, domain.ErrUnauthenticated
262+ }
263+ h := domain.HashTokenSecret(secret)
264+ if subtle.ConstantTimeCompare(h[:], a.root[:]) == 1 {
265+ return domain.Root(), nil
266+ }
267+ tok, err := a.tokens.FindByHash(ctx, h)
268+ if errors.Is(err, domain.ErrNotFound) {
269+ return domain.Principal{}, domain.ErrUnauthenticated
270+ }
271+ if err != nil {
272+ return domain.Principal{}, fmt.Errorf("find token: %w", err)
273+ }
274+ now := a.clock.Now().UTC()
275+ if tok.Expired(now) {
276+ return domain.Principal{}, domain.ErrUnauthenticated
277+ }
278+ if tok.LastUsedAt.IsZero() || now.Sub(tok.LastUsedAt) >= touchInterval {
279+ if err := a.tokens.Touch(ctx, tok.ID, now); err != nil && !errors.Is(err, domain.ErrNotFound) {
280+ log := zerowrap.FromCtx(ctx)
281+ log.Warn().Err(err).Str("token_id", tok.ID).Msg("record token use")
282+ }
283+ }
284+ return domain.TokenPrincipal(tok), nil
285+}
@@ -0,0 +1,285 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "crypto/rand"
8+ "crypto/subtle"
9+ "encoding/base64"
10+ "encoding/hex"
11+ "errors"
12+ "fmt"
13+ "slices"
14+ "time"
15+ "unicode/utf8"
16+
17+ "github.com/bnema/zerowrap"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+// MaxTokenLabelLen is the longest token label, in bytes.
24+const MaxTokenLabelLen = 100
25+
26+// touchInterval is how often last_used_at of a token is written at most.
27+const touchInterval = time.Minute
28+
29+// Tokens are the use cases that manage stored tokens.
30+type Tokens struct {
31+ store ports.TokenStore
32+ clock ports.Clock
33+}
34+
35+// NewTokens builds the token use cases over their ports.
36+func NewTokens(store ports.TokenStore, clock ports.Clock) *Tokens {
37+ return &Tokens{store: store, clock: clock}
38+}
39+
40+// CreateTokenParams are the settings of a new token.
41+type CreateTokenParams struct {
42+ Label string
43+ // Scopes must not be empty.
44+ Scopes []domain.Scope
45+ // Repos limits the token to these repositories; empty means every one.
46+ Repos []domain.RepoName
47+ // ExpiresAt is the zero time for a token that never expires.
48+ ExpiresAt time.Time
49+}
50+
51+// CreatedToken is a new token with its secret, which is shown once and never
52+// stored.
53+type CreatedToken struct {
54+ Token domain.Token
55+ Secret string
56+}
57+
58+// Create makes a stored token and returns it with its secret.
59+//
60+// The principal needs token:admin: anonymous callers get
61+// domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token
62+// cannot create a token more powerful than itself: every scope must be one it
63+// holds, the repositories must lie within its list (a limited token cannot
64+// create an unlimited one) and the expiry must not outlast its own. Violations
65+// give domain.ErrForbidden. Root has no such limits. Invalid settings give
66+// domain.ErrInvalidToken, an unknown scope domain.ErrInvalidScope.
67+func (t *Tokens) Create(ctx context.Context, p domain.Principal, params CreateTokenParams) (CreatedToken, error) {
68+ if p.IsAnonymous() {
69+ return CreatedToken{}, domain.ErrUnauthenticated
70+ }
71+ if !canManageTokens(p) {
72+ return CreatedToken{}, fmt.Errorf("%w: creating tokens needs %s", domain.ErrForbidden, domain.ScopeTokenAdmin)
73+ }
74+ now := t.clock.Now().UTC()
75+ tok, err := buildToken(params, now)
76+ if err != nil {
77+ return CreatedToken{}, err
78+ }
79+ if err := checkNoEscalation(p, tok); err != nil {
80+ return CreatedToken{}, err
81+ }
82+
83+ for range 3 {
84+ secret, err := newSecret()
85+ if err != nil {
86+ return CreatedToken{}, err
87+ }
88+ id, err := newTokenID()
89+ if err != nil {
90+ return CreatedToken{}, err
91+ }
92+ tok.ID, tok.Hash = id, domain.HashTokenSecret(secret)
93+ err = t.store.Create(ctx, tok)
94+ if errors.Is(err, domain.ErrAlreadyExists) {
95+ continue // a random id or secret collided: practically never
96+ }
97+ if err != nil {
98+ return CreatedToken{}, fmt.Errorf("store token: %w", err)
99+ }
100+ log := zerowrap.FromCtx(ctx)
101+ log.Info().
102+ Str("token_id", tok.ID).Str("label", tok.Label).
103+ Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).
104+ Str("created_by", p.Kind().String()).Str("created_by_token", p.TokenID()).
105+ Msg("token created")
106+ return CreatedToken{Token: tok, Secret: secret}, nil
107+ }
108+ return CreatedToken{}, errors.New("store token: could not find a free id")
109+}
110+
111+// buildToken validates the settings and returns the token without id and hash.
112+func buildToken(params CreateTokenParams, now time.Time) (domain.Token, error) {
113+ if params.Label == "" || len(params.Label) > MaxTokenLabelLen || !utf8.ValidString(params.Label) {
114+ return domain.Token{}, fmt.Errorf("%w: label must be 1 to %d bytes of UTF-8", domain.ErrInvalidToken, MaxTokenLabelLen)
115+ }
116+ if len(params.Scopes) == 0 {
117+ return domain.Token{}, fmt.Errorf("%w: at least one scope is required", domain.ErrInvalidToken)
118+ }
119+ for _, s := range params.Scopes {
120+ if _, err := domain.ParseScope(s.String()); err != nil {
121+ return domain.Token{}, err
122+ }
123+ }
124+ for _, r := range params.Repos {
125+ if r.IsZero() {
126+ return domain.Token{}, fmt.Errorf("%w: empty repository name", domain.ErrInvalidToken)
127+ }
128+ }
129+ if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) {
130+ return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken)
131+ }
132+ tok := domain.Token{
133+ Label: params.Label,
134+ CreatedAt: now,
135+ ExpiresAt: params.ExpiresAt.UTC(),
136+ }
137+ // Canonical order, no duplicates.
138+ for _, s := range domain.AllScopes() {
139+ if slices.Contains(params.Scopes, s) {
140+ tok.Scopes = append(tok.Scopes, s)
141+ }
142+ }
143+ for _, r := range params.Repos {
144+ if !slices.Contains(tok.Repos, r) {
145+ tok.Repos = append(tok.Repos, r)
146+ }
147+ }
148+ slices.SortFunc(tok.Repos, func(a, b domain.RepoName) int { return compareNames(a, b) })
149+ return tok, nil
150+}
151+
152+func compareNames(a, b domain.RepoName) int {
153+ switch {
154+ case a.String() < b.String():
155+ return -1
156+ case a.String() > b.String():
157+ return 1
158+ }
159+ return 0
160+}
161+
162+// checkNoEscalation enforces that tok is within the rights of its creator.
163+func checkNoEscalation(p domain.Principal, tok domain.Token) error {
164+ if p.IsRoot() {
165+ return nil
166+ }
167+ for _, s := range tok.Scopes {
168+ if !p.HasScope(s) {
169+ return fmt.Errorf("%w: scope %s exceeds the creating token", domain.ErrForbidden, s)
170+ }
171+ }
172+ if own := p.Repos(); len(own) > 0 {
173+ if len(tok.Repos) == 0 {
174+ return fmt.Errorf("%w: the creating token is limited to a repository list, so the new token must be too", domain.ErrForbidden)
175+ }
176+ for _, r := range tok.Repos {
177+ if !slices.Contains(own, r) {
178+ return fmt.Errorf("%w: repository %s is outside the creating token", domain.ErrForbidden, r)
179+ }
180+ }
181+ }
182+ if exp := p.ExpiresAt(); !exp.IsZero() {
183+ if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) {
184+ return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden)
185+ }
186+ }
187+ return nil
188+}
189+
190+// List returns the stored tokens, oldest first. It needs token:admin.
191+func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) {
192+ if err := requireTokenAdmin(p); err != nil {
193+ return nil, err
194+ }
195+ return t.store.List(ctx)
196+}
197+
198+// Revoke deletes a stored token; it stops working at once. It needs
199+// token:admin and returns domain.ErrNotFound for an unknown id.
200+func (t *Tokens) Revoke(ctx context.Context, p domain.Principal, id string) error {
201+ if err := requireTokenAdmin(p); err != nil {
202+ return err
203+ }
204+ if err := t.store.Revoke(ctx, id); err != nil {
205+ return err
206+ }
207+ log := zerowrap.FromCtx(ctx)
208+ log.Info().Str("token_id", id).Str("revoked_by", p.Kind().String()).Str("revoked_by_token", p.TokenID()).Msg("token revoked")
209+ return nil
210+}
211+
212+func requireTokenAdmin(p domain.Principal) error {
213+ if p.IsAnonymous() {
214+ return domain.ErrUnauthenticated
215+ }
216+ if !canManageTokens(p) {
217+ return fmt.Errorf("%w: needs %s", domain.ErrForbidden, domain.ScopeTokenAdmin)
218+ }
219+ return nil
220+}
221+
222+// newSecret returns "ermite_" and 32 random bytes in base64url.
223+func newSecret() (string, error) {
224+ var raw [32]byte
225+ if _, err := rand.Read(raw[:]); err != nil {
226+ return "", fmt.Errorf("random token secret: %w", err)
227+ }
228+ return domain.TokenSecretPrefix + base64.RawURLEncoding.EncodeToString(raw[:]), nil
229+}
230+
231+// newTokenID returns 16 hex characters.
232+func newTokenID() (string, error) {
233+ var raw [8]byte
234+ if _, err := rand.Read(raw[:]); err != nil {
235+ return "", fmt.Errorf("random token id: %w", err)
236+ }
237+ return hex.EncodeToString(raw[:]), nil
238+}
239+
240+// Auth authenticates secrets presented by callers.
241+type Auth struct {
242+ root domain.TokenHash
243+ tokens ports.TokenStore
244+ clock ports.Clock
245+}
246+
247+// NewAuth builds the authentication use case. rootHash is the SHA-256 of the
248+// admin token.
249+func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clock) *Auth {
250+ return &Auth{root: rootHash, tokens: tokens, clock: clock}
251+}
252+
253+// Authenticate resolves a token secret to its principal: root when it is the
254+// admin token (constant-time comparison of the hashes), else the stored token
255+// with that hash if it has not expired. Any other secret, including the empty
256+// one, gives domain.ErrUnauthenticated. last_used_at of a stored token is
257+// written at most once a minute; a failure to write it is logged, not
258+// returned.
259+func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principal, error) {
260+ if secret == "" {
261+ return domain.Principal{}, domain.ErrUnauthenticated
262+ }
263+ h := domain.HashTokenSecret(secret)
264+ if subtle.ConstantTimeCompare(h[:], a.root[:]) == 1 {
265+ return domain.Root(), nil
266+ }
267+ tok, err := a.tokens.FindByHash(ctx, h)
268+ if errors.Is(err, domain.ErrNotFound) {
269+ return domain.Principal{}, domain.ErrUnauthenticated
270+ }
271+ if err != nil {
272+ return domain.Principal{}, fmt.Errorf("find token: %w", err)
273+ }
274+ now := a.clock.Now().UTC()
275+ if tok.Expired(now) {
276+ return domain.Principal{}, domain.ErrUnauthenticated
277+ }
278+ if tok.LastUsedAt.IsZero() || now.Sub(tok.LastUsedAt) >= touchInterval {
279+ if err := a.tokens.Touch(ctx, tok.ID, now); err != nil && !errors.Is(err, domain.ErrNotFound) {
280+ log := zerowrap.FromCtx(ctx)
281+ log.Warn().Err(err).Str("token_id", tok.ID).Msg("record token use")
282+ }
283+ }
284+ return domain.TokenPrincipal(tok), nil
285+}
A internal/app/tokens_test.go +307 -0
@@ -0,0 +1,307 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/app"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+type tokenFixture struct {
23+ store *mocks.TokenStore
24+ clock *mocks.Clock
25+ tokens *app.Tokens
26+ auth *app.Auth
27+}
28+
29+var rootSecret = "root-secret-0123456789abcdef0123456789"
30+
31+func newTokenFixture(t *testing.T) *tokenFixture {
32+ t.Helper()
33+ f := &tokenFixture{store: mocks.NewTokenStore(t), clock: mocks.NewClock(t)}
34+ f.clock.EXPECT().Now().Return(now).Maybe()
35+ f.tokens = app.NewTokens(f.store, f.clock)
36+ f.auth = app.NewAuth(domain.HashTokenSecret(rootSecret), f.store, f.clock)
37+ return f
38+}
39+
40+func scopes(s ...domain.Scope) []domain.Scope { return s }
41+
42+func names(t *testing.T, ss ...string) []domain.RepoName {
43+ t.Helper()
44+ var out []domain.RepoName
45+ for _, s := range ss {
46+ out = append(out, name(t, s))
47+ }
48+ return out
49+}
50+
51+func TestTokensCreate(t *testing.T) {
52+ ctx := context.Background()
53+
54+ t.Run("root creates a token with a well-formed secret", func(t *testing.T) {
55+ f := newTokenFixture(t)
56+ var stored domain.Token
57+ f.store.EXPECT().Create(ctx, mock.Anything).Run(func(_ context.Context, tok domain.Token) { stored = tok }).Return(nil)
58+
59+ exp := now.Add(48 * time.Hour)
60+ got, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{
61+ Label: "ci",
62+ Scopes: scopes(domain.ScopeRepoWrite, domain.ScopeRepoRead, domain.ScopeRepoRead),
63+ Repos: names(t, "b", "a", "b"),
64+ ExpiresAt: exp,
65+ })
66+ require.NoError(t, err)
67+
68+ assert.True(t, strings.HasPrefix(got.Secret, "ermite_"))
69+ raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(got.Secret, "ermite_"))
70+ require.NoError(t, err)
71+ assert.Len(t, raw, 32)
72+
73+ assert.Equal(t, stored, got.Token)
74+ assert.Equal(t, domain.HashTokenSecret(got.Secret), got.Token.Hash)
75+ assert.Len(t, got.Token.ID, 16)
76+ assert.Equal(t, scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), got.Token.Scopes, "canonical order, deduplicated")
77+ assert.Equal(t, names(t, "a", "b"), got.Token.Repos, "sorted, deduplicated")
78+ assert.Equal(t, now, got.Token.CreatedAt)
79+ assert.True(t, exp.Equal(got.Token.ExpiresAt))
80+ assert.NotContains(t, got.Token.Label, got.Secret)
81+ })
82+
83+ t.Run("secrets are unique", func(t *testing.T) {
84+ f := newTokenFixture(t)
85+ f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
86+ a, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
87+ require.NoError(t, err)
88+ b, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
89+ require.NoError(t, err)
90+ assert.NotEqual(t, a.Secret, b.Secret)
91+ assert.NotEqual(t, a.Token.ID, b.Token.ID)
92+ })
93+
94+ t.Run("retries on a collision", func(t *testing.T) {
95+ f := newTokenFixture(t)
96+ f.store.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists).Once()
97+ f.store.EXPECT().Create(ctx, mock.Anything).Return(nil).Once()
98+ _, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
99+ require.NoError(t, err)
100+ })
101+
102+ t.Run("store failure", func(t *testing.T) {
103+ f := newTokenFixture(t)
104+ boom := errors.New("boom")
105+ f.store.EXPECT().Create(ctx, mock.Anything).Return(boom)
106+ _, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
107+ assert.ErrorIs(t, err, boom)
108+ })
109+
110+ invalid := []struct {
111+ name string
112+ params app.CreateTokenParams
113+ want error
114+ }{
115+ {"empty label", app.CreateTokenParams{Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
116+ {"long label", app.CreateTokenParams{Label: strings.Repeat("x", 101), Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
117+ {"bad utf8 label", app.CreateTokenParams{Label: "\xff", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
118+ {"no scopes", app.CreateTokenParams{Label: "x"}, domain.ErrInvalidToken},
119+ {"unknown scope", app.CreateTokenParams{Label: "x", Scopes: scopes("nope")}, domain.ErrInvalidScope},
120+ {"past expiry", app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: now}, domain.ErrInvalidToken},
121+ {"zero repo name", app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: []domain.RepoName{{}}}, domain.ErrInvalidToken},
122+ }
123+ for _, tt := range invalid {
124+ t.Run("invalid/"+tt.name, func(t *testing.T) {
125+ f := newTokenFixture(t)
126+ _, err := f.tokens.Create(ctx, domain.Root(), tt.params)
127+ assert.ErrorIs(t, err, tt.want)
128+ })
129+ }
130+}
131+
132+func TestTokensCreateNoEscalation(t *testing.T) {
133+ ctx := context.Background()
134+ exp := now.Add(time.Hour)
135+ admin := func(repos []domain.RepoName, expires time.Time, extra ...domain.Scope) domain.Principal {
136+ return domain.TokenPrincipal(domain.Token{
137+ ID: "creator",
138+ Scopes: append(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), extra...),
139+ Repos: repos,
140+ ExpiresAt: expires,
141+ })
142+ }
143+
144+ tests := []struct {
145+ name string
146+ principal domain.Principal
147+ params app.CreateTokenParams
148+ want error // nil = allowed
149+ }{
150+ {"anonymous", domain.Anonymous(), app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrUnauthenticated},
151+ {"token without token:admin",
152+ domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)}),
153+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
154+ {"subset of scopes", admin(nil, time.Time{}),
155+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, nil},
156+ {"same scopes incl. token:admin", admin(nil, time.Time{}),
157+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite)}, nil},
158+ {"scope it lacks", admin(nil, time.Time{}),
159+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin)}, domain.ErrForbidden},
160+ {"limited creator, same repos", admin(names(t, "a", "b"), time.Time{}),
161+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "a")}, nil},
162+ {"limited creator, repo outside its list", admin(names(t, "a"), time.Time{}),
163+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "a", "z")}, domain.ErrForbidden},
164+ {"limited creator, unlimited child", admin(names(t, "a"), time.Time{}),
165+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
166+ {"unlimited creator may limit", admin(nil, time.Time{}),
167+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "z")}, nil},
168+ {"expiring creator, child never expires", admin(nil, exp),
169+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
170+ {"expiring creator, child outlasts", admin(nil, exp),
171+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp.Add(time.Second)}, domain.ErrForbidden},
172+ {"expiring creator, child within", admin(nil, exp),
173+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp.Add(-time.Second)}, nil},
174+ {"expiring creator, child same expiry", admin(nil, exp),
175+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil},
176+ {"root: everything", domain.Root(),
177+ app.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil},
178+ }
179+ for _, tt := range tests {
180+ t.Run(tt.name, func(t *testing.T) {
181+ f := newTokenFixture(t)
182+ if tt.want == nil {
183+ f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
184+ }
185+ got, err := f.tokens.Create(ctx, tt.principal, tt.params)
186+ if tt.want != nil {
187+ assert.ErrorIs(t, err, tt.want)
188+ assert.Empty(t, got.Secret)
189+ return
190+ }
191+ require.NoError(t, err)
192+ assert.NotEmpty(t, got.Secret)
193+ })
194+ }
195+}
196+
197+func TestTokensListRevoke(t *testing.T) {
198+ ctx := context.Background()
199+ tokenAdmin := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeTokenAdmin)})
200+ writer := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)})
201+
202+ t.Run("list", func(t *testing.T) {
203+ f := newTokenFixture(t)
204+ want := []domain.Token{{ID: "a"}}
205+ f.store.EXPECT().List(ctx).Return(want, nil).Twice()
206+ for _, p := range []domain.Principal{domain.Root(), tokenAdmin} {
207+ got, err := f.tokens.List(ctx, p)
208+ require.NoError(t, err)
209+ assert.Equal(t, want, got)
210+ }
211+ _, err := f.tokens.List(ctx, writer)
212+ assert.ErrorIs(t, err, domain.ErrForbidden)
213+ _, err = f.tokens.List(ctx, domain.Anonymous())
214+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
215+ })
216+
217+ t.Run("revoke", func(t *testing.T) {
218+ f := newTokenFixture(t)
219+ f.store.EXPECT().Revoke(ctx, "a").Return(nil).Once()
220+ f.store.EXPECT().Revoke(ctx, "zz").Return(domain.ErrNotFound).Once()
221+ require.NoError(t, f.tokens.Revoke(ctx, tokenAdmin, "a"))
222+ assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Root(), "zz"), domain.ErrNotFound)
223+ assert.ErrorIs(t, f.tokens.Revoke(ctx, writer, "a"), domain.ErrForbidden)
224+ assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Anonymous(), "a"), domain.ErrUnauthenticated)
225+ })
226+}
227+
228+func TestAuthenticate(t *testing.T) {
229+ ctx := context.Background()
230+ secret := "ermite_stored-secret"
231+ hash := domain.HashTokenSecret(secret)
232+ tok := domain.Token{
233+ ID: "t1", Hash: hash, Scopes: scopes(domain.ScopeRepoWrite), Repos: names(t, "a"),
234+ ExpiresAt: now.Add(time.Hour),
235+ }
236+
237+ t.Run("root", func(t *testing.T) {
238+ f := newTokenFixture(t)
239+ p, err := f.auth.Authenticate(ctx, rootSecret)
240+ require.NoError(t, err)
241+ assert.True(t, p.IsRoot())
242+ })
243+
244+ t.Run("empty and unknown secrets", func(t *testing.T) {
245+ f := newTokenFixture(t)
246+ _, err := f.auth.Authenticate(ctx, "")
247+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
248+ f.store.EXPECT().FindByHash(ctx, domain.HashTokenSecret("nope")).Return(domain.Token{}, domain.ErrNotFound)
249+ _, err = f.auth.Authenticate(ctx, "nope")
250+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
251+ })
252+
253+ t.Run("stored token first use touches", func(t *testing.T) {
254+ f := newTokenFixture(t)
255+ f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil)
256+ f.store.EXPECT().Touch(ctx, "t1", now).Return(nil)
257+ p, err := f.auth.Authenticate(ctx, secret)
258+ require.NoError(t, err)
259+ assert.Equal(t, domain.PrincipalToken, p.Kind())
260+ assert.Equal(t, "t1", p.TokenID())
261+ assert.True(t, p.Allows(domain.ScopeRepoWrite, name(t, "a")))
262+ assert.False(t, p.Allows(domain.ScopeRepoWrite, name(t, "b")))
263+ })
264+
265+ t.Run("touch at most once a minute", func(t *testing.T) {
266+ f := newTokenFixture(t)
267+ recent := tok
268+ recent.LastUsedAt = now.Add(-30 * time.Second)
269+ f.store.EXPECT().FindByHash(ctx, hash).Return(recent, nil)
270+ _, err := f.auth.Authenticate(ctx, secret) // no Touch expected
271+ require.NoError(t, err)
272+
273+ f2 := newTokenFixture(t)
274+ old := tok
275+ old.LastUsedAt = now.Add(-time.Minute)
276+ f2.store.EXPECT().FindByHash(ctx, hash).Return(old, nil)
277+ f2.store.EXPECT().Touch(ctx, "t1", now).Return(nil)
278+ _, err = f2.auth.Authenticate(ctx, secret)
279+ require.NoError(t, err)
280+ })
281+
282+ t.Run("touch failure does not fail authentication", func(t *testing.T) {
283+ f := newTokenFixture(t)
284+ f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil)
285+ f.store.EXPECT().Touch(ctx, "t1", now).Return(errors.New("disk"))
286+ _, err := f.auth.Authenticate(ctx, secret)
287+ require.NoError(t, err)
288+ })
289+
290+ t.Run("expired", func(t *testing.T) {
291+ f := newTokenFixture(t)
292+ exp := tok
293+ exp.ExpiresAt = now
294+ f.store.EXPECT().FindByHash(ctx, hash).Return(exp, nil)
295+ _, err := f.auth.Authenticate(ctx, secret)
296+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
297+ })
298+
299+ t.Run("store error is not an authentication failure", func(t *testing.T) {
300+ f := newTokenFixture(t)
301+ boom := errors.New("boom")
302+ f.store.EXPECT().FindByHash(ctx, hash).Return(domain.Token{}, boom)
303+ _, err := f.auth.Authenticate(ctx, secret)
304+ assert.ErrorIs(t, err, boom)
305+ assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
306+ })
307+}
@@ -0,0 +1,307 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/app"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+type tokenFixture struct {
23+ store *mocks.TokenStore
24+ clock *mocks.Clock
25+ tokens *app.Tokens
26+ auth *app.Auth
27+}
28+
29+var rootSecret = "root-secret-0123456789abcdef0123456789"
30+
31+func newTokenFixture(t *testing.T) *tokenFixture {
32+ t.Helper()
33+ f := &tokenFixture{store: mocks.NewTokenStore(t), clock: mocks.NewClock(t)}
34+ f.clock.EXPECT().Now().Return(now).Maybe()
35+ f.tokens = app.NewTokens(f.store, f.clock)
36+ f.auth = app.NewAuth(domain.HashTokenSecret(rootSecret), f.store, f.clock)
37+ return f
38+}
39+
40+func scopes(s ...domain.Scope) []domain.Scope { return s }
41+
42+func names(t *testing.T, ss ...string) []domain.RepoName {
43+ t.Helper()
44+ var out []domain.RepoName
45+ for _, s := range ss {
46+ out = append(out, name(t, s))
47+ }
48+ return out
49+}
50+
51+func TestTokensCreate(t *testing.T) {
52+ ctx := context.Background()
53+
54+ t.Run("root creates a token with a well-formed secret", func(t *testing.T) {
55+ f := newTokenFixture(t)
56+ var stored domain.Token
57+ f.store.EXPECT().Create(ctx, mock.Anything).Run(func(_ context.Context, tok domain.Token) { stored = tok }).Return(nil)
58+
59+ exp := now.Add(48 * time.Hour)
60+ got, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{
61+ Label: "ci",
62+ Scopes: scopes(domain.ScopeRepoWrite, domain.ScopeRepoRead, domain.ScopeRepoRead),
63+ Repos: names(t, "b", "a", "b"),
64+ ExpiresAt: exp,
65+ })
66+ require.NoError(t, err)
67+
68+ assert.True(t, strings.HasPrefix(got.Secret, "ermite_"))
69+ raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(got.Secret, "ermite_"))
70+ require.NoError(t, err)
71+ assert.Len(t, raw, 32)
72+
73+ assert.Equal(t, stored, got.Token)
74+ assert.Equal(t, domain.HashTokenSecret(got.Secret), got.Token.Hash)
75+ assert.Len(t, got.Token.ID, 16)
76+ assert.Equal(t, scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), got.Token.Scopes, "canonical order, deduplicated")
77+ assert.Equal(t, names(t, "a", "b"), got.Token.Repos, "sorted, deduplicated")
78+ assert.Equal(t, now, got.Token.CreatedAt)
79+ assert.True(t, exp.Equal(got.Token.ExpiresAt))
80+ assert.NotContains(t, got.Token.Label, got.Secret)
81+ })
82+
83+ t.Run("secrets are unique", func(t *testing.T) {
84+ f := newTokenFixture(t)
85+ f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
86+ a, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
87+ require.NoError(t, err)
88+ b, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
89+ require.NoError(t, err)
90+ assert.NotEqual(t, a.Secret, b.Secret)
91+ assert.NotEqual(t, a.Token.ID, b.Token.ID)
92+ })
93+
94+ t.Run("retries on a collision", func(t *testing.T) {
95+ f := newTokenFixture(t)
96+ f.store.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists).Once()
97+ f.store.EXPECT().Create(ctx, mock.Anything).Return(nil).Once()
98+ _, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
99+ require.NoError(t, err)
100+ })
101+
102+ t.Run("store failure", func(t *testing.T) {
103+ f := newTokenFixture(t)
104+ boom := errors.New("boom")
105+ f.store.EXPECT().Create(ctx, mock.Anything).Return(boom)
106+ _, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
107+ assert.ErrorIs(t, err, boom)
108+ })
109+
110+ invalid := []struct {
111+ name string
112+ params app.CreateTokenParams
113+ want error
114+ }{
115+ {"empty label", app.CreateTokenParams{Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
116+ {"long label", app.CreateTokenParams{Label: strings.Repeat("x", 101), Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
117+ {"bad utf8 label", app.CreateTokenParams{Label: "\xff", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
118+ {"no scopes", app.CreateTokenParams{Label: "x"}, domain.ErrInvalidToken},
119+ {"unknown scope", app.CreateTokenParams{Label: "x", Scopes: scopes("nope")}, domain.ErrInvalidScope},
120+ {"past expiry", app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: now}, domain.ErrInvalidToken},
121+ {"zero repo name", app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: []domain.RepoName{{}}}, domain.ErrInvalidToken},
122+ }
123+ for _, tt := range invalid {
124+ t.Run("invalid/"+tt.name, func(t *testing.T) {
125+ f := newTokenFixture(t)
126+ _, err := f.tokens.Create(ctx, domain.Root(), tt.params)
127+ assert.ErrorIs(t, err, tt.want)
128+ })
129+ }
130+}
131+
132+func TestTokensCreateNoEscalation(t *testing.T) {
133+ ctx := context.Background()
134+ exp := now.Add(time.Hour)
135+ admin := func(repos []domain.RepoName, expires time.Time, extra ...domain.Scope) domain.Principal {
136+ return domain.TokenPrincipal(domain.Token{
137+ ID: "creator",
138+ Scopes: append(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), extra...),
139+ Repos: repos,
140+ ExpiresAt: expires,
141+ })
142+ }
143+
144+ tests := []struct {
145+ name string
146+ principal domain.Principal
147+ params app.CreateTokenParams
148+ want error // nil = allowed
149+ }{
150+ {"anonymous", domain.Anonymous(), app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrUnauthenticated},
151+ {"token without token:admin",
152+ domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)}),
153+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
154+ {"subset of scopes", admin(nil, time.Time{}),
155+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, nil},
156+ {"same scopes incl. token:admin", admin(nil, time.Time{}),
157+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite)}, nil},
158+ {"scope it lacks", admin(nil, time.Time{}),
159+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin)}, domain.ErrForbidden},
160+ {"limited creator, same repos", admin(names(t, "a", "b"), time.Time{}),
161+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "a")}, nil},
162+ {"limited creator, repo outside its list", admin(names(t, "a"), time.Time{}),
163+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "a", "z")}, domain.ErrForbidden},
164+ {"limited creator, unlimited child", admin(names(t, "a"), time.Time{}),
165+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
166+ {"unlimited creator may limit", admin(nil, time.Time{}),
167+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "z")}, nil},
168+ {"expiring creator, child never expires", admin(nil, exp),
169+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
170+ {"expiring creator, child outlasts", admin(nil, exp),
171+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp.Add(time.Second)}, domain.ErrForbidden},
172+ {"expiring creator, child within", admin(nil, exp),
173+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp.Add(-time.Second)}, nil},
174+ {"expiring creator, child same expiry", admin(nil, exp),
175+ app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil},
176+ {"root: everything", domain.Root(),
177+ app.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil},
178+ }
179+ for _, tt := range tests {
180+ t.Run(tt.name, func(t *testing.T) {
181+ f := newTokenFixture(t)
182+ if tt.want == nil {
183+ f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
184+ }
185+ got, err := f.tokens.Create(ctx, tt.principal, tt.params)
186+ if tt.want != nil {
187+ assert.ErrorIs(t, err, tt.want)
188+ assert.Empty(t, got.Secret)
189+ return
190+ }
191+ require.NoError(t, err)
192+ assert.NotEmpty(t, got.Secret)
193+ })
194+ }
195+}
196+
197+func TestTokensListRevoke(t *testing.T) {
198+ ctx := context.Background()
199+ tokenAdmin := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeTokenAdmin)})
200+ writer := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)})
201+
202+ t.Run("list", func(t *testing.T) {
203+ f := newTokenFixture(t)
204+ want := []domain.Token{{ID: "a"}}
205+ f.store.EXPECT().List(ctx).Return(want, nil).Twice()
206+ for _, p := range []domain.Principal{domain.Root(), tokenAdmin} {
207+ got, err := f.tokens.List(ctx, p)
208+ require.NoError(t, err)
209+ assert.Equal(t, want, got)
210+ }
211+ _, err := f.tokens.List(ctx, writer)
212+ assert.ErrorIs(t, err, domain.ErrForbidden)
213+ _, err = f.tokens.List(ctx, domain.Anonymous())
214+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
215+ })
216+
217+ t.Run("revoke", func(t *testing.T) {
218+ f := newTokenFixture(t)
219+ f.store.EXPECT().Revoke(ctx, "a").Return(nil).Once()
220+ f.store.EXPECT().Revoke(ctx, "zz").Return(domain.ErrNotFound).Once()
221+ require.NoError(t, f.tokens.Revoke(ctx, tokenAdmin, "a"))
222+ assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Root(), "zz"), domain.ErrNotFound)
223+ assert.ErrorIs(t, f.tokens.Revoke(ctx, writer, "a"), domain.ErrForbidden)
224+ assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Anonymous(), "a"), domain.ErrUnauthenticated)
225+ })
226+}
227+
228+func TestAuthenticate(t *testing.T) {
229+ ctx := context.Background()
230+ secret := "ermite_stored-secret"
231+ hash := domain.HashTokenSecret(secret)
232+ tok := domain.Token{
233+ ID: "t1", Hash: hash, Scopes: scopes(domain.ScopeRepoWrite), Repos: names(t, "a"),
234+ ExpiresAt: now.Add(time.Hour),
235+ }
236+
237+ t.Run("root", func(t *testing.T) {
238+ f := newTokenFixture(t)
239+ p, err := f.auth.Authenticate(ctx, rootSecret)
240+ require.NoError(t, err)
241+ assert.True(t, p.IsRoot())
242+ })
243+
244+ t.Run("empty and unknown secrets", func(t *testing.T) {
245+ f := newTokenFixture(t)
246+ _, err := f.auth.Authenticate(ctx, "")
247+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
248+ f.store.EXPECT().FindByHash(ctx, domain.HashTokenSecret("nope")).Return(domain.Token{}, domain.ErrNotFound)
249+ _, err = f.auth.Authenticate(ctx, "nope")
250+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
251+ })
252+
253+ t.Run("stored token first use touches", func(t *testing.T) {
254+ f := newTokenFixture(t)
255+ f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil)
256+ f.store.EXPECT().Touch(ctx, "t1", now).Return(nil)
257+ p, err := f.auth.Authenticate(ctx, secret)
258+ require.NoError(t, err)
259+ assert.Equal(t, domain.PrincipalToken, p.Kind())
260+ assert.Equal(t, "t1", p.TokenID())
261+ assert.True(t, p.Allows(domain.ScopeRepoWrite, name(t, "a")))
262+ assert.False(t, p.Allows(domain.ScopeRepoWrite, name(t, "b")))
263+ })
264+
265+ t.Run("touch at most once a minute", func(t *testing.T) {
266+ f := newTokenFixture(t)
267+ recent := tok
268+ recent.LastUsedAt = now.Add(-30 * time.Second)
269+ f.store.EXPECT().FindByHash(ctx, hash).Return(recent, nil)
270+ _, err := f.auth.Authenticate(ctx, secret) // no Touch expected
271+ require.NoError(t, err)
272+
273+ f2 := newTokenFixture(t)
274+ old := tok
275+ old.LastUsedAt = now.Add(-time.Minute)
276+ f2.store.EXPECT().FindByHash(ctx, hash).Return(old, nil)
277+ f2.store.EXPECT().Touch(ctx, "t1", now).Return(nil)
278+ _, err = f2.auth.Authenticate(ctx, secret)
279+ require.NoError(t, err)
280+ })
281+
282+ t.Run("touch failure does not fail authentication", func(t *testing.T) {
283+ f := newTokenFixture(t)
284+ f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil)
285+ f.store.EXPECT().Touch(ctx, "t1", now).Return(errors.New("disk"))
286+ _, err := f.auth.Authenticate(ctx, secret)
287+ require.NoError(t, err)
288+ })
289+
290+ t.Run("expired", func(t *testing.T) {
291+ f := newTokenFixture(t)
292+ exp := tok
293+ exp.ExpiresAt = now
294+ f.store.EXPECT().FindByHash(ctx, hash).Return(exp, nil)
295+ _, err := f.auth.Authenticate(ctx, secret)
296+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
297+ })
298+
299+ t.Run("store error is not an authentication failure", func(t *testing.T) {
300+ f := newTokenFixture(t)
301+ boom := errors.New("boom")
302+ f.store.EXPECT().FindByHash(ctx, hash).Return(domain.Token{}, boom)
303+ _, err := f.auth.Authenticate(ctx, secret)
304+ assert.ErrorIs(t, err, boom)
305+ assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
306+ })
307+}

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