detached 607c375

feat(tokens): add token and scope domain, storage message and kv token store

commit
607c375df2368c8038ec6b099f8b34e2349a43ba
author
brice · · 2026-10-10 06:40 UTC
committer
brice · · 2026-10-10 07:02 UTC
parent
2b1cdbf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +1202 -14
M .mockery.yml +1 -0
@@ -11,3 +11,4 @@ packages:
1111 RepoMetaStore:
1212 GitRepos:
1313 Clock:
14+ TokenStore:
@@ -11,3 +11,4 @@ packages:
11 RepoMetaStore:11 RepoMetaStore:
12 GitRepos:12 GitRepos:
13 Clock:13 Clock:
14+ TokenStore:
M gen/ermite/storage/repo.pb.go +134 -11
@@ -200,6 +200,113 @@ func (x *SchemaVersion) GetVersion() uint32 {
200200 return 0
201201 }
202202
203+// Token is stored under the key "token/<id>"; the key "tokenhash/<hex>" maps
204+// the SHA-256 of the secret back to the id.
205+type Token struct {
206+ state protoimpl.MessageState `protogen:"open.v1"`
207+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
208+ Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"`
209+ // SHA-256 of the secret, 32 bytes.
210+ Hash []byte `protobuf:"bytes,3,opt,name=hash,proto3" json:"hash,omitempty"`
211+ // Scopes in their text form, such as "repo:read". Never empty.
212+ Scopes []string `protobuf:"bytes,4,rep,name=scopes,proto3" json:"scopes,omitempty"`
213+ // Repository names the token is limited to; empty means every repository.
214+ Repos []string `protobuf:"bytes,5,rep,name=repos,proto3" json:"repos,omitempty"`
215+ // Unset for a token that never expires.
216+ ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
217+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
218+ // Unset until the token is first used.
219+ LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"`
220+ unknownFields protoimpl.UnknownFields
221+ sizeCache protoimpl.SizeCache
222+}
223+
224+func (x *Token) Reset() {
225+ *x = Token{}
226+ mi := &file_ermite_storage_repo_proto_msgTypes[2]
227+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
228+ ms.StoreMessageInfo(mi)
229+}
230+
231+func (x *Token) String() string {
232+ return protoimpl.X.MessageStringOf(x)
233+}
234+
235+func (*Token) ProtoMessage() {}
236+
237+func (x *Token) ProtoReflect() protoreflect.Message {
238+ mi := &file_ermite_storage_repo_proto_msgTypes[2]
239+ if x != nil {
240+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
241+ if ms.LoadMessageInfo() == nil {
242+ ms.StoreMessageInfo(mi)
243+ }
244+ return ms
245+ }
246+ return mi.MessageOf(x)
247+}
248+
249+// Deprecated: Use Token.ProtoReflect.Descriptor instead.
250+func (*Token) Descriptor() ([]byte, []int) {
251+ return file_ermite_storage_repo_proto_rawDescGZIP(), []int{2}
252+}
253+
254+func (x *Token) GetId() string {
255+ if x != nil {
256+ return x.Id
257+ }
258+ return ""
259+}
260+
261+func (x *Token) GetLabel() string {
262+ if x != nil {
263+ return x.Label
264+ }
265+ return ""
266+}
267+
268+func (x *Token) GetHash() []byte {
269+ if x != nil {
270+ return x.Hash
271+ }
272+ return nil
273+}
274+
275+func (x *Token) GetScopes() []string {
276+ if x != nil {
277+ return x.Scopes
278+ }
279+ return nil
280+}
281+
282+func (x *Token) GetRepos() []string {
283+ if x != nil {
284+ return x.Repos
285+ }
286+ return nil
287+}
288+
289+func (x *Token) GetExpiresAt() *timestamppb.Timestamp {
290+ if x != nil {
291+ return x.ExpiresAt
292+ }
293+ return nil
294+}
295+
296+func (x *Token) GetCreatedAt() *timestamppb.Timestamp {
297+ if x != nil {
298+ return x.CreatedAt
299+ }
300+ return nil
301+}
302+
303+func (x *Token) GetLastUsedAt() *timestamppb.Timestamp {
304+ if x != nil {
305+ return x.LastUsedAt
306+ }
307+ return nil
308+}
309+
203310 var File_ermite_storage_repo_proto protoreflect.FileDescriptor
204311
205312 const file_ermite_storage_repo_proto_rawDesc = "" +
@@ -215,7 +322,19 @@ const file_ermite_storage_repo_proto_rawDesc = "" +
215322 "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x127\n" +
216323 "\tpushed_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\bpushedAt\")\n" +
217324 "\rSchemaVersion\x12\x18\n" +
218- "\aversion\x18\x01 \x01(\rR\aversion*W\n" +
325+ "\aversion\x18\x01 \x01(\rR\aversion\"\xa3\x02\n" +
326+ "\x05Token\x12\x0e\n" +
327+ "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
328+ "\x05label\x18\x02 \x01(\tR\x05label\x12\x12\n" +
329+ "\x04hash\x18\x03 \x01(\fR\x04hash\x12\x16\n" +
330+ "\x06scopes\x18\x04 \x03(\tR\x06scopes\x12\x14\n" +
331+ "\x05repos\x18\x05 \x03(\tR\x05repos\x129\n" +
332+ "\n" +
333+ "expires_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x129\n" +
334+ "\n" +
335+ "created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" +
336+ "\flast_used_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" +
337+ "lastUsedAt*W\n" +
219338 "\n" +
220339 "Visibility\x12\x1a\n" +
221340 "\x16VISIBILITY_UNSPECIFIED\x10\x00\x12\x16\n" +
@@ -235,22 +354,26 @@ func file_ermite_storage_repo_proto_rawDescGZIP() []byte {
235354 }
236355
237356 var file_ermite_storage_repo_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
238-var file_ermite_storage_repo_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
357+var file_ermite_storage_repo_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
239358 var file_ermite_storage_repo_proto_goTypes = []any{
240359 (Visibility)(0), // 0: ermite.storage.Visibility
241360 (*Repo)(nil), // 1: ermite.storage.Repo
242361 (*SchemaVersion)(nil), // 2: ermite.storage.SchemaVersion
243- (*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp
362+ (*Token)(nil), // 3: ermite.storage.Token
363+ (*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp
244364 }
245365 var file_ermite_storage_repo_proto_depIdxs = []int32{
246366 0, // 0: ermite.storage.Repo.visibility:type_name -> ermite.storage.Visibility
247- 3, // 1: ermite.storage.Repo.created_at:type_name -> google.protobuf.Timestamp
248- 3, // 2: ermite.storage.Repo.pushed_at:type_name -> google.protobuf.Timestamp
249- 3, // [3:3] is the sub-list for method output_type
250- 3, // [3:3] is the sub-list for method input_type
251- 3, // [3:3] is the sub-list for extension type_name
252- 3, // [3:3] is the sub-list for extension extendee
253- 0, // [0:3] is the sub-list for field type_name
367+ 4, // 1: ermite.storage.Repo.created_at:type_name -> google.protobuf.Timestamp
368+ 4, // 2: ermite.storage.Repo.pushed_at:type_name -> google.protobuf.Timestamp
369+ 4, // 3: ermite.storage.Token.expires_at:type_name -> google.protobuf.Timestamp
370+ 4, // 4: ermite.storage.Token.created_at:type_name -> google.protobuf.Timestamp
371+ 4, // 5: ermite.storage.Token.last_used_at:type_name -> google.protobuf.Timestamp
372+ 6, // [6:6] is the sub-list for method output_type
373+ 6, // [6:6] is the sub-list for method input_type
374+ 6, // [6:6] is the sub-list for extension type_name
375+ 6, // [6:6] is the sub-list for extension extendee
376+ 0, // [0:6] is the sub-list for field type_name
254377 }
255378
256379 func init() { file_ermite_storage_repo_proto_init() }
@@ -264,7 +387,7 @@ func file_ermite_storage_repo_proto_init() {
264387 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
265388 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_repo_proto_rawDesc), len(file_ermite_storage_repo_proto_rawDesc)),
266389 NumEnums: 1,
267- NumMessages: 2,
390+ NumMessages: 3,
268391 NumExtensions: 0,
269392 NumServices: 0,
270393 },
@@ -200,6 +200,113 @@ func (x *SchemaVersion) GetVersion() uint32 {
200 return 0200 return 0
201 }201 }
202 202
203+// Token is stored under the key "token/<id>"; the key "tokenhash/<hex>" maps
204+// the SHA-256 of the secret back to the id.
205+type Token struct {
206+ state protoimpl.MessageState `protogen:"open.v1"`
207+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
208+ Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"`
209+ // SHA-256 of the secret, 32 bytes.
210+ Hash []byte `protobuf:"bytes,3,opt,name=hash,proto3" json:"hash,omitempty"`
211+ // Scopes in their text form, such as "repo:read". Never empty.
212+ Scopes []string `protobuf:"bytes,4,rep,name=scopes,proto3" json:"scopes,omitempty"`
213+ // Repository names the token is limited to; empty means every repository.
214+ Repos []string `protobuf:"bytes,5,rep,name=repos,proto3" json:"repos,omitempty"`
215+ // Unset for a token that never expires.
216+ ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
217+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
218+ // Unset until the token is first used.
219+ LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"`
220+ unknownFields protoimpl.UnknownFields
221+ sizeCache protoimpl.SizeCache
222+}
223+
224+func (x *Token) Reset() {
225+ *x = Token{}
226+ mi := &file_ermite_storage_repo_proto_msgTypes[2]
227+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
228+ ms.StoreMessageInfo(mi)
229+}
230+
231+func (x *Token) String() string {
232+ return protoimpl.X.MessageStringOf(x)
233+}
234+
235+func (*Token) ProtoMessage() {}
236+
237+func (x *Token) ProtoReflect() protoreflect.Message {
238+ mi := &file_ermite_storage_repo_proto_msgTypes[2]
239+ if x != nil {
240+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
241+ if ms.LoadMessageInfo() == nil {
242+ ms.StoreMessageInfo(mi)
243+ }
244+ return ms
245+ }
246+ return mi.MessageOf(x)
247+}
248+
249+// Deprecated: Use Token.ProtoReflect.Descriptor instead.
250+func (*Token) Descriptor() ([]byte, []int) {
251+ return file_ermite_storage_repo_proto_rawDescGZIP(), []int{2}
252+}
253+
254+func (x *Token) GetId() string {
255+ if x != nil {
256+ return x.Id
257+ }
258+ return ""
259+}
260+
261+func (x *Token) GetLabel() string {
262+ if x != nil {
263+ return x.Label
264+ }
265+ return ""
266+}
267+
268+func (x *Token) GetHash() []byte {
269+ if x != nil {
270+ return x.Hash
271+ }
272+ return nil
273+}
274+
275+func (x *Token) GetScopes() []string {
276+ if x != nil {
277+ return x.Scopes
278+ }
279+ return nil
280+}
281+
282+func (x *Token) GetRepos() []string {
283+ if x != nil {
284+ return x.Repos
285+ }
286+ return nil
287+}
288+
289+func (x *Token) GetExpiresAt() *timestamppb.Timestamp {
290+ if x != nil {
291+ return x.ExpiresAt
292+ }
293+ return nil
294+}
295+
296+func (x *Token) GetCreatedAt() *timestamppb.Timestamp {
297+ if x != nil {
298+ return x.CreatedAt
299+ }
300+ return nil
301+}
302+
303+func (x *Token) GetLastUsedAt() *timestamppb.Timestamp {
304+ if x != nil {
305+ return x.LastUsedAt
306+ }
307+ return nil
308+}
309+
203 var File_ermite_storage_repo_proto protoreflect.FileDescriptor310 var File_ermite_storage_repo_proto protoreflect.FileDescriptor
204 311
205 const file_ermite_storage_repo_proto_rawDesc = "" +312 const file_ermite_storage_repo_proto_rawDesc = "" +
@@ -215,7 +322,19 @@ const file_ermite_storage_repo_proto_rawDesc = "" +
215 "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x127\n" +322 "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x127\n" +
216 "\tpushed_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\bpushedAt\")\n" +323 "\tpushed_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\bpushedAt\")\n" +
217 "\rSchemaVersion\x12\x18\n" +324 "\rSchemaVersion\x12\x18\n" +
218- "\aversion\x18\x01 \x01(\rR\aversion*W\n" +325+ "\aversion\x18\x01 \x01(\rR\aversion\"\xa3\x02\n" +
326+ "\x05Token\x12\x0e\n" +
327+ "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
328+ "\x05label\x18\x02 \x01(\tR\x05label\x12\x12\n" +
329+ "\x04hash\x18\x03 \x01(\fR\x04hash\x12\x16\n" +
330+ "\x06scopes\x18\x04 \x03(\tR\x06scopes\x12\x14\n" +
331+ "\x05repos\x18\x05 \x03(\tR\x05repos\x129\n" +
332+ "\n" +
333+ "expires_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x129\n" +
334+ "\n" +
335+ "created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" +
336+ "\flast_used_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" +
337+ "lastUsedAt*W\n" +
219 "\n" +338 "\n" +
220 "Visibility\x12\x1a\n" +339 "Visibility\x12\x1a\n" +
221 "\x16VISIBILITY_UNSPECIFIED\x10\x00\x12\x16\n" +340 "\x16VISIBILITY_UNSPECIFIED\x10\x00\x12\x16\n" +
@@ -235,22 +354,26 @@ func file_ermite_storage_repo_proto_rawDescGZIP() []byte {
235 }354 }
236 355
237 var file_ermite_storage_repo_proto_enumTypes = make([]protoimpl.EnumInfo, 1)356 var file_ermite_storage_repo_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
238-var file_ermite_storage_repo_proto_msgTypes = make([]protoimpl.MessageInfo, 2)357+var file_ermite_storage_repo_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
239 var file_ermite_storage_repo_proto_goTypes = []any{358 var file_ermite_storage_repo_proto_goTypes = []any{
240 (Visibility)(0), // 0: ermite.storage.Visibility359 (Visibility)(0), // 0: ermite.storage.Visibility
241 (*Repo)(nil), // 1: ermite.storage.Repo360 (*Repo)(nil), // 1: ermite.storage.Repo
242 (*SchemaVersion)(nil), // 2: ermite.storage.SchemaVersion361 (*SchemaVersion)(nil), // 2: ermite.storage.SchemaVersion
243- (*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp362+ (*Token)(nil), // 3: ermite.storage.Token
363+ (*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp
244 }364 }
245 var file_ermite_storage_repo_proto_depIdxs = []int32{365 var file_ermite_storage_repo_proto_depIdxs = []int32{
246 0, // 0: ermite.storage.Repo.visibility:type_name -> ermite.storage.Visibility366 0, // 0: ermite.storage.Repo.visibility:type_name -> ermite.storage.Visibility
247- 3, // 1: ermite.storage.Repo.created_at:type_name -> google.protobuf.Timestamp367+ 4, // 1: ermite.storage.Repo.created_at:type_name -> google.protobuf.Timestamp
248- 3, // 2: ermite.storage.Repo.pushed_at:type_name -> google.protobuf.Timestamp368+ 4, // 2: ermite.storage.Repo.pushed_at:type_name -> google.protobuf.Timestamp
249- 3, // [3:3] is the sub-list for method output_type369+ 4, // 3: ermite.storage.Token.expires_at:type_name -> google.protobuf.Timestamp
250- 3, // [3:3] is the sub-list for method input_type370+ 4, // 4: ermite.storage.Token.created_at:type_name -> google.protobuf.Timestamp
251- 3, // [3:3] is the sub-list for extension type_name371+ 4, // 5: ermite.storage.Token.last_used_at:type_name -> google.protobuf.Timestamp
252- 3, // [3:3] is the sub-list for extension extendee372+ 6, // [6:6] is the sub-list for method output_type
253- 0, // [0:3] is the sub-list for field type_name373+ 6, // [6:6] is the sub-list for method input_type
374+ 6, // [6:6] is the sub-list for extension type_name
375+ 6, // [6:6] is the sub-list for extension extendee
376+ 0, // [0:6] is the sub-list for field type_name
254 }377 }
255 378
256 func init() { file_ermite_storage_repo_proto_init() }379 func init() { file_ermite_storage_repo_proto_init() }
@@ -264,7 +387,7 @@ func file_ermite_storage_repo_proto_init() {
264 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),387 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
265 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_repo_proto_rawDesc), len(file_ermite_storage_repo_proto_rawDesc)),388 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_repo_proto_rawDesc), len(file_ermite_storage_repo_proto_rawDesc)),
266 NumEnums: 1,389 NumEnums: 1,
267- NumMessages: 2,390+ NumMessages: 3,
268 NumExtensions: 0,391 NumExtensions: 0,
269 NumServices: 0,392 NumServices: 0,
270 },393 },
A internal/adapters/kvstore/tokens.go +243 -0
@@ -0,0 +1,243 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strings"
11+ "time"
12+ "unicode/utf8"
13+
14+ kv "bnema.dev/ermite-kv"
15+ "google.golang.org/protobuf/proto"
16+
17+ storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+const (
23+ tokenPrefix = "token/"
24+ tokenHashPrefix = "tokenhash/"
25+
26+ // updateAttempts bounds the retries of a read-modify-write that loses a
27+ // race with another write.
28+ updateAttempts = 5
29+)
30+
31+// Tokens is the ermite-kv backed ports.TokenStore. It shares the database of
32+// the Store it comes from.
33+type Tokens struct {
34+ db *kv.DB
35+}
36+
37+var _ ports.TokenStore = (*Tokens)(nil)
38+
39+// Tokens returns the token store over the same database. It is valid until
40+// the Store is closed.
41+func (s *Store) Tokens() *Tokens { return &Tokens{db: s.db} }
42+
43+func tokenKey(id string) []byte { return []byte(tokenPrefix + id) }
44+func tokenHashKey(h domain.TokenHash) []byte { return []byte(tokenHashPrefix + h.String()) }
45+
46+// validTokenID reports whether id is usable inside a key.
47+func validTokenID(id string) bool {
48+ return id != "" && len(id) <= 64 && !strings.ContainsAny(id, "/\x00") && utf8.ValidString(id)
49+}
50+
51+// Create stores token/<id> and tokenhash/<hex> in one batch, each with an
52+// "absent" precondition: a taken id or hash yields domain.ErrAlreadyExists.
53+func (s *Tokens) Create(ctx context.Context, t domain.Token) error {
54+ val, err := encodeToken(t)
55+ if err != nil {
56+ return err
57+ }
58+ var b kv.Batch
59+ b.RequireAbsent(tokenKey(t.ID))
60+ b.RequireAbsent(tokenHashKey(t.Hash))
61+ b.Put(tokenKey(t.ID), val)
62+ b.Put(tokenHashKey(t.Hash), []byte(t.ID))
63+ if _, err := s.db.Apply(ctx, &b); err != nil {
64+ if errors.Is(err, kv.ErrConflict) {
65+ return domain.ErrAlreadyExists
66+ }
67+ return fmt.Errorf("create token %q: %w", t.ID, err)
68+ }
69+ return nil
70+}
71+
72+// FindByHash resolves the hash index, then reads the token.
73+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 agree
75+ idx, ok := snap.Get(ctx, tokenHashKey(hash))
76+ if !ok {
77+ return domain.Token{}, domain.ErrNotFound
78+ }
79+ item, ok := snap.Get(ctx, tokenKey(string(idx.Value)))
80+ if !ok {
81+ return domain.Token{}, domain.ErrNotFound
82+ }
83+ t, err := decodeToken(item.Value)
84+ if err != nil {
85+ return domain.Token{}, err
86+ }
87+ if t.Hash != hash {
88+ return domain.Token{}, fmt.Errorf("token %q: hash index does not match the stored hash", t.ID)
89+ }
90+ return t, nil
91+}
92+
93+// List returns every token ordered by creation time, then id.
94+func (s *Tokens) List(ctx context.Context) ([]domain.Token, error) {
95+ var out []domain.Token
96+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
97+ t, err := decodeToken(item.Value)
98+ if err != nil {
99+ return nil, fmt.Errorf("decode %s: %w", key, err)
100+ }
101+ out = append(out, t)
102+ }
103+ slices.SortStableFunc(out, func(a, b domain.Token) int {
104+ if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 {
105+ return c
106+ }
107+ return strings.Compare(a.ID, b.ID)
108+ })
109+ return out, nil
110+}
111+
112+// Revoke deletes token/<id> and its hash index in one batch.
113+func (s *Tokens) Revoke(ctx context.Context, id string) error {
114+ if !validTokenID(id) {
115+ return domain.ErrNotFound
116+ }
117+ for range updateAttempts {
118+ item, ok := s.db.Get(ctx, tokenKey(id))
119+ if !ok {
120+ return domain.ErrNotFound
121+ }
122+ t, err := decodeToken(item.Value)
123+ if err != nil {
124+ return err
125+ }
126+ var b kv.Batch
127+ b.RequireVersion(tokenKey(id), item.Version)
128+ b.Delete(tokenKey(id))
129+ b.Delete(tokenHashKey(t.Hash))
130+ _, err = s.db.Apply(ctx, &b)
131+ if errors.Is(err, kv.ErrConflict) {
132+ continue // touched or revoked meanwhile: read again
133+ }
134+ if err != nil {
135+ return fmt.Errorf("revoke token %q: %w", id, err)
136+ }
137+ return nil
138+ }
139+ return fmt.Errorf("revoke token %q: %w", id, domain.ErrConflict)
140+}
141+
142+// Touch sets last_used_at when the given time is later than the stored one.
143+func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {
144+ if !validTokenID(id) {
145+ return domain.ErrNotFound
146+ }
147+ for range updateAttempts {
148+ item, ok := s.db.Get(ctx, tokenKey(id))
149+ if !ok {
150+ return domain.ErrNotFound
151+ }
152+ t, err := decodeToken(item.Value)
153+ if err != nil {
154+ return err
155+ }
156+ if !at.After(t.LastUsedAt) {
157+ return nil
158+ }
159+ t.LastUsedAt = at
160+ val, err := encodeToken(t)
161+ if err != nil {
162+ return err
163+ }
164+ var b kv.Batch
165+ b.RequireVersion(tokenKey(id), item.Version)
166+ b.Put(tokenKey(id), val)
167+ _, err = s.db.Apply(ctx, &b)
168+ if errors.Is(err, kv.ErrConflict) {
169+ continue
170+ }
171+ if err != nil {
172+ return fmt.Errorf("touch token %q: %w", id, err)
173+ }
174+ return nil
175+ }
176+ return fmt.Errorf("touch token %q: %w", id, domain.ErrConflict)
177+}
178+
179+func encodeToken(t domain.Token) ([]byte, error) {
180+ if !validTokenID(t.ID) {
181+ return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID)
182+ }
183+ if !utf8.ValidString(t.Label) {
184+ return nil, fmt.Errorf("%w: label is not valid UTF-8", domain.ErrInvalidToken)
185+ }
186+ if len(t.Scopes) == 0 {
187+ return nil, fmt.Errorf("%w: no scopes", domain.ErrInvalidToken)
188+ }
189+ pb := &storagepb.Token{
190+ Id: t.ID,
191+ Label: t.Label,
192+ Hash: t.Hash[:],
193+ ExpiresAt: encodeTime(t.ExpiresAt),
194+ CreatedAt: encodeTime(t.CreatedAt),
195+ LastUsedAt: encodeTime(t.LastUsedAt),
196+ }
197+ for _, sc := range t.Scopes {
198+ if _, err := domain.ParseScope(sc.String()); err != nil {
199+ return nil, err
200+ }
201+ pb.Scopes = append(pb.Scopes, sc.String())
202+ }
203+ for _, r := range t.Repos {
204+ if r.IsZero() {
205+ return nil, fmt.Errorf("%w: empty repository name", domain.ErrInvalidName)
206+ }
207+ pb.Repos = append(pb.Repos, r.String())
208+ }
209+ return mustMarshal(pb), nil
210+}
211+
212+// 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.
214+func decodeToken(val []byte) (domain.Token, error) {
215+ var pb storagepb.Token
216+ if err := proto.Unmarshal(val, &pb); err != nil {
217+ return domain.Token{}, fmt.Errorf("decode token: %w", err)
218+ }
219+ t := domain.Token{
220+ ID: pb.GetId(),
221+ Label: pb.GetLabel(),
222+ ExpiresAt: decodeTime(pb.GetExpiresAt()),
223+ CreatedAt: decodeTime(pb.GetCreatedAt()),
224+ LastUsedAt: decodeTime(pb.GetLastUsedAt()),
225+ }
226+ if len(pb.GetHash()) != len(t.Hash) {
227+ return domain.Token{}, fmt.Errorf("decode token %q: hash has %d bytes", t.ID, len(pb.GetHash()))
228+ }
229+ copy(t.Hash[:], pb.GetHash())
230+ for _, s := range pb.GetScopes() {
231+ if sc, err := domain.ParseScope(s); err == nil {
232+ t.Scopes = append(t.Scopes, sc)
233+ }
234+ }
235+ for _, r := range pb.GetRepos() {
236+ name, err := domain.ParseRepoName(r)
237+ if err != nil {
238+ return domain.Token{}, fmt.Errorf("decode token %q: %w", t.ID, err)
239+ }
240+ t.Repos = append(t.Repos, name)
241+ }
242+ return t, nil
243+}
@@ -0,0 +1,243 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strings"
11+ "time"
12+ "unicode/utf8"
13+
14+ kv "bnema.dev/ermite-kv"
15+ "google.golang.org/protobuf/proto"
16+
17+ storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+const (
23+ tokenPrefix = "token/"
24+ tokenHashPrefix = "tokenhash/"
25+
26+ // updateAttempts bounds the retries of a read-modify-write that loses a
27+ // race with another write.
28+ updateAttempts = 5
29+)
30+
31+// Tokens is the ermite-kv backed ports.TokenStore. It shares the database of
32+// the Store it comes from.
33+type Tokens struct {
34+ db *kv.DB
35+}
36+
37+var _ ports.TokenStore = (*Tokens)(nil)
38+
39+// Tokens returns the token store over the same database. It is valid until
40+// the Store is closed.
41+func (s *Store) Tokens() *Tokens { return &Tokens{db: s.db} }
42+
43+func tokenKey(id string) []byte { return []byte(tokenPrefix + id) }
44+func tokenHashKey(h domain.TokenHash) []byte { return []byte(tokenHashPrefix + h.String()) }
45+
46+// validTokenID reports whether id is usable inside a key.
47+func validTokenID(id string) bool {
48+ return id != "" && len(id) <= 64 && !strings.ContainsAny(id, "/\x00") && utf8.ValidString(id)
49+}
50+
51+// Create stores token/<id> and tokenhash/<hex> in one batch, each with an
52+// "absent" precondition: a taken id or hash yields domain.ErrAlreadyExists.
53+func (s *Tokens) Create(ctx context.Context, t domain.Token) error {
54+ val, err := encodeToken(t)
55+ if err != nil {
56+ return err
57+ }
58+ var b kv.Batch
59+ b.RequireAbsent(tokenKey(t.ID))
60+ b.RequireAbsent(tokenHashKey(t.Hash))
61+ b.Put(tokenKey(t.ID), val)
62+ b.Put(tokenHashKey(t.Hash), []byte(t.ID))
63+ if _, err := s.db.Apply(ctx, &b); err != nil {
64+ if errors.Is(err, kv.ErrConflict) {
65+ return domain.ErrAlreadyExists
66+ }
67+ return fmt.Errorf("create token %q: %w", t.ID, err)
68+ }
69+ return nil
70+}
71+
72+// FindByHash resolves the hash index, then reads the token.
73+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 agree
75+ idx, ok := snap.Get(ctx, tokenHashKey(hash))
76+ if !ok {
77+ return domain.Token{}, domain.ErrNotFound
78+ }
79+ item, ok := snap.Get(ctx, tokenKey(string(idx.Value)))
80+ if !ok {
81+ return domain.Token{}, domain.ErrNotFound
82+ }
83+ t, err := decodeToken(item.Value)
84+ if err != nil {
85+ return domain.Token{}, err
86+ }
87+ if t.Hash != hash {
88+ return domain.Token{}, fmt.Errorf("token %q: hash index does not match the stored hash", t.ID)
89+ }
90+ return t, nil
91+}
92+
93+// List returns every token ordered by creation time, then id.
94+func (s *Tokens) List(ctx context.Context) ([]domain.Token, error) {
95+ var out []domain.Token
96+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
97+ t, err := decodeToken(item.Value)
98+ if err != nil {
99+ return nil, fmt.Errorf("decode %s: %w", key, err)
100+ }
101+ out = append(out, t)
102+ }
103+ slices.SortStableFunc(out, func(a, b domain.Token) int {
104+ if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 {
105+ return c
106+ }
107+ return strings.Compare(a.ID, b.ID)
108+ })
109+ return out, nil
110+}
111+
112+// Revoke deletes token/<id> and its hash index in one batch.
113+func (s *Tokens) Revoke(ctx context.Context, id string) error {
114+ if !validTokenID(id) {
115+ return domain.ErrNotFound
116+ }
117+ for range updateAttempts {
118+ item, ok := s.db.Get(ctx, tokenKey(id))
119+ if !ok {
120+ return domain.ErrNotFound
121+ }
122+ t, err := decodeToken(item.Value)
123+ if err != nil {
124+ return err
125+ }
126+ var b kv.Batch
127+ b.RequireVersion(tokenKey(id), item.Version)
128+ b.Delete(tokenKey(id))
129+ b.Delete(tokenHashKey(t.Hash))
130+ _, err = s.db.Apply(ctx, &b)
131+ if errors.Is(err, kv.ErrConflict) {
132+ continue // touched or revoked meanwhile: read again
133+ }
134+ if err != nil {
135+ return fmt.Errorf("revoke token %q: %w", id, err)
136+ }
137+ return nil
138+ }
139+ return fmt.Errorf("revoke token %q: %w", id, domain.ErrConflict)
140+}
141+
142+// Touch sets last_used_at when the given time is later than the stored one.
143+func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {
144+ if !validTokenID(id) {
145+ return domain.ErrNotFound
146+ }
147+ for range updateAttempts {
148+ item, ok := s.db.Get(ctx, tokenKey(id))
149+ if !ok {
150+ return domain.ErrNotFound
151+ }
152+ t, err := decodeToken(item.Value)
153+ if err != nil {
154+ return err
155+ }
156+ if !at.After(t.LastUsedAt) {
157+ return nil
158+ }
159+ t.LastUsedAt = at
160+ val, err := encodeToken(t)
161+ if err != nil {
162+ return err
163+ }
164+ var b kv.Batch
165+ b.RequireVersion(tokenKey(id), item.Version)
166+ b.Put(tokenKey(id), val)
167+ _, err = s.db.Apply(ctx, &b)
168+ if errors.Is(err, kv.ErrConflict) {
169+ continue
170+ }
171+ if err != nil {
172+ return fmt.Errorf("touch token %q: %w", id, err)
173+ }
174+ return nil
175+ }
176+ return fmt.Errorf("touch token %q: %w", id, domain.ErrConflict)
177+}
178+
179+func encodeToken(t domain.Token) ([]byte, error) {
180+ if !validTokenID(t.ID) {
181+ return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID)
182+ }
183+ if !utf8.ValidString(t.Label) {
184+ return nil, fmt.Errorf("%w: label is not valid UTF-8", domain.ErrInvalidToken)
185+ }
186+ if len(t.Scopes) == 0 {
187+ return nil, fmt.Errorf("%w: no scopes", domain.ErrInvalidToken)
188+ }
189+ pb := &storagepb.Token{
190+ Id: t.ID,
191+ Label: t.Label,
192+ Hash: t.Hash[:],
193+ ExpiresAt: encodeTime(t.ExpiresAt),
194+ CreatedAt: encodeTime(t.CreatedAt),
195+ LastUsedAt: encodeTime(t.LastUsedAt),
196+ }
197+ for _, sc := range t.Scopes {
198+ if _, err := domain.ParseScope(sc.String()); err != nil {
199+ return nil, err
200+ }
201+ pb.Scopes = append(pb.Scopes, sc.String())
202+ }
203+ for _, r := range t.Repos {
204+ if r.IsZero() {
205+ return nil, fmt.Errorf("%w: empty repository name", domain.ErrInvalidName)
206+ }
207+ pb.Repos = append(pb.Repos, r.String())
208+ }
209+ return mustMarshal(pb), nil
210+}
211+
212+// 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.
214+func decodeToken(val []byte) (domain.Token, error) {
215+ var pb storagepb.Token
216+ if err := proto.Unmarshal(val, &pb); err != nil {
217+ return domain.Token{}, fmt.Errorf("decode token: %w", err)
218+ }
219+ t := domain.Token{
220+ ID: pb.GetId(),
221+ Label: pb.GetLabel(),
222+ ExpiresAt: decodeTime(pb.GetExpiresAt()),
223+ CreatedAt: decodeTime(pb.GetCreatedAt()),
224+ LastUsedAt: decodeTime(pb.GetLastUsedAt()),
225+ }
226+ if len(pb.GetHash()) != len(t.Hash) {
227+ return domain.Token{}, fmt.Errorf("decode token %q: hash has %d bytes", t.ID, len(pb.GetHash()))
228+ }
229+ copy(t.Hash[:], pb.GetHash())
230+ for _, s := range pb.GetScopes() {
231+ if sc, err := domain.ParseScope(s); err == nil {
232+ t.Scopes = append(t.Scopes, sc)
233+ }
234+ }
235+ for _, r := range pb.GetRepos() {
236+ name, err := domain.ParseRepoName(r)
237+ if err != nil {
238+ return domain.Token{}, fmt.Errorf("decode token %q: %w", t.ID, err)
239+ }
240+ t.Repos = append(t.Repos, name)
241+ }
242+ return t, nil
243+}
A internal/adapters/kvstore/tokens_test.go +175 -0
@@ -0,0 +1,175 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "fmt"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+)
17+
18+func newToken(t *testing.T, id, secret string) domain.Token {
19+ t.Helper()
20+ return domain.Token{
21+ ID: id,
22+ Label: "label " + id,
23+ Hash: domain.HashTokenSecret(secret),
24+ Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite},
25+ Repos: []domain.RepoName{name(t, "a"), name(t, "b")},
26+ ExpiresAt: created.Add(24 * time.Hour),
27+ CreatedAt: created,
28+ }
29+}
30+
31+func TestTokensRoundTrip(t *testing.T) {
32+ ctx := context.Background()
33+ s := openStore(t, t.TempDir())
34+ ts := s.Tokens()
35+
36+ tok := newToken(t, "t1", "secret-1")
37+ require.NoError(t, ts.Create(ctx, tok))
38+
39+ got, err := ts.FindByHash(ctx, tok.Hash)
40+ require.NoError(t, err)
41+ assert.Equal(t, tok, got)
42+
43+ _, err = ts.FindByHash(ctx, domain.HashTokenSecret("other"))
44+ assert.ErrorIs(t, err, domain.ErrNotFound)
45+
46+ // Minimal token: no repos, no expiry.
47+ min := domain.Token{ID: "t2", Hash: domain.HashTokenSecret("secret-2"), Scopes: []domain.Scope{domain.ScopeRepoRead}, CreatedAt: created.Add(time.Minute)}
48+ require.NoError(t, ts.Create(ctx, min))
49+ got, err = ts.FindByHash(ctx, min.Hash)
50+ require.NoError(t, err)
51+ assert.Empty(t, got.Repos)
52+ assert.True(t, got.ExpiresAt.IsZero())
53+ assert.True(t, got.LastUsedAt.IsZero())
54+
55+ list, err := ts.List(ctx)
56+ require.NoError(t, err)
57+ require.Len(t, list, 2)
58+ assert.Equal(t, "t1", list[0].ID)
59+ assert.Equal(t, "t2", list[1].ID)
60+}
61+
62+func TestTokensCreateRejects(t *testing.T) {
63+ ctx := context.Background()
64+ s := openStore(t, t.TempDir())
65+ ts := s.Tokens()
66+ require.NoError(t, ts.Create(ctx, newToken(t, "t1", "s1")))
67+
68+ assert.ErrorIs(t, ts.Create(ctx, newToken(t, "t1", "other")), domain.ErrAlreadyExists, "same id")
69+ assert.ErrorIs(t, ts.Create(ctx, newToken(t, "t9", "s1")), domain.ErrAlreadyExists, "same hash")
70+
71+ // A failed create leaves no half-written index.
72+ _, err := ts.FindByHash(ctx, domain.HashTokenSecret("other"))
73+ assert.ErrorIs(t, err, domain.ErrNotFound)
74+ list, err := ts.List(ctx)
75+ require.NoError(t, err)
76+ assert.Len(t, list, 1)
77+
78+ bad := newToken(t, "t3", "s3")
79+ bad.Scopes = nil
80+ assert.ErrorIs(t, ts.Create(ctx, bad), domain.ErrInvalidToken)
81+ bad = newToken(t, "t3", "s3")
82+ bad.Scopes = []domain.Scope{"nope"}
83+ assert.ErrorIs(t, ts.Create(ctx, bad), domain.ErrInvalidScope)
84+ bad = newToken(t, "a/b", "s3")
85+ assert.ErrorIs(t, ts.Create(ctx, bad), domain.ErrInvalidToken)
86+ bad = newToken(t, "", "s3")
87+ assert.ErrorIs(t, ts.Create(ctx, bad), domain.ErrInvalidToken)
88+}
89+
90+func TestTokensRevokeKeepsIndexConsistent(t *testing.T) {
91+ ctx := context.Background()
92+ dir := t.TempDir()
93+ s := openStore(t, dir)
94+ ts := s.Tokens()
95+ a, b := newToken(t, "a", "sa"), newToken(t, "b", "sb")
96+ require.NoError(t, ts.Create(ctx, a))
97+ require.NoError(t, ts.Create(ctx, b))
98+
99+ require.NoError(t, ts.Revoke(ctx, "a"))
100+ _, err := ts.FindByHash(ctx, a.Hash)
101+ assert.ErrorIs(t, err, domain.ErrNotFound)
102+ got, err := ts.FindByHash(ctx, b.Hash)
103+ require.NoError(t, err)
104+ assert.Equal(t, "b", got.ID)
105+ assert.ErrorIs(t, ts.Revoke(ctx, "a"), domain.ErrNotFound)
106+ assert.ErrorIs(t, ts.Revoke(ctx, "../x"), domain.ErrNotFound)
107+
108+ // The hash of a revoked token can be used again, and the index survives a restart.
109+ require.NoError(t, ts.Create(ctx, newToken(t, "c", "sa")))
110+ require.NoError(t, s.Close(ctx))
111+ s2 := openStore(t, dir)
112+ got, err = s2.Tokens().FindByHash(ctx, a.Hash)
113+ require.NoError(t, err)
114+ assert.Equal(t, "c", got.ID)
115+ list, err := s2.Tokens().List(ctx)
116+ require.NoError(t, err)
117+ assert.Len(t, list, 2)
118+}
119+
120+func TestTokensTouch(t *testing.T) {
121+ ctx := context.Background()
122+ s := openStore(t, t.TempDir())
123+ ts := s.Tokens()
124+ tok := newToken(t, "t1", "s1")
125+ require.NoError(t, ts.Create(ctx, tok))
126+
127+ at := created.Add(time.Hour)
128+ require.NoError(t, ts.Touch(ctx, "t1", at))
129+ got, err := ts.FindByHash(ctx, tok.Hash)
130+ require.NoError(t, err)
131+ assert.True(t, at.Equal(got.LastUsedAt))
132+
133+ // An older time does not move it back.
134+ require.NoError(t, ts.Touch(ctx, "t1", created))
135+ got, err = ts.FindByHash(ctx, tok.Hash)
136+ require.NoError(t, err)
137+ assert.True(t, at.Equal(got.LastUsedAt))
138+
139+ assert.ErrorIs(t, ts.Touch(ctx, "gone", at), domain.ErrNotFound)
140+
141+ // Everything else is untouched.
142+ got.LastUsedAt = time.Time{}
143+ assert.Equal(t, tok, got)
144+}
145+
146+func TestTokensConcurrentTouchAndRevoke(t *testing.T) {
147+ ctx := context.Background()
148+ s := openStore(t, t.TempDir())
149+ ts := s.Tokens()
150+ tok := newToken(t, "t1", "s1")
151+ require.NoError(t, ts.Create(ctx, tok))
152+
153+ var wg sync.WaitGroup
154+ for i := range 8 {
155+ wg.Add(1)
156+ go func() {
157+ defer wg.Done()
158+ _ = ts.Touch(ctx, "t1", created.Add(time.Duration(i+1)*time.Second))
159+ }()
160+ }
161+ wg.Add(1)
162+ go func() { defer wg.Done(); _ = ts.Revoke(ctx, "t1") }()
163+ wg.Wait()
164+
165+ // Whatever the interleaving, token and index are both there or both gone.
166+ _, errTok := ts.FindByHash(ctx, tok.Hash)
167+ list, err := ts.List(ctx)
168+ require.NoError(t, err)
169+ if errTok != nil {
170+ assert.ErrorIs(t, errTok, domain.ErrNotFound, fmt.Sprint(errTok))
171+ assert.Empty(t, list)
172+ } else {
173+ assert.Len(t, list, 1)
174+ }
175+}
@@ -0,0 +1,175 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "fmt"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+)
17+
18+func newToken(t *testing.T, id, secret string) domain.Token {
19+ t.Helper()
20+ return domain.Token{
21+ ID: id,
22+ Label: "label " + id,
23+ Hash: domain.HashTokenSecret(secret),
24+ Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite},
25+ Repos: []domain.RepoName{name(t, "a"), name(t, "b")},
26+ ExpiresAt: created.Add(24 * time.Hour),
27+ CreatedAt: created,
28+ }
29+}
30+
31+func TestTokensRoundTrip(t *testing.T) {
32+ ctx := context.Background()
33+ s := openStore(t, t.TempDir())
34+ ts := s.Tokens()
35+
36+ tok := newToken(t, "t1", "secret-1")
37+ require.NoError(t, ts.Create(ctx, tok))
38+
39+ got, err := ts.FindByHash(ctx, tok.Hash)
40+ require.NoError(t, err)
41+ assert.Equal(t, tok, got)
42+
43+ _, err = ts.FindByHash(ctx, domain.HashTokenSecret("other"))
44+ assert.ErrorIs(t, err, domain.ErrNotFound)
45+
46+ // Minimal token: no repos, no expiry.
47+ min := domain.Token{ID: "t2", Hash: domain.HashTokenSecret("secret-2"), Scopes: []domain.Scope{domain.ScopeRepoRead}, CreatedAt: created.Add(time.Minute)}
48+ require.NoError(t, ts.Create(ctx, min))
49+ got, err = ts.FindByHash(ctx, min.Hash)
50+ require.NoError(t, err)
51+ assert.Empty(t, got.Repos)
52+ assert.True(t, got.ExpiresAt.IsZero())
53+ assert.True(t, got.LastUsedAt.IsZero())
54+
55+ list, err := ts.List(ctx)
56+ require.NoError(t, err)
57+ require.Len(t, list, 2)
58+ assert.Equal(t, "t1", list[0].ID)
59+ assert.Equal(t, "t2", list[1].ID)
60+}
61+
62+func TestTokensCreateRejects(t *testing.T) {
63+ ctx := context.Background()
64+ s := openStore(t, t.TempDir())
65+ ts := s.Tokens()
66+ require.NoError(t, ts.Create(ctx, newToken(t, "t1", "s1")))
67+
68+ assert.ErrorIs(t, ts.Create(ctx, newToken(t, "t1", "other")), domain.ErrAlreadyExists, "same id")
69+ assert.ErrorIs(t, ts.Create(ctx, newToken(t, "t9", "s1")), domain.ErrAlreadyExists, "same hash")
70+
71+ // A failed create leaves no half-written index.
72+ _, err := ts.FindByHash(ctx, domain.HashTokenSecret("other"))
73+ assert.ErrorIs(t, err, domain.ErrNotFound)
74+ list, err := ts.List(ctx)
75+ require.NoError(t, err)
76+ assert.Len(t, list, 1)
77+
78+ bad := newToken(t, "t3", "s3")
79+ bad.Scopes = nil
80+ assert.ErrorIs(t, ts.Create(ctx, bad), domain.ErrInvalidToken)
81+ bad = newToken(t, "t3", "s3")
82+ bad.Scopes = []domain.Scope{"nope"}
83+ assert.ErrorIs(t, ts.Create(ctx, bad), domain.ErrInvalidScope)
84+ bad = newToken(t, "a/b", "s3")
85+ assert.ErrorIs(t, ts.Create(ctx, bad), domain.ErrInvalidToken)
86+ bad = newToken(t, "", "s3")
87+ assert.ErrorIs(t, ts.Create(ctx, bad), domain.ErrInvalidToken)
88+}
89+
90+func TestTokensRevokeKeepsIndexConsistent(t *testing.T) {
91+ ctx := context.Background()
92+ dir := t.TempDir()
93+ s := openStore(t, dir)
94+ ts := s.Tokens()
95+ a, b := newToken(t, "a", "sa"), newToken(t, "b", "sb")
96+ require.NoError(t, ts.Create(ctx, a))
97+ require.NoError(t, ts.Create(ctx, b))
98+
99+ require.NoError(t, ts.Revoke(ctx, "a"))
100+ _, err := ts.FindByHash(ctx, a.Hash)
101+ assert.ErrorIs(t, err, domain.ErrNotFound)
102+ got, err := ts.FindByHash(ctx, b.Hash)
103+ require.NoError(t, err)
104+ assert.Equal(t, "b", got.ID)
105+ assert.ErrorIs(t, ts.Revoke(ctx, "a"), domain.ErrNotFound)
106+ assert.ErrorIs(t, ts.Revoke(ctx, "../x"), domain.ErrNotFound)
107+
108+ // The hash of a revoked token can be used again, and the index survives a restart.
109+ require.NoError(t, ts.Create(ctx, newToken(t, "c", "sa")))
110+ require.NoError(t, s.Close(ctx))
111+ s2 := openStore(t, dir)
112+ got, err = s2.Tokens().FindByHash(ctx, a.Hash)
113+ require.NoError(t, err)
114+ assert.Equal(t, "c", got.ID)
115+ list, err := s2.Tokens().List(ctx)
116+ require.NoError(t, err)
117+ assert.Len(t, list, 2)
118+}
119+
120+func TestTokensTouch(t *testing.T) {
121+ ctx := context.Background()
122+ s := openStore(t, t.TempDir())
123+ ts := s.Tokens()
124+ tok := newToken(t, "t1", "s1")
125+ require.NoError(t, ts.Create(ctx, tok))
126+
127+ at := created.Add(time.Hour)
128+ require.NoError(t, ts.Touch(ctx, "t1", at))
129+ got, err := ts.FindByHash(ctx, tok.Hash)
130+ require.NoError(t, err)
131+ assert.True(t, at.Equal(got.LastUsedAt))
132+
133+ // An older time does not move it back.
134+ require.NoError(t, ts.Touch(ctx, "t1", created))
135+ got, err = ts.FindByHash(ctx, tok.Hash)
136+ require.NoError(t, err)
137+ assert.True(t, at.Equal(got.LastUsedAt))
138+
139+ assert.ErrorIs(t, ts.Touch(ctx, "gone", at), domain.ErrNotFound)
140+
141+ // Everything else is untouched.
142+ got.LastUsedAt = time.Time{}
143+ assert.Equal(t, tok, got)
144+}
145+
146+func TestTokensConcurrentTouchAndRevoke(t *testing.T) {
147+ ctx := context.Background()
148+ s := openStore(t, t.TempDir())
149+ ts := s.Tokens()
150+ tok := newToken(t, "t1", "s1")
151+ require.NoError(t, ts.Create(ctx, tok))
152+
153+ var wg sync.WaitGroup
154+ for i := range 8 {
155+ wg.Add(1)
156+ go func() {
157+ defer wg.Done()
158+ _ = ts.Touch(ctx, "t1", created.Add(time.Duration(i+1)*time.Second))
159+ }()
160+ }
161+ wg.Add(1)
162+ go func() { defer wg.Done(); _ = ts.Revoke(ctx, "t1") }()
163+ wg.Wait()
164+
165+ // Whatever the interleaving, token and index are both there or both gone.
166+ _, errTok := ts.FindByHash(ctx, tok.Hash)
167+ list, err := ts.List(ctx)
168+ require.NoError(t, err)
169+ if errTok != nil {
170+ assert.ErrorIs(t, errTok, domain.ErrNotFound, fmt.Sprint(errTok))
171+ assert.Empty(t, list)
172+ } else {
173+ assert.Len(t, list, 1)
174+ }
175+}
M internal/domain/errors.go +6 -0
@@ -12,10 +12,16 @@ var (
1212 ErrInvalidName = errors.New("invalid repository name")
1313 // ErrInvalidVisibility means a visibility value is neither public nor private.
1414 ErrInvalidVisibility = errors.New("invalid visibility")
15+ // ErrInvalidScope means a scope is not one of the known scopes.
16+ ErrInvalidScope = errors.New("invalid scope")
17+ // ErrInvalidToken means the settings of a token are not acceptable.
18+ ErrInvalidToken = errors.New("invalid token")
1519 // ErrAlreadyExists means the entity to create already exists.
1620 ErrAlreadyExists = errors.New("already exists")
1721 // ErrUnauthenticated means the operation needs credentials the caller lacks.
1822 ErrUnauthenticated = errors.New("unauthenticated")
23+ // ErrForbidden means the caller is known but may not do this.
24+ ErrForbidden = errors.New("forbidden")
1925 // ErrConflict means a concurrent change prevented the operation.
2026 ErrConflict = errors.New("conflict")
2127 )
@@ -12,10 +12,16 @@ var (
12 ErrInvalidName = errors.New("invalid repository name")12 ErrInvalidName = errors.New("invalid repository name")
13 // ErrInvalidVisibility means a visibility value is neither public nor private.13 // ErrInvalidVisibility means a visibility value is neither public nor private.
14 ErrInvalidVisibility = errors.New("invalid visibility")14 ErrInvalidVisibility = errors.New("invalid visibility")
15+ // ErrInvalidScope means a scope is not one of the known scopes.
16+ ErrInvalidScope = errors.New("invalid scope")
17+ // ErrInvalidToken means the settings of a token are not acceptable.
18+ ErrInvalidToken = errors.New("invalid token")
15 // ErrAlreadyExists means the entity to create already exists.19 // ErrAlreadyExists means the entity to create already exists.
16 ErrAlreadyExists = errors.New("already exists")20 ErrAlreadyExists = errors.New("already exists")
17 // ErrUnauthenticated means the operation needs credentials the caller lacks.21 // ErrUnauthenticated means the operation needs credentials the caller lacks.
18 ErrUnauthenticated = errors.New("unauthenticated")22 ErrUnauthenticated = errors.New("unauthenticated")
23+ // ErrForbidden means the caller is known but may not do this.
24+ ErrForbidden = errors.New("forbidden")
19 // ErrConflict means a concurrent change prevented the operation.25 // ErrConflict means a concurrent change prevented the operation.
20 ErrConflict = errors.New("conflict")26 ErrConflict = errors.New("conflict")
21 )27 )
M internal/domain/principal.go +75 -3
@@ -2,6 +2,11 @@
22
33 package domain
44
5+import (
6+ "slices"
7+ "time"
8+)
9+
510 // PrincipalKind tells who a caller is.
611 type PrincipalKind uint8
712
@@ -11,11 +16,30 @@ const (
1116 PrincipalAnonymous PrincipalKind = iota
1217 // PrincipalRoot is the holder of the admin token.
1318 PrincipalRoot
19+ // PrincipalToken is the holder of a stored token.
20+ PrincipalToken
1421 )
1522
16-// Principal is the authenticated identity of a caller. Stored tokens add
17-// scoped principals later.
18-type Principal struct{ kind PrincipalKind }
23+// String returns "anonymous", "root" or "token".
24+func (k PrincipalKind) String() string {
25+ switch k {
26+ case PrincipalRoot:
27+ return "root"
28+ case PrincipalToken:
29+ return "token"
30+ }
31+ return "anonymous"
32+}
33+
34+// Principal is the authenticated identity of a caller: nobody, the root
35+// token, or a stored token with its scopes and repositories.
36+type Principal struct {
37+ kind PrincipalKind
38+ tokenID string
39+ scopes []Scope
40+ repos []RepoName
41+ expiresAt time.Time
42+}
1943
2044 // Anonymous returns the principal of a caller without credentials.
2145 func Anonymous() Principal { return Principal{kind: PrincipalAnonymous} }
@@ -23,6 +47,17 @@ func Anonymous() Principal { return Principal{kind: PrincipalAnonymous} }
2347 // Root returns the principal of the admin token holder.
2448 func Root() Principal { return Principal{kind: PrincipalRoot} }
2549
50+// TokenPrincipal returns the principal of the holder of a stored token.
51+func TokenPrincipal(t Token) Principal {
52+ return Principal{
53+ kind: PrincipalToken,
54+ tokenID: t.ID,
55+ scopes: slices.Clone(t.Scopes),
56+ repos: slices.Clone(t.Repos),
57+ expiresAt: t.ExpiresAt,
58+ }
59+}
60+
2661 // Kind returns the kind of principal.
2762 func (p Principal) Kind() PrincipalKind { return p.kind }
2863
@@ -31,3 +66,40 @@ func (p Principal) IsRoot() bool { return p.kind == PrincipalRoot }
3166
3267 // IsAnonymous reports whether p has no credentials.
3368 func (p Principal) IsAnonymous() bool { return p.kind == PrincipalAnonymous }
69+
70+// TokenID returns the ID of the stored token, or "" for other principals.
71+func (p Principal) TokenID() string { return p.tokenID }
72+
73+// Scopes returns the scopes of a stored token; nil for other principals.
74+func (p Principal) Scopes() []Scope { return slices.Clone(p.scopes) }
75+
76+// Repos returns the repository list of a stored token; empty means every
77+// repository.
78+func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) }
79+
80+// ExpiresAt returns the expiry of a stored token; zero means none.
81+func (p Principal) ExpiresAt() time.Time { return p.expiresAt }
82+
83+// HasScope reports whether p holds the scope, for some repository. Root holds
84+// every scope; anonymous none.
85+func (p Principal) HasScope(s Scope) bool {
86+ switch p.kind {
87+ case PrincipalRoot:
88+ return true
89+ case PrincipalToken:
90+ return slices.Contains(p.scopes, s)
91+ }
92+ return false
93+}
94+
95+// Allows reports whether p holds the scope on the repository: the scope, and
96+// the repository within the token's list when it has one.
97+func (p Principal) Allows(s Scope, repo RepoName) bool {
98+ if !p.HasScope(s) {
99+ return false
100+ }
101+ if p.kind == PrincipalToken && len(p.repos) > 0 {
102+ return slices.Contains(p.repos, repo)
103+ }
104+ return true
105+}
@@ -2,6 +2,11 @@
2 2
3 package domain3 package domain
4 4
5+import (
6+ "slices"
7+ "time"
8+)
9+
5 // PrincipalKind tells who a caller is.10 // PrincipalKind tells who a caller is.
6 type PrincipalKind uint811 type PrincipalKind uint8
7 12
@@ -11,11 +16,30 @@ const (
11 PrincipalAnonymous PrincipalKind = iota16 PrincipalAnonymous PrincipalKind = iota
12 // PrincipalRoot is the holder of the admin token.17 // PrincipalRoot is the holder of the admin token.
13 PrincipalRoot18 PrincipalRoot
19+ // PrincipalToken is the holder of a stored token.
20+ PrincipalToken
14 )21 )
15 22
16-// Principal is the authenticated identity of a caller. Stored tokens add23+// String returns "anonymous", "root" or "token".
17-// scoped principals later.24+func (k PrincipalKind) String() string {
18-type Principal struct{ kind PrincipalKind }25+ switch k {
26+ case PrincipalRoot:
27+ return "root"
28+ case PrincipalToken:
29+ return "token"
30+ }
31+ return "anonymous"
32+}
33+
34+// Principal is the authenticated identity of a caller: nobody, the root
35+// token, or a stored token with its scopes and repositories.
36+type Principal struct {
37+ kind PrincipalKind
38+ tokenID string
39+ scopes []Scope
40+ repos []RepoName
41+ expiresAt time.Time
42+}
19 43
20 // Anonymous returns the principal of a caller without credentials.44 // Anonymous returns the principal of a caller without credentials.
21 func Anonymous() Principal { return Principal{kind: PrincipalAnonymous} }45 func Anonymous() Principal { return Principal{kind: PrincipalAnonymous} }
@@ -23,6 +47,17 @@ func Anonymous() Principal { return Principal{kind: PrincipalAnonymous} }
23 // Root returns the principal of the admin token holder.47 // Root returns the principal of the admin token holder.
24 func Root() Principal { return Principal{kind: PrincipalRoot} }48 func Root() Principal { return Principal{kind: PrincipalRoot} }
25 49
50+// TokenPrincipal returns the principal of the holder of a stored token.
51+func TokenPrincipal(t Token) Principal {
52+ return Principal{
53+ kind: PrincipalToken,
54+ tokenID: t.ID,
55+ scopes: slices.Clone(t.Scopes),
56+ repos: slices.Clone(t.Repos),
57+ expiresAt: t.ExpiresAt,
58+ }
59+}
60+
26 // Kind returns the kind of principal.61 // Kind returns the kind of principal.
27 func (p Principal) Kind() PrincipalKind { return p.kind }62 func (p Principal) Kind() PrincipalKind { return p.kind }
28 63
@@ -31,3 +66,40 @@ func (p Principal) IsRoot() bool { return p.kind == PrincipalRoot }
31 66
32 // IsAnonymous reports whether p has no credentials.67 // IsAnonymous reports whether p has no credentials.
33 func (p Principal) IsAnonymous() bool { return p.kind == PrincipalAnonymous }68 func (p Principal) IsAnonymous() bool { return p.kind == PrincipalAnonymous }
69+
70+// TokenID returns the ID of the stored token, or "" for other principals.
71+func (p Principal) TokenID() string { return p.tokenID }
72+
73+// Scopes returns the scopes of a stored token; nil for other principals.
74+func (p Principal) Scopes() []Scope { return slices.Clone(p.scopes) }
75+
76+// Repos returns the repository list of a stored token; empty means every
77+// repository.
78+func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) }
79+
80+// ExpiresAt returns the expiry of a stored token; zero means none.
81+func (p Principal) ExpiresAt() time.Time { return p.expiresAt }
82+
83+// HasScope reports whether p holds the scope, for some repository. Root holds
84+// every scope; anonymous none.
85+func (p Principal) HasScope(s Scope) bool {
86+ switch p.kind {
87+ case PrincipalRoot:
88+ return true
89+ case PrincipalToken:
90+ return slices.Contains(p.scopes, s)
91+ }
92+ return false
93+}
94+
95+// Allows reports whether p holds the scope on the repository: the scope, and
96+// the repository within the token's list when it has one.
97+func (p Principal) Allows(s Scope, repo RepoName) bool {
98+ if !p.HasScope(s) {
99+ return false
100+ }
101+ if p.kind == PrincipalToken && len(p.repos) > 0 {
102+ return slices.Contains(p.repos, repo)
103+ }
104+ return true
105+}
A internal/domain/token.go +92 -0
@@ -0,0 +1,92 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "crypto/sha256"
7+ "encoding/hex"
8+ "fmt"
9+ "slices"
10+ "time"
11+)
12+
13+// TokenSecretPrefix starts every token secret.
14+const TokenSecretPrefix = "ermite_"
15+
16+// Scope is one permission of a stored token.
17+type Scope string
18+
19+// The scopes of ADR 0006. The last three only matter after v1 and are defined
20+// now so that the token format never changes.
21+const (
22+ // ScopeRepoRead reads private repositories.
23+ ScopeRepoRead Scope = "repo:read"
24+ // ScopeRepoWrite pushes and creates a repository on first push.
25+ ScopeRepoWrite Scope = "repo:write"
26+ // ScopeRepoAdmin deletes a repository and changes its visibility or description.
27+ ScopeRepoAdmin Scope = "repo:admin"
28+ // ScopeMRWrite creates, comments on and closes merge requests.
29+ ScopeMRWrite Scope = "mr:write"
30+ // ScopeMRMerge merges.
31+ ScopeMRMerge Scope = "mr:merge"
32+ // ScopeCIWrite re-runs or cancels CI runs.
33+ ScopeCIWrite Scope = "ci:write"
34+ // ScopeSecretsWrite sets and deletes secrets and variables.
35+ ScopeSecretsWrite Scope = "secrets:write"
36+ // ScopeRunnerRegister registers a CI runner.
37+ ScopeRunnerRegister Scope = "runner:register"
38+ // ScopeTokenAdmin creates, lists and revokes stored tokens.
39+ ScopeTokenAdmin Scope = "token:admin"
40+)
41+
42+// AllScopes returns the nine scopes in a stable order.
43+func AllScopes() []Scope {
44+ return []Scope{
45+ ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
46+ ScopeMRWrite, ScopeMRMerge,
47+ ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
48+ ScopeTokenAdmin,
49+ }
50+}
51+
52+// ParseScope parses the text form of a scope, such as "repo:read".
53+func ParseScope(s string) (Scope, error) {
54+ sc := Scope(s)
55+ if slices.Contains(AllScopes(), sc) {
56+ return sc, nil
57+ }
58+ return "", fmt.Errorf("%w: %q", ErrInvalidScope, s)
59+}
60+
61+// String returns the scope as text.
62+func (s Scope) String() string { return string(s) }
63+
64+// TokenHash is the SHA-256 of a token secret. Secrets are never stored.
65+type TokenHash [sha256.Size]byte
66+
67+// HashTokenSecret returns the hash of a token secret.
68+func HashTokenSecret(secret string) TokenHash { return sha256.Sum256([]byte(secret)) }
69+
70+// String returns the hash as lowercase hex.
71+func (h TokenHash) String() string { return hex.EncodeToString(h[:]) }
72+
73+// Token is a stored credential: what it may do, where, and until when.
74+type Token struct {
75+ // ID identifies the token; it is not secret.
76+ ID string
77+ Label string
78+ Hash TokenHash
79+ // Scopes is never empty.
80+ Scopes []Scope
81+ // Repos limits the token to these repositories; empty means every one.
82+ Repos []RepoName
83+ // ExpiresAt is the zero time for a token that never expires.
84+ ExpiresAt time.Time
85+ CreatedAt time.Time
86+ LastUsedAt time.Time // zero until first used
87+}
88+
89+// Expired reports whether the token is no longer valid at now.
90+func (t Token) Expired(now time.Time) bool {
91+ return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt)
92+}
@@ -0,0 +1,92 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "crypto/sha256"
7+ "encoding/hex"
8+ "fmt"
9+ "slices"
10+ "time"
11+)
12+
13+// TokenSecretPrefix starts every token secret.
14+const TokenSecretPrefix = "ermite_"
15+
16+// Scope is one permission of a stored token.
17+type Scope string
18+
19+// The scopes of ADR 0006. The last three only matter after v1 and are defined
20+// now so that the token format never changes.
21+const (
22+ // ScopeRepoRead reads private repositories.
23+ ScopeRepoRead Scope = "repo:read"
24+ // ScopeRepoWrite pushes and creates a repository on first push.
25+ ScopeRepoWrite Scope = "repo:write"
26+ // ScopeRepoAdmin deletes a repository and changes its visibility or description.
27+ ScopeRepoAdmin Scope = "repo:admin"
28+ // ScopeMRWrite creates, comments on and closes merge requests.
29+ ScopeMRWrite Scope = "mr:write"
30+ // ScopeMRMerge merges.
31+ ScopeMRMerge Scope = "mr:merge"
32+ // ScopeCIWrite re-runs or cancels CI runs.
33+ ScopeCIWrite Scope = "ci:write"
34+ // ScopeSecretsWrite sets and deletes secrets and variables.
35+ ScopeSecretsWrite Scope = "secrets:write"
36+ // ScopeRunnerRegister registers a CI runner.
37+ ScopeRunnerRegister Scope = "runner:register"
38+ // ScopeTokenAdmin creates, lists and revokes stored tokens.
39+ ScopeTokenAdmin Scope = "token:admin"
40+)
41+
42+// AllScopes returns the nine scopes in a stable order.
43+func AllScopes() []Scope {
44+ return []Scope{
45+ ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
46+ ScopeMRWrite, ScopeMRMerge,
47+ ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
48+ ScopeTokenAdmin,
49+ }
50+}
51+
52+// ParseScope parses the text form of a scope, such as "repo:read".
53+func ParseScope(s string) (Scope, error) {
54+ sc := Scope(s)
55+ if slices.Contains(AllScopes(), sc) {
56+ return sc, nil
57+ }
58+ return "", fmt.Errorf("%w: %q", ErrInvalidScope, s)
59+}
60+
61+// String returns the scope as text.
62+func (s Scope) String() string { return string(s) }
63+
64+// TokenHash is the SHA-256 of a token secret. Secrets are never stored.
65+type TokenHash [sha256.Size]byte
66+
67+// HashTokenSecret returns the hash of a token secret.
68+func HashTokenSecret(secret string) TokenHash { return sha256.Sum256([]byte(secret)) }
69+
70+// String returns the hash as lowercase hex.
71+func (h TokenHash) String() string { return hex.EncodeToString(h[:]) }
72+
73+// Token is a stored credential: what it may do, where, and until when.
74+type Token struct {
75+ // ID identifies the token; it is not secret.
76+ ID string
77+ Label string
78+ Hash TokenHash
79+ // Scopes is never empty.
80+ Scopes []Scope
81+ // Repos limits the token to these repositories; empty means every one.
82+ Repos []RepoName
83+ // ExpiresAt is the zero time for a token that never expires.
84+ ExpiresAt time.Time
85+ CreatedAt time.Time
86+ LastUsedAt time.Time // zero until first used
87+}
88+
89+// Expired reports whether the token is no longer valid at now.
90+func (t Token) Expired(now time.Time) bool {
91+ return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt)
92+}
A internal/domain/token_test.go +85 -0
@@ -0,0 +1,85 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestScopes(t *testing.T) {
16+ all := domain.AllScopes()
17+ assert.Len(t, all, 9)
18+ for _, s := range all {
19+ got, err := domain.ParseScope(s.String())
20+ require.NoError(t, err)
21+ assert.Equal(t, s, got)
22+ }
23+ for _, in := range []string{"", "repo", "repo:READ", "admin", "repo:read "} {
24+ _, err := domain.ParseScope(in)
25+ assert.ErrorIs(t, err, domain.ErrInvalidScope, in)
26+ }
27+}
28+
29+func TestHashTokenSecret(t *testing.T) {
30+ h := domain.HashTokenSecret("ermite_abc")
31+ assert.Equal(t, h, domain.HashTokenSecret("ermite_abc"))
32+ assert.NotEqual(t, h, domain.HashTokenSecret("ermite_abd"))
33+ assert.Len(t, h.String(), 64)
34+ // SHA-256("") known value.
35+ assert.Equal(t, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", domain.HashTokenSecret("").String())
36+}
37+
38+func TestTokenExpired(t *testing.T) {
39+ now := time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)
40+ assert.False(t, domain.Token{}.Expired(now), "no expiry never expires")
41+ assert.False(t, domain.Token{ExpiresAt: now.Add(time.Second)}.Expired(now))
42+ assert.True(t, domain.Token{ExpiresAt: now}.Expired(now), "expires at the instant")
43+ assert.True(t, domain.Token{ExpiresAt: now.Add(-time.Second)}.Expired(now))
44+}
45+
46+func TestPrincipalScopes(t *testing.T) {
47+ a, b := mustName(t, "a"), mustName(t, "b")
48+ tok := domain.Token{ID: "t1", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, Repos: []domain.RepoName{a}}
49+ p := domain.TokenPrincipal(tok)
50+
51+ assert.Equal(t, domain.PrincipalToken, p.Kind())
52+ assert.Equal(t, "token", p.Kind().String())
53+ assert.Equal(t, "t1", p.TokenID())
54+ assert.False(t, p.IsRoot())
55+ assert.False(t, p.IsAnonymous())
56+ assert.True(t, p.HasScope(domain.ScopeRepoWrite))
57+ assert.False(t, p.HasScope(domain.ScopeTokenAdmin))
58+ assert.True(t, p.Allows(domain.ScopeRepoWrite, a))
59+ assert.False(t, p.Allows(domain.ScopeRepoWrite, b), "outside the repository list")
60+ assert.False(t, p.Allows(domain.ScopeRepoAdmin, a), "scope not held")
61+
62+ // Without a list, every repository.
63+ all := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoRead}})
64+ assert.True(t, all.Allows(domain.ScopeRepoRead, a))
65+ assert.True(t, all.Allows(domain.ScopeRepoRead, b))
66+
67+ // The principal owns copies.
68+ tok.Scopes[0] = domain.ScopeTokenAdmin
69+ assert.True(t, p.HasScope(domain.ScopeRepoRead))
70+ p.Scopes()[0] = domain.ScopeTokenAdmin
71+ assert.True(t, p.HasScope(domain.ScopeRepoRead))
72+
73+ assert.True(t, domain.Root().Allows(domain.ScopeTokenAdmin, a))
74+ assert.False(t, domain.Anonymous().HasScope(domain.ScopeRepoRead))
75+ assert.False(t, domain.Anonymous().Allows(domain.ScopeRepoRead, a))
76+ assert.Equal(t, "root", domain.Root().Kind().String())
77+ assert.Equal(t, "anonymous", domain.Anonymous().Kind().String())
78+}
79+
80+func mustName(t *testing.T, s string) domain.RepoName {
81+ t.Helper()
82+ n, err := domain.ParseRepoName(s)
83+ require.NoError(t, err)
84+ return n
85+}
@@ -0,0 +1,85 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestScopes(t *testing.T) {
16+ all := domain.AllScopes()
17+ assert.Len(t, all, 9)
18+ for _, s := range all {
19+ got, err := domain.ParseScope(s.String())
20+ require.NoError(t, err)
21+ assert.Equal(t, s, got)
22+ }
23+ for _, in := range []string{"", "repo", "repo:READ", "admin", "repo:read "} {
24+ _, err := domain.ParseScope(in)
25+ assert.ErrorIs(t, err, domain.ErrInvalidScope, in)
26+ }
27+}
28+
29+func TestHashTokenSecret(t *testing.T) {
30+ h := domain.HashTokenSecret("ermite_abc")
31+ assert.Equal(t, h, domain.HashTokenSecret("ermite_abc"))
32+ assert.NotEqual(t, h, domain.HashTokenSecret("ermite_abd"))
33+ assert.Len(t, h.String(), 64)
34+ // SHA-256("") known value.
35+ assert.Equal(t, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", domain.HashTokenSecret("").String())
36+}
37+
38+func TestTokenExpired(t *testing.T) {
39+ now := time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)
40+ assert.False(t, domain.Token{}.Expired(now), "no expiry never expires")
41+ assert.False(t, domain.Token{ExpiresAt: now.Add(time.Second)}.Expired(now))
42+ assert.True(t, domain.Token{ExpiresAt: now}.Expired(now), "expires at the instant")
43+ assert.True(t, domain.Token{ExpiresAt: now.Add(-time.Second)}.Expired(now))
44+}
45+
46+func TestPrincipalScopes(t *testing.T) {
47+ a, b := mustName(t, "a"), mustName(t, "b")
48+ tok := domain.Token{ID: "t1", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, Repos: []domain.RepoName{a}}
49+ p := domain.TokenPrincipal(tok)
50+
51+ assert.Equal(t, domain.PrincipalToken, p.Kind())
52+ assert.Equal(t, "token", p.Kind().String())
53+ assert.Equal(t, "t1", p.TokenID())
54+ assert.False(t, p.IsRoot())
55+ assert.False(t, p.IsAnonymous())
56+ assert.True(t, p.HasScope(domain.ScopeRepoWrite))
57+ assert.False(t, p.HasScope(domain.ScopeTokenAdmin))
58+ assert.True(t, p.Allows(domain.ScopeRepoWrite, a))
59+ assert.False(t, p.Allows(domain.ScopeRepoWrite, b), "outside the repository list")
60+ assert.False(t, p.Allows(domain.ScopeRepoAdmin, a), "scope not held")
61+
62+ // Without a list, every repository.
63+ all := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoRead}})
64+ assert.True(t, all.Allows(domain.ScopeRepoRead, a))
65+ assert.True(t, all.Allows(domain.ScopeRepoRead, b))
66+
67+ // The principal owns copies.
68+ tok.Scopes[0] = domain.ScopeTokenAdmin
69+ assert.True(t, p.HasScope(domain.ScopeRepoRead))
70+ p.Scopes()[0] = domain.ScopeTokenAdmin
71+ assert.True(t, p.HasScope(domain.ScopeRepoRead))
72+
73+ assert.True(t, domain.Root().Allows(domain.ScopeTokenAdmin, a))
74+ assert.False(t, domain.Anonymous().HasScope(domain.ScopeRepoRead))
75+ assert.False(t, domain.Anonymous().Allows(domain.ScopeRepoRead, a))
76+ assert.Equal(t, "root", domain.Root().Kind().String())
77+ assert.Equal(t, "anonymous", domain.Anonymous().Kind().String())
78+}
79+
80+func mustName(t *testing.T, s string) domain.RepoName {
81+ t.Helper()
82+ n, err := domain.ParseRepoName(s)
83+ require.NoError(t, err)
84+ return n
85+}
A internal/ports/mocks/TokenStore.go +354 -0
@@ -0,0 +1,354 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewTokenStore creates a new instance of TokenStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewTokenStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *TokenStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &TokenStore{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// TokenStore is an autogenerated mock type for the TokenStore type
39+type TokenStore struct {
40+ mock.Mock
41+}
42+
43+type TokenStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *TokenStore) EXPECT() *TokenStore_Expecter {
48+ return &TokenStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// Create provides a mock function for the type TokenStore
52+func (_mock *TokenStore) Create(ctx context.Context, token domain.Token) error {
53+ ret := _mock.Called(ctx, token)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Create")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Token) error); ok {
61+ r0 = returnFunc(ctx, token)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// TokenStore_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
69+type TokenStore_Create_Call struct {
70+ *mock.Call
71+}
72+
73+// Create is a helper method to define mock.On call
74+// - ctx context.Context
75+// - token domain.Token
76+func (_e *TokenStore_Expecter) Create(ctx any, token any) *TokenStore_Create_Call {
77+ return &TokenStore_Create_Call{Call: _e.mock.On("Create", ctx, token)}
78+}
79+
80+func (_c *TokenStore_Create_Call) Run(run func(ctx context.Context, token domain.Token)) *TokenStore_Create_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 domain.Token
87+ if args[1] != nil {
88+ arg1 = args[1].(domain.Token)
89+ }
90+ run(
91+ arg0,
92+ arg1,
93+ )
94+ })
95+ return _c
96+}
97+
98+func (_c *TokenStore_Create_Call) Return(err error) *TokenStore_Create_Call {
99+ _c.Call.Return(err)
100+ return _c
101+}
102+
103+func (_c *TokenStore_Create_Call) RunAndReturn(run func(ctx context.Context, token domain.Token) error) *TokenStore_Create_Call {
104+ _c.Call.Return(run)
105+ return _c
106+}
107+
108+// FindByHash provides a mock function for the type TokenStore
109+func (_mock *TokenStore) FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error) {
110+ ret := _mock.Called(ctx, hash)
111+
112+ if len(ret) == 0 {
113+ panic("no return value specified for FindByHash")
114+ }
115+
116+ var r0 domain.Token
117+ var r1 error
118+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash) (domain.Token, error)); ok {
119+ return returnFunc(ctx, hash)
120+ }
121+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash) domain.Token); ok {
122+ r0 = returnFunc(ctx, hash)
123+ } else {
124+ r0 = ret.Get(0).(domain.Token)
125+ }
126+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.TokenHash) error); ok {
127+ r1 = returnFunc(ctx, hash)
128+ } else {
129+ r1 = ret.Error(1)
130+ }
131+ return r0, r1
132+}
133+
134+// TokenStore_FindByHash_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByHash'
135+type TokenStore_FindByHash_Call struct {
136+ *mock.Call
137+}
138+
139+// FindByHash is a helper method to define mock.On call
140+// - ctx context.Context
141+// - hash domain.TokenHash
142+func (_e *TokenStore_Expecter) FindByHash(ctx any, hash any) *TokenStore_FindByHash_Call {
143+ return &TokenStore_FindByHash_Call{Call: _e.mock.On("FindByHash", ctx, hash)}
144+}
145+
146+func (_c *TokenStore_FindByHash_Call) Run(run func(ctx context.Context, hash domain.TokenHash)) *TokenStore_FindByHash_Call {
147+ _c.Call.Run(func(args mock.Arguments) {
148+ var arg0 context.Context
149+ if args[0] != nil {
150+ arg0 = args[0].(context.Context)
151+ }
152+ var arg1 domain.TokenHash
153+ if args[1] != nil {
154+ arg1 = args[1].(domain.TokenHash)
155+ }
156+ run(
157+ arg0,
158+ arg1,
159+ )
160+ })
161+ return _c
162+}
163+
164+func (_c *TokenStore_FindByHash_Call) Return(token domain.Token, err error) *TokenStore_FindByHash_Call {
165+ _c.Call.Return(token, err)
166+ return _c
167+}
168+
169+func (_c *TokenStore_FindByHash_Call) RunAndReturn(run func(ctx context.Context, hash domain.TokenHash) (domain.Token, error)) *TokenStore_FindByHash_Call {
170+ _c.Call.Return(run)
171+ return _c
172+}
173+
174+// List provides a mock function for the type TokenStore
175+func (_mock *TokenStore) List(ctx context.Context) ([]domain.Token, error) {
176+ ret := _mock.Called(ctx)
177+
178+ if len(ret) == 0 {
179+ panic("no return value specified for List")
180+ }
181+
182+ var r0 []domain.Token
183+ var r1 error
184+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.Token, error)); ok {
185+ return returnFunc(ctx)
186+ }
187+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Token); ok {
188+ r0 = returnFunc(ctx)
189+ } else {
190+ if ret.Get(0) != nil {
191+ r0 = ret.Get(0).([]domain.Token)
192+ }
193+ }
194+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
195+ r1 = returnFunc(ctx)
196+ } else {
197+ r1 = ret.Error(1)
198+ }
199+ return r0, r1
200+}
201+
202+// TokenStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
203+type TokenStore_List_Call struct {
204+ *mock.Call
205+}
206+
207+// List is a helper method to define mock.On call
208+// - ctx context.Context
209+func (_e *TokenStore_Expecter) List(ctx any) *TokenStore_List_Call {
210+ return &TokenStore_List_Call{Call: _e.mock.On("List", ctx)}
211+}
212+
213+func (_c *TokenStore_List_Call) Run(run func(ctx context.Context)) *TokenStore_List_Call {
214+ _c.Call.Run(func(args mock.Arguments) {
215+ var arg0 context.Context
216+ if args[0] != nil {
217+ arg0 = args[0].(context.Context)
218+ }
219+ run(
220+ arg0,
221+ )
222+ })
223+ return _c
224+}
225+
226+func (_c *TokenStore_List_Call) Return(tokens []domain.Token, err error) *TokenStore_List_Call {
227+ _c.Call.Return(tokens, err)
228+ return _c
229+}
230+
231+func (_c *TokenStore_List_Call) RunAndReturn(run func(ctx context.Context) ([]domain.Token, error)) *TokenStore_List_Call {
232+ _c.Call.Return(run)
233+ return _c
234+}
235+
236+// Revoke provides a mock function for the type TokenStore
237+func (_mock *TokenStore) Revoke(ctx context.Context, id string) error {
238+ ret := _mock.Called(ctx, id)
239+
240+ if len(ret) == 0 {
241+ panic("no return value specified for Revoke")
242+ }
243+
244+ var r0 error
245+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
246+ r0 = returnFunc(ctx, id)
247+ } else {
248+ r0 = ret.Error(0)
249+ }
250+ return r0
251+}
252+
253+// TokenStore_Revoke_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Revoke'
254+type TokenStore_Revoke_Call struct {
255+ *mock.Call
256+}
257+
258+// Revoke is a helper method to define mock.On call
259+// - ctx context.Context
260+// - id string
261+func (_e *TokenStore_Expecter) Revoke(ctx any, id any) *TokenStore_Revoke_Call {
262+ return &TokenStore_Revoke_Call{Call: _e.mock.On("Revoke", ctx, id)}
263+}
264+
265+func (_c *TokenStore_Revoke_Call) Run(run func(ctx context.Context, id string)) *TokenStore_Revoke_Call {
266+ _c.Call.Run(func(args mock.Arguments) {
267+ var arg0 context.Context
268+ if args[0] != nil {
269+ arg0 = args[0].(context.Context)
270+ }
271+ var arg1 string
272+ if args[1] != nil {
273+ arg1 = args[1].(string)
274+ }
275+ run(
276+ arg0,
277+ arg1,
278+ )
279+ })
280+ return _c
281+}
282+
283+func (_c *TokenStore_Revoke_Call) Return(err error) *TokenStore_Revoke_Call {
284+ _c.Call.Return(err)
285+ return _c
286+}
287+
288+func (_c *TokenStore_Revoke_Call) RunAndReturn(run func(ctx context.Context, id string) error) *TokenStore_Revoke_Call {
289+ _c.Call.Return(run)
290+ return _c
291+}
292+
293+// Touch provides a mock function for the type TokenStore
294+func (_mock *TokenStore) Touch(ctx context.Context, id string, at time.Time) error {
295+ ret := _mock.Called(ctx, id, at)
296+
297+ if len(ret) == 0 {
298+ panic("no return value specified for Touch")
299+ }
300+
301+ var r0 error
302+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) error); ok {
303+ r0 = returnFunc(ctx, id, at)
304+ } else {
305+ r0 = ret.Error(0)
306+ }
307+ return r0
308+}
309+
310+// TokenStore_Touch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Touch'
311+type TokenStore_Touch_Call struct {
312+ *mock.Call
313+}
314+
315+// Touch is a helper method to define mock.On call
316+// - ctx context.Context
317+// - id string
318+// - at time.Time
319+func (_e *TokenStore_Expecter) Touch(ctx any, id any, at any) *TokenStore_Touch_Call {
320+ return &TokenStore_Touch_Call{Call: _e.mock.On("Touch", ctx, id, at)}
321+}
322+
323+func (_c *TokenStore_Touch_Call) Run(run func(ctx context.Context, id string, at time.Time)) *TokenStore_Touch_Call {
324+ _c.Call.Run(func(args mock.Arguments) {
325+ var arg0 context.Context
326+ if args[0] != nil {
327+ arg0 = args[0].(context.Context)
328+ }
329+ var arg1 string
330+ if args[1] != nil {
331+ arg1 = args[1].(string)
332+ }
333+ var arg2 time.Time
334+ if args[2] != nil {
335+ arg2 = args[2].(time.Time)
336+ }
337+ run(
338+ arg0,
339+ arg1,
340+ arg2,
341+ )
342+ })
343+ return _c
344+}
345+
346+func (_c *TokenStore_Touch_Call) Return(err error) *TokenStore_Touch_Call {
347+ _c.Call.Return(err)
348+ return _c
349+}
350+
351+func (_c *TokenStore_Touch_Call) RunAndReturn(run func(ctx context.Context, id string, at time.Time) error) *TokenStore_Touch_Call {
352+ _c.Call.Return(run)
353+ return _c
354+}
@@ -0,0 +1,354 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewTokenStore creates a new instance of TokenStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewTokenStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *TokenStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &TokenStore{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// TokenStore is an autogenerated mock type for the TokenStore type
39+type TokenStore struct {
40+ mock.Mock
41+}
42+
43+type TokenStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *TokenStore) EXPECT() *TokenStore_Expecter {
48+ return &TokenStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// Create provides a mock function for the type TokenStore
52+func (_mock *TokenStore) Create(ctx context.Context, token domain.Token) error {
53+ ret := _mock.Called(ctx, token)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Create")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Token) error); ok {
61+ r0 = returnFunc(ctx, token)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// TokenStore_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
69+type TokenStore_Create_Call struct {
70+ *mock.Call
71+}
72+
73+// Create is a helper method to define mock.On call
74+// - ctx context.Context
75+// - token domain.Token
76+func (_e *TokenStore_Expecter) Create(ctx any, token any) *TokenStore_Create_Call {
77+ return &TokenStore_Create_Call{Call: _e.mock.On("Create", ctx, token)}
78+}
79+
80+func (_c *TokenStore_Create_Call) Run(run func(ctx context.Context, token domain.Token)) *TokenStore_Create_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 domain.Token
87+ if args[1] != nil {
88+ arg1 = args[1].(domain.Token)
89+ }
90+ run(
91+ arg0,
92+ arg1,
93+ )
94+ })
95+ return _c
96+}
97+
98+func (_c *TokenStore_Create_Call) Return(err error) *TokenStore_Create_Call {
99+ _c.Call.Return(err)
100+ return _c
101+}
102+
103+func (_c *TokenStore_Create_Call) RunAndReturn(run func(ctx context.Context, token domain.Token) error) *TokenStore_Create_Call {
104+ _c.Call.Return(run)
105+ return _c
106+}
107+
108+// FindByHash provides a mock function for the type TokenStore
109+func (_mock *TokenStore) FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error) {
110+ ret := _mock.Called(ctx, hash)
111+
112+ if len(ret) == 0 {
113+ panic("no return value specified for FindByHash")
114+ }
115+
116+ var r0 domain.Token
117+ var r1 error
118+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash) (domain.Token, error)); ok {
119+ return returnFunc(ctx, hash)
120+ }
121+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash) domain.Token); ok {
122+ r0 = returnFunc(ctx, hash)
123+ } else {
124+ r0 = ret.Get(0).(domain.Token)
125+ }
126+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.TokenHash) error); ok {
127+ r1 = returnFunc(ctx, hash)
128+ } else {
129+ r1 = ret.Error(1)
130+ }
131+ return r0, r1
132+}
133+
134+// TokenStore_FindByHash_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByHash'
135+type TokenStore_FindByHash_Call struct {
136+ *mock.Call
137+}
138+
139+// FindByHash is a helper method to define mock.On call
140+// - ctx context.Context
141+// - hash domain.TokenHash
142+func (_e *TokenStore_Expecter) FindByHash(ctx any, hash any) *TokenStore_FindByHash_Call {
143+ return &TokenStore_FindByHash_Call{Call: _e.mock.On("FindByHash", ctx, hash)}
144+}
145+
146+func (_c *TokenStore_FindByHash_Call) Run(run func(ctx context.Context, hash domain.TokenHash)) *TokenStore_FindByHash_Call {
147+ _c.Call.Run(func(args mock.Arguments) {
148+ var arg0 context.Context
149+ if args[0] != nil {
150+ arg0 = args[0].(context.Context)
151+ }
152+ var arg1 domain.TokenHash
153+ if args[1] != nil {
154+ arg1 = args[1].(domain.TokenHash)
155+ }
156+ run(
157+ arg0,
158+ arg1,
159+ )
160+ })
161+ return _c
162+}
163+
164+func (_c *TokenStore_FindByHash_Call) Return(token domain.Token, err error) *TokenStore_FindByHash_Call {
165+ _c.Call.Return(token, err)
166+ return _c
167+}
168+
169+func (_c *TokenStore_FindByHash_Call) RunAndReturn(run func(ctx context.Context, hash domain.TokenHash) (domain.Token, error)) *TokenStore_FindByHash_Call {
170+ _c.Call.Return(run)
171+ return _c
172+}
173+
174+// List provides a mock function for the type TokenStore
175+func (_mock *TokenStore) List(ctx context.Context) ([]domain.Token, error) {
176+ ret := _mock.Called(ctx)
177+
178+ if len(ret) == 0 {
179+ panic("no return value specified for List")
180+ }
181+
182+ var r0 []domain.Token
183+ var r1 error
184+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.Token, error)); ok {
185+ return returnFunc(ctx)
186+ }
187+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Token); ok {
188+ r0 = returnFunc(ctx)
189+ } else {
190+ if ret.Get(0) != nil {
191+ r0 = ret.Get(0).([]domain.Token)
192+ }
193+ }
194+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
195+ r1 = returnFunc(ctx)
196+ } else {
197+ r1 = ret.Error(1)
198+ }
199+ return r0, r1
200+}
201+
202+// TokenStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
203+type TokenStore_List_Call struct {
204+ *mock.Call
205+}
206+
207+// List is a helper method to define mock.On call
208+// - ctx context.Context
209+func (_e *TokenStore_Expecter) List(ctx any) *TokenStore_List_Call {
210+ return &TokenStore_List_Call{Call: _e.mock.On("List", ctx)}
211+}
212+
213+func (_c *TokenStore_List_Call) Run(run func(ctx context.Context)) *TokenStore_List_Call {
214+ _c.Call.Run(func(args mock.Arguments) {
215+ var arg0 context.Context
216+ if args[0] != nil {
217+ arg0 = args[0].(context.Context)
218+ }
219+ run(
220+ arg0,
221+ )
222+ })
223+ return _c
224+}
225+
226+func (_c *TokenStore_List_Call) Return(tokens []domain.Token, err error) *TokenStore_List_Call {
227+ _c.Call.Return(tokens, err)
228+ return _c
229+}
230+
231+func (_c *TokenStore_List_Call) RunAndReturn(run func(ctx context.Context) ([]domain.Token, error)) *TokenStore_List_Call {
232+ _c.Call.Return(run)
233+ return _c
234+}
235+
236+// Revoke provides a mock function for the type TokenStore
237+func (_mock *TokenStore) Revoke(ctx context.Context, id string) error {
238+ ret := _mock.Called(ctx, id)
239+
240+ if len(ret) == 0 {
241+ panic("no return value specified for Revoke")
242+ }
243+
244+ var r0 error
245+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
246+ r0 = returnFunc(ctx, id)
247+ } else {
248+ r0 = ret.Error(0)
249+ }
250+ return r0
251+}
252+
253+// TokenStore_Revoke_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Revoke'
254+type TokenStore_Revoke_Call struct {
255+ *mock.Call
256+}
257+
258+// Revoke is a helper method to define mock.On call
259+// - ctx context.Context
260+// - id string
261+func (_e *TokenStore_Expecter) Revoke(ctx any, id any) *TokenStore_Revoke_Call {
262+ return &TokenStore_Revoke_Call{Call: _e.mock.On("Revoke", ctx, id)}
263+}
264+
265+func (_c *TokenStore_Revoke_Call) Run(run func(ctx context.Context, id string)) *TokenStore_Revoke_Call {
266+ _c.Call.Run(func(args mock.Arguments) {
267+ var arg0 context.Context
268+ if args[0] != nil {
269+ arg0 = args[0].(context.Context)
270+ }
271+ var arg1 string
272+ if args[1] != nil {
273+ arg1 = args[1].(string)
274+ }
275+ run(
276+ arg0,
277+ arg1,
278+ )
279+ })
280+ return _c
281+}
282+
283+func (_c *TokenStore_Revoke_Call) Return(err error) *TokenStore_Revoke_Call {
284+ _c.Call.Return(err)
285+ return _c
286+}
287+
288+func (_c *TokenStore_Revoke_Call) RunAndReturn(run func(ctx context.Context, id string) error) *TokenStore_Revoke_Call {
289+ _c.Call.Return(run)
290+ return _c
291+}
292+
293+// Touch provides a mock function for the type TokenStore
294+func (_mock *TokenStore) Touch(ctx context.Context, id string, at time.Time) error {
295+ ret := _mock.Called(ctx, id, at)
296+
297+ if len(ret) == 0 {
298+ panic("no return value specified for Touch")
299+ }
300+
301+ var r0 error
302+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) error); ok {
303+ r0 = returnFunc(ctx, id, at)
304+ } else {
305+ r0 = ret.Error(0)
306+ }
307+ return r0
308+}
309+
310+// TokenStore_Touch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Touch'
311+type TokenStore_Touch_Call struct {
312+ *mock.Call
313+}
314+
315+// Touch is a helper method to define mock.On call
316+// - ctx context.Context
317+// - id string
318+// - at time.Time
319+func (_e *TokenStore_Expecter) Touch(ctx any, id any, at any) *TokenStore_Touch_Call {
320+ return &TokenStore_Touch_Call{Call: _e.mock.On("Touch", ctx, id, at)}
321+}
322+
323+func (_c *TokenStore_Touch_Call) Run(run func(ctx context.Context, id string, at time.Time)) *TokenStore_Touch_Call {
324+ _c.Call.Run(func(args mock.Arguments) {
325+ var arg0 context.Context
326+ if args[0] != nil {
327+ arg0 = args[0].(context.Context)
328+ }
329+ var arg1 string
330+ if args[1] != nil {
331+ arg1 = args[1].(string)
332+ }
333+ var arg2 time.Time
334+ if args[2] != nil {
335+ arg2 = args[2].(time.Time)
336+ }
337+ run(
338+ arg0,
339+ arg1,
340+ arg2,
341+ )
342+ })
343+ return _c
344+}
345+
346+func (_c *TokenStore_Touch_Call) Return(err error) *TokenStore_Touch_Call {
347+ _c.Call.Return(err)
348+ return _c
349+}
350+
351+func (_c *TokenStore_Touch_Call) RunAndReturn(run func(ctx context.Context, id string, at time.Time) error) *TokenStore_Touch_Call {
352+ _c.Call.Return(run)
353+ return _c
354+}
M internal/ports/ports.go +19 -0
@@ -62,6 +62,25 @@ type GitRepos interface {
6262 Trash(ctx context.Context, name domain.RepoName) error
6363 }
6464
65+// TokenStore keeps the stored tokens and the index from secret hash to token.
66+// The secret itself is never stored.
67+type TokenStore interface {
68+ // Create stores the token and its hash index in one write. It returns
69+ // domain.ErrAlreadyExists if the id or the hash is taken.
70+ Create(ctx context.Context, token domain.Token) error
71+ // FindByHash returns the token whose secret has that hash, or
72+ // domain.ErrNotFound.
73+ FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error)
74+ // List returns every token, oldest first.
75+ List(ctx context.Context) ([]domain.Token, error)
76+ // Revoke removes the token and its hash index in one write, or returns
77+ // domain.ErrNotFound.
78+ Revoke(ctx context.Context, id string) error
79+ // Touch records that the token was used at the given time. It returns
80+ // domain.ErrNotFound if the token is gone.
81+ Touch(ctx context.Context, id string, at time.Time) error
82+}
83+
6584 // Clock tells the time.
6685 type Clock interface {
6786 Now() time.Time
@@ -62,6 +62,25 @@ type GitRepos interface {
62 Trash(ctx context.Context, name domain.RepoName) error62 Trash(ctx context.Context, name domain.RepoName) error
63 }63 }
64 64
65+// TokenStore keeps the stored tokens and the index from secret hash to token.
66+// The secret itself is never stored.
67+type TokenStore interface {
68+ // Create stores the token and its hash index in one write. It returns
69+ // domain.ErrAlreadyExists if the id or the hash is taken.
70+ Create(ctx context.Context, token domain.Token) error
71+ // FindByHash returns the token whose secret has that hash, or
72+ // domain.ErrNotFound.
73+ FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error)
74+ // List returns every token, oldest first.
75+ List(ctx context.Context) ([]domain.Token, error)
76+ // Revoke removes the token and its hash index in one write, or returns
77+ // domain.ErrNotFound.
78+ Revoke(ctx context.Context, id string) error
79+ // Touch records that the token was used at the given time. It returns
80+ // domain.ErrNotFound if the token is gone.
81+ Touch(ctx context.Context, id string, at time.Time) error
82+}
83+
65 // Clock tells the time.84 // Clock tells the time.
66 type Clock interface {85 type Clock interface {
67 Now() time.Time86 Now() time.Time
M proto/ermite/storage/repo.proto +18 -0
@@ -32,3 +32,21 @@ message Repo {
3232 message SchemaVersion {
3333 uint32 version = 1;
3434 }
35+
36+// Token is stored under the key "token/<id>"; the key "tokenhash/<hex>" maps
37+// the SHA-256 of the secret back to the id.
38+message Token {
39+ string id = 1;
40+ string label = 2;
41+ // SHA-256 of the secret, 32 bytes.
42+ bytes hash = 3;
43+ // Scopes in their text form, such as "repo:read". Never empty.
44+ repeated string scopes = 4;
45+ // Repository names the token is limited to; empty means every repository.
46+ repeated string repos = 5;
47+ // Unset for a token that never expires.
48+ google.protobuf.Timestamp expires_at = 6;
49+ google.protobuf.Timestamp created_at = 7;
50+ // Unset until the token is first used.
51+ google.protobuf.Timestamp last_used_at = 8;
52+}
@@ -32,3 +32,21 @@ message Repo {
32 message SchemaVersion {32 message SchemaVersion {
33 uint32 version = 1;33 uint32 version = 1;
34 }34 }
35+
36+// Token is stored under the key "token/<id>"; the key "tokenhash/<hex>" maps
37+// the SHA-256 of the secret back to the id.
38+message Token {
39+ string id = 1;
40+ string label = 2;
41+ // SHA-256 of the secret, 32 bytes.
42+ bytes hash = 3;
43+ // Scopes in their text form, such as "repo:read". Never empty.
44+ repeated string scopes = 4;
45+ // Repository names the token is limited to; empty means every repository.
46+ repeated string repos = 5;
47+ // Unset for a token that never expires.
48+ google.protobuf.Timestamp expires_at = 6;
49+ google.protobuf.Timestamp created_at = 7;
50+ // Unset until the token is first used.
51+ google.protobuf.Timestamp last_used_at = 8;
52+}

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 42

↑↓ moveenter openesc close