detached 857b2f4

feat(app): limit token list and revoke to the caller's reach, add Auth.Revalidate

List returns and Revoke accepts only tokens within the caller's scopes,
repositories and expiry (the no-escalation rule of Create); root sees all.
Revoking a more powerful token answers ErrNotFound. TokenStore gains Get so
the use cases can look a token up by id; Auth.Revalidate checks that a stored
token still exists and has not expired.
commit
857b2f46b264866b889bbff9293d372d3b16f071
author
brice · · 2026-10-10 08:11 UTC
parent
71acbb2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +283 -8
M internal/adapters/kvstore/tokens.go +12 -0
@@ -69,6 +69,18 @@ func (s *Tokens) Create(ctx context.Context, t domain.Token) error {
6969 return nil
7070 }
7171
72+// Get reads token/<id>.
73+func (s *Tokens) Get(ctx context.Context, id string) (domain.Token, error) {
74+ if !validTokenID(id) {
75+ return domain.Token{}, domain.ErrNotFound
76+ }
77+ item, ok := s.db.Get(ctx, tokenKey(id))
78+ if !ok {
79+ return domain.Token{}, domain.ErrNotFound
80+ }
81+ return decodeToken(item.Value)
82+}
83+
7284 // FindByHash resolves the hash index, then reads the token.
7385 func (s *Tokens) FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error) {
7486 snap := s.db.Snapshot(ctx) // one snapshot: the index and the token agree
@@ -69,6 +69,18 @@ func (s *Tokens) Create(ctx context.Context, t domain.Token) error {
69 return nil69 return nil
70 }70 }
71 71
72+// Get reads token/<id>.
73+func (s *Tokens) Get(ctx context.Context, id string) (domain.Token, error) {
74+ if !validTokenID(id) {
75+ return domain.Token{}, domain.ErrNotFound
76+ }
77+ item, ok := s.db.Get(ctx, tokenKey(id))
78+ if !ok {
79+ return domain.Token{}, domain.ErrNotFound
80+ }
81+ return decodeToken(item.Value)
82+}
83+
72 // FindByHash resolves the hash index, then reads the token.84 // FindByHash resolves the hash index, then reads the token.
73 func (s *Tokens) FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error) {85 func (s *Tokens) FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error) {
74 snap := s.db.Snapshot(ctx) // one snapshot: the index and the token agree86 snap := s.db.Snapshot(ctx) // one snapshot: the index and the token agree
M internal/adapters/kvstore/tokens_test.go +19 -0
@@ -117,6 +117,25 @@ func TestTokensRevokeKeepsIndexConsistent(t *testing.T) {
117117 assert.Len(t, list, 2)
118118 }
119119
120+func TestTokensGet(t *testing.T) {
121+ ctx := context.Background()
122+ ts := openStore(t, t.TempDir()).Tokens()
123+ tok := newToken(t, "t1", "s1")
124+ require.NoError(t, ts.Create(ctx, tok))
125+
126+ got, err := ts.Get(ctx, "t1")
127+ require.NoError(t, err)
128+ assert.Equal(t, tok, got)
129+
130+ for _, id := range []string{"gone", "", "../t1"} {
131+ _, err = ts.Get(ctx, id)
132+ assert.ErrorIs(t, err, domain.ErrNotFound, id)
133+ }
134+ require.NoError(t, ts.Revoke(ctx, "t1"))
135+ _, err = ts.Get(ctx, "t1")
136+ assert.ErrorIs(t, err, domain.ErrNotFound)
137+}
138+
120139 func TestTokensTouch(t *testing.T) {
121140 ctx := context.Background()
122141 s := openStore(t, t.TempDir())
@@ -117,6 +117,25 @@ func TestTokensRevokeKeepsIndexConsistent(t *testing.T) {
117 assert.Len(t, list, 2)117 assert.Len(t, list, 2)
118 }118 }
119 119
120+func TestTokensGet(t *testing.T) {
121+ ctx := context.Background()
122+ ts := openStore(t, t.TempDir()).Tokens()
123+ tok := newToken(t, "t1", "s1")
124+ require.NoError(t, ts.Create(ctx, tok))
125+
126+ got, err := ts.Get(ctx, "t1")
127+ require.NoError(t, err)
128+ assert.Equal(t, tok, got)
129+
130+ for _, id := range []string{"gone", "", "../t1"} {
131+ _, err = ts.Get(ctx, id)
132+ assert.ErrorIs(t, err, domain.ErrNotFound, id)
133+ }
134+ require.NoError(t, ts.Revoke(ctx, "t1"))
135+ _, err = ts.Get(ctx, "t1")
136+ assert.ErrorIs(t, err, domain.ErrNotFound)
137+}
138+
120 func TestTokensTouch(t *testing.T) {139 func TestTokensTouch(t *testing.T) {
121 ctx := context.Background()140 ctx := context.Background()
122 s := openStore(t, t.TempDir())141 s := openStore(t, t.TempDir())
M internal/app/tokens.go +51 -4
@@ -143,7 +143,9 @@ func buildToken(params CreateTokenParams, now time.Time) (domain.Token, error) {
143143 return tok, nil
144144 }
145145
146-// checkNoEscalation enforces that tok is within the rights of its creator.
146+// checkNoEscalation enforces that tok is within the rights of p: every scope
147+// held by p, the repositories within its list and the expiry not beyond its
148+// own. Root has no limits.
147149 func checkNoEscalation(p domain.Principal, tok domain.Token) error {
148150 if p.IsRoot() {
149151 return nil
@@ -171,20 +173,40 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error {
171173 return nil
172174 }
173175
174-// List returns the stored tokens, oldest first. It needs token:admin.
176+// List returns the stored tokens the principal could have created, oldest
177+// first: those whose scopes, repositories and expiry lie within its own (the
178+// no-escalation rule of Create). Root sees every token. It needs token:admin.
175179 func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) {
176180 if err := requireTokenAdmin(p); err != nil {
177181 return nil, err
178182 }
179- return t.store.List(ctx)
183+ all, err := t.store.List(ctx)
184+ if err != nil {
185+ return nil, err
186+ }
187+ if p.IsRoot() {
188+ return all, nil
189+ }
190+ return slices.DeleteFunc(all, func(tok domain.Token) bool { return checkNoEscalation(p, tok) != nil }), nil
180191 }
181192
182193 // Revoke deletes a stored token; it stops working at once. It needs
183-// token:admin and returns domain.ErrNotFound for an unknown id.
194+// token:admin and returns domain.ErrNotFound for an unknown id, and also for
195+// a token more powerful than the principal (the no-escalation rule of
196+// Create), so that it does not show itself. Root may revoke any token.
184197 func (t *Tokens) Revoke(ctx context.Context, p domain.Principal, id string) error {
185198 if err := requireTokenAdmin(p); err != nil {
186199 return err
187200 }
201+ if !p.IsRoot() {
202+ tok, err := t.store.Get(ctx, id)
203+ if err != nil {
204+ return err
205+ }
206+ if checkNoEscalation(p, tok) != nil {
207+ return domain.ErrNotFound
208+ }
209+ }
188210 if err := t.store.Revoke(ctx, id); err != nil {
189211 return err
190212 }
@@ -231,6 +253,31 @@ func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clo
231253 return &Auth{root: rootHash, tokens: tokens, clock: clock}
232254 }
233255
256+// Revalidate checks that the holder of a principal obtained earlier is still
257+// authorized: root always is; a stored token must still exist and not have
258+// expired. It gives domain.ErrUnauthenticated otherwise, and any error of the
259+// store as it is, so that callers fail closed. It is for long operations that
260+// outlive the request that authenticated them, such as a push.
261+func (a *Auth) Revalidate(ctx context.Context, p domain.Principal) error {
262+ if p.IsRoot() {
263+ return nil
264+ }
265+ if p.IsAnonymous() {
266+ return domain.ErrUnauthenticated
267+ }
268+ tok, err := a.tokens.Get(ctx, p.TokenID())
269+ if errors.Is(err, domain.ErrNotFound) {
270+ return domain.ErrUnauthenticated
271+ }
272+ if err != nil {
273+ return fmt.Errorf("find token: %w", err)
274+ }
275+ if tok.Expired(a.clock.Now().UTC()) {
276+ return domain.ErrUnauthenticated
277+ }
278+ return nil
279+}
280+
234281 // Authenticate resolves a token secret to its principal: root when it is the
235282 // admin token (constant-time comparison of the hashes), else the stored token
236283 // with that hash if it has not expired. Any other secret, including the empty
@@ -143,7 +143,9 @@ func buildToken(params CreateTokenParams, now time.Time) (domain.Token, error) {
143 return tok, nil143 return tok, nil
144 }144 }
145 145
146-// checkNoEscalation enforces that tok is within the rights of its creator.146+// checkNoEscalation enforces that tok is within the rights of p: every scope
147+// held by p, the repositories within its list and the expiry not beyond its
148+// own. Root has no limits.
147 func checkNoEscalation(p domain.Principal, tok domain.Token) error {149 func checkNoEscalation(p domain.Principal, tok domain.Token) error {
148 if p.IsRoot() {150 if p.IsRoot() {
149 return nil151 return nil
@@ -171,20 +173,40 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error {
171 return nil173 return nil
172 }174 }
173 175
174-// List returns the stored tokens, oldest first. It needs token:admin.176+// List returns the stored tokens the principal could have created, oldest
177+// first: those whose scopes, repositories and expiry lie within its own (the
178+// no-escalation rule of Create). Root sees every token. It needs token:admin.
175 func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) {179 func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) {
176 if err := requireTokenAdmin(p); err != nil {180 if err := requireTokenAdmin(p); err != nil {
177 return nil, err181 return nil, err
178 }182 }
179- return t.store.List(ctx)183+ all, err := t.store.List(ctx)
184+ if err != nil {
185+ return nil, err
186+ }
187+ if p.IsRoot() {
188+ return all, nil
189+ }
190+ return slices.DeleteFunc(all, func(tok domain.Token) bool { return checkNoEscalation(p, tok) != nil }), nil
180 }191 }
181 192
182 // Revoke deletes a stored token; it stops working at once. It needs193 // Revoke deletes a stored token; it stops working at once. It needs
183-// token:admin and returns domain.ErrNotFound for an unknown id.194+// token:admin and returns domain.ErrNotFound for an unknown id, and also for
195+// a token more powerful than the principal (the no-escalation rule of
196+// Create), so that it does not show itself. Root may revoke any token.
184 func (t *Tokens) Revoke(ctx context.Context, p domain.Principal, id string) error {197 func (t *Tokens) Revoke(ctx context.Context, p domain.Principal, id string) error {
185 if err := requireTokenAdmin(p); err != nil {198 if err := requireTokenAdmin(p); err != nil {
186 return err199 return err
187 }200 }
201+ if !p.IsRoot() {
202+ tok, err := t.store.Get(ctx, id)
203+ if err != nil {
204+ return err
205+ }
206+ if checkNoEscalation(p, tok) != nil {
207+ return domain.ErrNotFound
208+ }
209+ }
188 if err := t.store.Revoke(ctx, id); err != nil {210 if err := t.store.Revoke(ctx, id); err != nil {
189 return err211 return err
190 }212 }
@@ -231,6 +253,31 @@ func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clo
231 return &Auth{root: rootHash, tokens: tokens, clock: clock}253 return &Auth{root: rootHash, tokens: tokens, clock: clock}
232 }254 }
233 255
256+// Revalidate checks that the holder of a principal obtained earlier is still
257+// authorized: root always is; a stored token must still exist and not have
258+// expired. It gives domain.ErrUnauthenticated otherwise, and any error of the
259+// store as it is, so that callers fail closed. It is for long operations that
260+// outlive the request that authenticated them, such as a push.
261+func (a *Auth) Revalidate(ctx context.Context, p domain.Principal) error {
262+ if p.IsRoot() {
263+ return nil
264+ }
265+ if p.IsAnonymous() {
266+ return domain.ErrUnauthenticated
267+ }
268+ tok, err := a.tokens.Get(ctx, p.TokenID())
269+ if errors.Is(err, domain.ErrNotFound) {
270+ return domain.ErrUnauthenticated
271+ }
272+ if err != nil {
273+ return fmt.Errorf("find token: %w", err)
274+ }
275+ if tok.Expired(a.clock.Now().UTC()) {
276+ return domain.ErrUnauthenticated
277+ }
278+ return nil
279+}
280+
234 // Authenticate resolves a token secret to its principal: root when it is the281 // Authenticate resolves a token secret to its principal: root when it is the
235 // admin token (constant-time comparison of the hashes), else the stored token282 // admin token (constant-time comparison of the hashes), else the stored token
236 // with that hash if it has not expired. Any other secret, including the empty283 // with that hash if it has not expired. Any other secret, including the empty
M internal/app/tokens_test.go +133 -4
@@ -196,12 +196,12 @@ func TestTokensCreateNoEscalation(t *testing.T) {
196196
197197 func TestTokensListRevoke(t *testing.T) {
198198 ctx := context.Background()
199- tokenAdmin := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeTokenAdmin)})
199+ tokenAdmin := domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: scopes(domain.ScopeTokenAdmin)})
200200 writer := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)})
201201
202- t.Run("list", func(t *testing.T) {
202+ t.Run("list for the admin token and root", func(t *testing.T) {
203203 f := newTokenFixture(t)
204- want := []domain.Token{{ID: "a"}}
204+ want := []domain.Token{{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}}
205205 f.store.EXPECT().List(ctx).Return(want, nil).Twice()
206206 for _, p := range []domain.Principal{domain.Root(), tokenAdmin} {
207207 got, err := f.tokens.List(ctx, p)
@@ -216,15 +216,144 @@ func TestTokensListRevoke(t *testing.T) {
216216
217217 t.Run("revoke", func(t *testing.T) {
218218 f := newTokenFixture(t)
219- f.store.EXPECT().Revoke(ctx, "a").Return(nil).Once()
219+ f.store.EXPECT().Get(ctx, "a").Return(domain.Token{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}, nil).Once()
220+ f.store.EXPECT().Revoke(ctx, "a").Return(nil).Twice()
220221 f.store.EXPECT().Revoke(ctx, "zz").Return(domain.ErrNotFound).Once()
221222 require.NoError(t, f.tokens.Revoke(ctx, tokenAdmin, "a"))
223+ require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "a"))
222224 assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Root(), "zz"), domain.ErrNotFound)
223225 assert.ErrorIs(t, f.tokens.Revoke(ctx, writer, "a"), domain.ErrForbidden)
224226 assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Anonymous(), "a"), domain.ErrUnauthenticated)
225227 })
226228 }
227229
230+// A token admin sees and revokes only the tokens it could have created: the
231+// no-escalation rule of Create, applied to scopes, repositories and expiry.
232+func TestTokensAdminReach(t *testing.T) {
233+ ctx := context.Background()
234+ exp := now.Add(48 * time.Hour)
235+ admin := func(repos []domain.RepoName, expires time.Time, extra ...domain.Scope) domain.Principal {
236+ return domain.TokenPrincipal(domain.Token{
237+ ID: "caller",
238+ Scopes: append(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), extra...),
239+ Repos: repos,
240+ ExpiresAt: expires,
241+ })
242+ }
243+ tok := func(id string, sc []domain.Scope, repos []domain.RepoName, expires time.Time) domain.Token {
244+ return domain.Token{ID: id, Scopes: sc, Repos: repos, ExpiresAt: expires}
245+ }
246+ rw := scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite)
247+ ab, a := names(t, "a", "b"), names(t, "a")
248+
249+ tests := []struct {
250+ name string
251+ caller domain.Principal
252+ target domain.Token
253+ within bool
254+ }{
255+ {"fewer scopes", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead), nil, time.Time{}), true},
256+ {"the same rights", admin(ab, exp), tok("t", rw, ab, exp), true},
257+ {"a scope the caller lacks", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), nil, time.Time{}), false},
258+ {"root-only reach: a token:admin caller cannot reach a mr:merge token", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeMRMerge), nil, time.Time{}), false},
259+ {"unlimited token, limited caller", admin(a, time.Time{}), tok("t", rw, nil, time.Time{}), false},
260+ {"repo outside the caller's list", admin(a, time.Time{}), tok("t", rw, ab, time.Time{}), false},
261+ {"repo inside the caller's list", admin(ab, time.Time{}), tok("t", rw, a, time.Time{}), true},
262+ {"limited token, unlimited caller", admin(nil, time.Time{}), tok("t", rw, ab, time.Time{}), true},
263+ {"never expires, caller expires", admin(nil, exp), tok("t", rw, nil, time.Time{}), false},
264+ {"expires later than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(time.Hour)), false},
265+ {"expires sooner than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(-time.Hour)), true},
266+ {"expiring token, caller never expires", admin(nil, time.Time{}), tok("t", rw, nil, exp), true},
267+ }
268+ for _, tt := range tests {
269+ t.Run("list: "+tt.name, func(t *testing.T) {
270+ f := newTokenFixture(t)
271+ other := tok("other", scopes(domain.ScopeRepoRead), nil, time.Time{})
272+ f.store.EXPECT().List(ctx).Return([]domain.Token{tt.target, other}, nil)
273+ got, err := f.tokens.List(ctx, tt.caller)
274+ require.NoError(t, err)
275+ if !tt.within {
276+ // Only the token that is within reach of every caller shows.
277+ assert.NotContains(t, got, tt.target)
278+ return
279+ }
280+ assert.Contains(t, got, tt.target)
281+ })
282+ t.Run("revoke: "+tt.name, func(t *testing.T) {
283+ f := newTokenFixture(t)
284+ f.store.EXPECT().Get(ctx, "t").Return(tt.target, nil)
285+ if tt.within {
286+ f.store.EXPECT().Revoke(ctx, "t").Return(nil)
287+ require.NoError(t, f.tokens.Revoke(ctx, tt.caller, "t"))
288+ return
289+ }
290+ // Not leaked: the same answer as for a token that does not exist, and nothing is deleted.
291+ assert.ErrorIs(t, f.tokens.Revoke(ctx, tt.caller, "t"), domain.ErrNotFound)
292+ })
293+ }
294+
295+ t.Run("root lists and revokes everything", func(t *testing.T) {
296+ f := newTokenFixture(t)
297+ all := []domain.Token{tok("x", domain.AllScopes(), nil, time.Time{}), tok("y", scopes(domain.ScopeMRMerge), a, exp)}
298+ f.store.EXPECT().List(ctx).Return(all, nil)
299+ got, err := f.tokens.List(ctx, domain.Root())
300+ require.NoError(t, err)
301+ assert.Equal(t, all, got)
302+ f.store.EXPECT().Revoke(ctx, "x").Return(nil) // no Get: root has no limits
303+ require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "x"))
304+ })
305+
306+ t.Run("an unknown id is not found for everyone", func(t *testing.T) {
307+ f := newTokenFixture(t)
308+ f.store.EXPECT().Get(ctx, "nope").Return(domain.Token{}, domain.ErrNotFound)
309+ assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "nope"), domain.ErrNotFound)
310+ })
311+
312+ t.Run("a store failure on the lookup is returned", func(t *testing.T) {
313+ f := newTokenFixture(t)
314+ boom := errors.New("boom")
315+ f.store.EXPECT().Get(ctx, "t").Return(domain.Token{}, boom)
316+ assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "t"), boom)
317+ })
318+}
319+
320+func TestAuthRevalidate(t *testing.T) {
321+ ctx := context.Background()
322+ p := domain.TokenPrincipal(domain.Token{ID: "t1", Scopes: scopes(domain.ScopeRepoWrite)})
323+
324+ t.Run("root is always valid", func(t *testing.T) {
325+ f := newTokenFixture(t)
326+ require.NoError(t, f.auth.Revalidate(ctx, domain.Root()))
327+ })
328+ t.Run("anonymous is not", func(t *testing.T) {
329+ f := newTokenFixture(t)
330+ assert.ErrorIs(t, f.auth.Revalidate(ctx, domain.Anonymous()), domain.ErrUnauthenticated)
331+ })
332+ t.Run("a stored token that exists", func(t *testing.T) {
333+ f := newTokenFixture(t)
334+ f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now.Add(time.Hour)}, nil)
335+ require.NoError(t, f.auth.Revalidate(ctx, p))
336+ })
337+ t.Run("revoked", func(t *testing.T) {
338+ f := newTokenFixture(t)
339+ f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, domain.ErrNotFound)
340+ assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated)
341+ })
342+ t.Run("expired", func(t *testing.T) {
343+ f := newTokenFixture(t)
344+ f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now}, nil)
345+ assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated)
346+ })
347+ t.Run("a store failure is an error, not a pass", func(t *testing.T) {
348+ f := newTokenFixture(t)
349+ boom := errors.New("boom")
350+ f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, boom)
351+ err := f.auth.Revalidate(ctx, p)
352+ require.ErrorIs(t, err, boom)
353+ assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
354+ })
355+}
356+
228357 func TestAuthenticate(t *testing.T) {
229358 ctx := context.Background()
230359 secret := "ermite_stored-secret"
@@ -196,12 +196,12 @@ func TestTokensCreateNoEscalation(t *testing.T) {
196 196
197 func TestTokensListRevoke(t *testing.T) {197 func TestTokensListRevoke(t *testing.T) {
198 ctx := context.Background()198 ctx := context.Background()
199- tokenAdmin := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeTokenAdmin)})199+ tokenAdmin := domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: scopes(domain.ScopeTokenAdmin)})
200 writer := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)})200 writer := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)})
201 201
202- t.Run("list", func(t *testing.T) {202+ t.Run("list for the admin token and root", func(t *testing.T) {
203 f := newTokenFixture(t)203 f := newTokenFixture(t)
204- want := []domain.Token{{ID: "a"}}204+ want := []domain.Token{{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}}
205 f.store.EXPECT().List(ctx).Return(want, nil).Twice()205 f.store.EXPECT().List(ctx).Return(want, nil).Twice()
206 for _, p := range []domain.Principal{domain.Root(), tokenAdmin} {206 for _, p := range []domain.Principal{domain.Root(), tokenAdmin} {
207 got, err := f.tokens.List(ctx, p)207 got, err := f.tokens.List(ctx, p)
@@ -216,15 +216,144 @@ func TestTokensListRevoke(t *testing.T) {
216 216
217 t.Run("revoke", func(t *testing.T) {217 t.Run("revoke", func(t *testing.T) {
218 f := newTokenFixture(t)218 f := newTokenFixture(t)
219- f.store.EXPECT().Revoke(ctx, "a").Return(nil).Once()219+ f.store.EXPECT().Get(ctx, "a").Return(domain.Token{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}, nil).Once()
220+ f.store.EXPECT().Revoke(ctx, "a").Return(nil).Twice()
220 f.store.EXPECT().Revoke(ctx, "zz").Return(domain.ErrNotFound).Once()221 f.store.EXPECT().Revoke(ctx, "zz").Return(domain.ErrNotFound).Once()
221 require.NoError(t, f.tokens.Revoke(ctx, tokenAdmin, "a"))222 require.NoError(t, f.tokens.Revoke(ctx, tokenAdmin, "a"))
223+ require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "a"))
222 assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Root(), "zz"), domain.ErrNotFound)224 assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Root(), "zz"), domain.ErrNotFound)
223 assert.ErrorIs(t, f.tokens.Revoke(ctx, writer, "a"), domain.ErrForbidden)225 assert.ErrorIs(t, f.tokens.Revoke(ctx, writer, "a"), domain.ErrForbidden)
224 assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Anonymous(), "a"), domain.ErrUnauthenticated)226 assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Anonymous(), "a"), domain.ErrUnauthenticated)
225 })227 })
226 }228 }
227 229
230+// A token admin sees and revokes only the tokens it could have created: the
231+// no-escalation rule of Create, applied to scopes, repositories and expiry.
232+func TestTokensAdminReach(t *testing.T) {
233+ ctx := context.Background()
234+ exp := now.Add(48 * time.Hour)
235+ admin := func(repos []domain.RepoName, expires time.Time, extra ...domain.Scope) domain.Principal {
236+ return domain.TokenPrincipal(domain.Token{
237+ ID: "caller",
238+ Scopes: append(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), extra...),
239+ Repos: repos,
240+ ExpiresAt: expires,
241+ })
242+ }
243+ tok := func(id string, sc []domain.Scope, repos []domain.RepoName, expires time.Time) domain.Token {
244+ return domain.Token{ID: id, Scopes: sc, Repos: repos, ExpiresAt: expires}
245+ }
246+ rw := scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite)
247+ ab, a := names(t, "a", "b"), names(t, "a")
248+
249+ tests := []struct {
250+ name string
251+ caller domain.Principal
252+ target domain.Token
253+ within bool
254+ }{
255+ {"fewer scopes", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead), nil, time.Time{}), true},
256+ {"the same rights", admin(ab, exp), tok("t", rw, ab, exp), true},
257+ {"a scope the caller lacks", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), nil, time.Time{}), false},
258+ {"root-only reach: a token:admin caller cannot reach a mr:merge token", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeMRMerge), nil, time.Time{}), false},
259+ {"unlimited token, limited caller", admin(a, time.Time{}), tok("t", rw, nil, time.Time{}), false},
260+ {"repo outside the caller's list", admin(a, time.Time{}), tok("t", rw, ab, time.Time{}), false},
261+ {"repo inside the caller's list", admin(ab, time.Time{}), tok("t", rw, a, time.Time{}), true},
262+ {"limited token, unlimited caller", admin(nil, time.Time{}), tok("t", rw, ab, time.Time{}), true},
263+ {"never expires, caller expires", admin(nil, exp), tok("t", rw, nil, time.Time{}), false},
264+ {"expires later than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(time.Hour)), false},
265+ {"expires sooner than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(-time.Hour)), true},
266+ {"expiring token, caller never expires", admin(nil, time.Time{}), tok("t", rw, nil, exp), true},
267+ }
268+ for _, tt := range tests {
269+ t.Run("list: "+tt.name, func(t *testing.T) {
270+ f := newTokenFixture(t)
271+ other := tok("other", scopes(domain.ScopeRepoRead), nil, time.Time{})
272+ f.store.EXPECT().List(ctx).Return([]domain.Token{tt.target, other}, nil)
273+ got, err := f.tokens.List(ctx, tt.caller)
274+ require.NoError(t, err)
275+ if !tt.within {
276+ // Only the token that is within reach of every caller shows.
277+ assert.NotContains(t, got, tt.target)
278+ return
279+ }
280+ assert.Contains(t, got, tt.target)
281+ })
282+ t.Run("revoke: "+tt.name, func(t *testing.T) {
283+ f := newTokenFixture(t)
284+ f.store.EXPECT().Get(ctx, "t").Return(tt.target, nil)
285+ if tt.within {
286+ f.store.EXPECT().Revoke(ctx, "t").Return(nil)
287+ require.NoError(t, f.tokens.Revoke(ctx, tt.caller, "t"))
288+ return
289+ }
290+ // Not leaked: the same answer as for a token that does not exist, and nothing is deleted.
291+ assert.ErrorIs(t, f.tokens.Revoke(ctx, tt.caller, "t"), domain.ErrNotFound)
292+ })
293+ }
294+
295+ t.Run("root lists and revokes everything", func(t *testing.T) {
296+ f := newTokenFixture(t)
297+ all := []domain.Token{tok("x", domain.AllScopes(), nil, time.Time{}), tok("y", scopes(domain.ScopeMRMerge), a, exp)}
298+ f.store.EXPECT().List(ctx).Return(all, nil)
299+ got, err := f.tokens.List(ctx, domain.Root())
300+ require.NoError(t, err)
301+ assert.Equal(t, all, got)
302+ f.store.EXPECT().Revoke(ctx, "x").Return(nil) // no Get: root has no limits
303+ require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "x"))
304+ })
305+
306+ t.Run("an unknown id is not found for everyone", func(t *testing.T) {
307+ f := newTokenFixture(t)
308+ f.store.EXPECT().Get(ctx, "nope").Return(domain.Token{}, domain.ErrNotFound)
309+ assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "nope"), domain.ErrNotFound)
310+ })
311+
312+ t.Run("a store failure on the lookup is returned", func(t *testing.T) {
313+ f := newTokenFixture(t)
314+ boom := errors.New("boom")
315+ f.store.EXPECT().Get(ctx, "t").Return(domain.Token{}, boom)
316+ assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "t"), boom)
317+ })
318+}
319+
320+func TestAuthRevalidate(t *testing.T) {
321+ ctx := context.Background()
322+ p := domain.TokenPrincipal(domain.Token{ID: "t1", Scopes: scopes(domain.ScopeRepoWrite)})
323+
324+ t.Run("root is always valid", func(t *testing.T) {
325+ f := newTokenFixture(t)
326+ require.NoError(t, f.auth.Revalidate(ctx, domain.Root()))
327+ })
328+ t.Run("anonymous is not", func(t *testing.T) {
329+ f := newTokenFixture(t)
330+ assert.ErrorIs(t, f.auth.Revalidate(ctx, domain.Anonymous()), domain.ErrUnauthenticated)
331+ })
332+ t.Run("a stored token that exists", func(t *testing.T) {
333+ f := newTokenFixture(t)
334+ f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now.Add(time.Hour)}, nil)
335+ require.NoError(t, f.auth.Revalidate(ctx, p))
336+ })
337+ t.Run("revoked", func(t *testing.T) {
338+ f := newTokenFixture(t)
339+ f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, domain.ErrNotFound)
340+ assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated)
341+ })
342+ t.Run("expired", func(t *testing.T) {
343+ f := newTokenFixture(t)
344+ f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now}, nil)
345+ assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated)
346+ })
347+ t.Run("a store failure is an error, not a pass", func(t *testing.T) {
348+ f := newTokenFixture(t)
349+ boom := errors.New("boom")
350+ f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, boom)
351+ err := f.auth.Revalidate(ctx, p)
352+ require.ErrorIs(t, err, boom)
353+ assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
354+ })
355+}
356+
228 func TestAuthenticate(t *testing.T) {357 func TestAuthenticate(t *testing.T) {
229 ctx := context.Background()358 ctx := context.Background()
230 secret := "ermite_stored-secret"359 secret := "ermite_stored-secret"
M internal/ports/mocks/TokenStore.go +66 -0
@@ -171,6 +171,72 @@ func (_c *TokenStore_FindByHash_Call) RunAndReturn(run func(ctx context.Context,
171171 return _c
172172 }
173173
174+// Get provides a mock function for the type TokenStore
175+func (_mock *TokenStore) Get(ctx context.Context, id string) (domain.Token, error) {
176+ ret := _mock.Called(ctx, id)
177+
178+ if len(ret) == 0 {
179+ panic("no return value specified for Get")
180+ }
181+
182+ var r0 domain.Token
183+ var r1 error
184+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.Token, error)); ok {
185+ return returnFunc(ctx, id)
186+ }
187+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.Token); ok {
188+ r0 = returnFunc(ctx, id)
189+ } else {
190+ r0 = ret.Get(0).(domain.Token)
191+ }
192+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
193+ r1 = returnFunc(ctx, id)
194+ } else {
195+ r1 = ret.Error(1)
196+ }
197+ return r0, r1
198+}
199+
200+// TokenStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
201+type TokenStore_Get_Call struct {
202+ *mock.Call
203+}
204+
205+// Get is a helper method to define mock.On call
206+// - ctx context.Context
207+// - id string
208+func (_e *TokenStore_Expecter) Get(ctx any, id any) *TokenStore_Get_Call {
209+ return &TokenStore_Get_Call{Call: _e.mock.On("Get", ctx, id)}
210+}
211+
212+func (_c *TokenStore_Get_Call) Run(run func(ctx context.Context, id string)) *TokenStore_Get_Call {
213+ _c.Call.Run(func(args mock.Arguments) {
214+ var arg0 context.Context
215+ if args[0] != nil {
216+ arg0 = args[0].(context.Context)
217+ }
218+ var arg1 string
219+ if args[1] != nil {
220+ arg1 = args[1].(string)
221+ }
222+ run(
223+ arg0,
224+ arg1,
225+ )
226+ })
227+ return _c
228+}
229+
230+func (_c *TokenStore_Get_Call) Return(token domain.Token, err error) *TokenStore_Get_Call {
231+ _c.Call.Return(token, err)
232+ return _c
233+}
234+
235+func (_c *TokenStore_Get_Call) RunAndReturn(run func(ctx context.Context, id string) (domain.Token, error)) *TokenStore_Get_Call {
236+ _c.Call.Return(run)
237+ return _c
238+}
239+
174240 // List provides a mock function for the type TokenStore
175241 func (_mock *TokenStore) List(ctx context.Context) ([]domain.Token, error) {
176242 ret := _mock.Called(ctx)
@@ -171,6 +171,72 @@ func (_c *TokenStore_FindByHash_Call) RunAndReturn(run func(ctx context.Context,
171 return _c171 return _c
172 }172 }
173 173
174+// Get provides a mock function for the type TokenStore
175+func (_mock *TokenStore) Get(ctx context.Context, id string) (domain.Token, error) {
176+ ret := _mock.Called(ctx, id)
177+
178+ if len(ret) == 0 {
179+ panic("no return value specified for Get")
180+ }
181+
182+ var r0 domain.Token
183+ var r1 error
184+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.Token, error)); ok {
185+ return returnFunc(ctx, id)
186+ }
187+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.Token); ok {
188+ r0 = returnFunc(ctx, id)
189+ } else {
190+ r0 = ret.Get(0).(domain.Token)
191+ }
192+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
193+ r1 = returnFunc(ctx, id)
194+ } else {
195+ r1 = ret.Error(1)
196+ }
197+ return r0, r1
198+}
199+
200+// TokenStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
201+type TokenStore_Get_Call struct {
202+ *mock.Call
203+}
204+
205+// Get is a helper method to define mock.On call
206+// - ctx context.Context
207+// - id string
208+func (_e *TokenStore_Expecter) Get(ctx any, id any) *TokenStore_Get_Call {
209+ return &TokenStore_Get_Call{Call: _e.mock.On("Get", ctx, id)}
210+}
211+
212+func (_c *TokenStore_Get_Call) Run(run func(ctx context.Context, id string)) *TokenStore_Get_Call {
213+ _c.Call.Run(func(args mock.Arguments) {
214+ var arg0 context.Context
215+ if args[0] != nil {
216+ arg0 = args[0].(context.Context)
217+ }
218+ var arg1 string
219+ if args[1] != nil {
220+ arg1 = args[1].(string)
221+ }
222+ run(
223+ arg0,
224+ arg1,
225+ )
226+ })
227+ return _c
228+}
229+
230+func (_c *TokenStore_Get_Call) Return(token domain.Token, err error) *TokenStore_Get_Call {
231+ _c.Call.Return(token, err)
232+ return _c
233+}
234+
235+func (_c *TokenStore_Get_Call) RunAndReturn(run func(ctx context.Context, id string) (domain.Token, error)) *TokenStore_Get_Call {
236+ _c.Call.Return(run)
237+ return _c
238+}
239+
174 // List provides a mock function for the type TokenStore240 // List provides a mock function for the type TokenStore
175 func (_mock *TokenStore) List(ctx context.Context) ([]domain.Token, error) {241 func (_mock *TokenStore) List(ctx context.Context) ([]domain.Token, error) {
176 ret := _mock.Called(ctx)242 ret := _mock.Called(ctx)
M internal/ports/ports.go +2 -0
@@ -96,6 +96,8 @@ type TokenStore interface {
9696 // Create stores the token and its hash index in one write. It returns
9797 // domain.ErrAlreadyExists if the id or the hash is taken.
9898 Create(ctx context.Context, token domain.Token) error
99+ // Get returns the token with that id, or domain.ErrNotFound.
100+ Get(ctx context.Context, id string) (domain.Token, error)
99101 // FindByHash returns the token whose secret has that hash, or
100102 // domain.ErrNotFound.
101103 FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error)
@@ -96,6 +96,8 @@ type TokenStore interface {
96 // Create stores the token and its hash index in one write. It returns96 // Create stores the token and its hash index in one write. It returns
97 // domain.ErrAlreadyExists if the id or the hash is taken.97 // domain.ErrAlreadyExists if the id or the hash is taken.
98 Create(ctx context.Context, token domain.Token) error98 Create(ctx context.Context, token domain.Token) error
99+ // Get returns the token with that id, or domain.ErrNotFound.
100+ Get(ctx context.Context, id string) (domain.Token, error)
99 // FindByHash returns the token whose secret has that hash, or101 // FindByHash returns the token whose secret has that hash, or
100 // domain.ErrNotFound.102 // domain.ErrNotFound.
101 FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error)103 FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error)

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