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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +1202 -14
M .mockery.yml +1 -0
| @@ -11,3 +11,4 @@ packages: | |||
| 11 | 11 | RepoMetaStore: | |
| 12 | 12 | GitRepos: | |
| 13 | 13 | 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 { | |||
| 200 | 200 | 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 | 310 | var File_ermite_storage_repo_proto protoreflect.FileDescriptor | |
| 204 | 311 | ||
| 205 | 312 | const file_ermite_storage_repo_proto_rawDesc = "" + | |
| @@ -215,7 +322,19 @@ const file_ermite_storage_repo_proto_rawDesc = "" + | |||
| 215 | 322 | "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x127\n" + | |
| 216 | 323 | "\tpushed_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\bpushedAt\")\n" + | |
| 217 | 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 | 338 | "\n" + | |
| 220 | 339 | "Visibility\x12\x1a\n" + | |
| 221 | 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 | 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 | 358 | var file_ermite_storage_repo_proto_goTypes = []any{ | |
| 240 | 359 | (Visibility)(0), // 0: ermite.storage.Visibility | |
| 241 | 360 | (*Repo)(nil), // 1: ermite.storage.Repo | |
| 242 | 361 | (*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 | |
| 244 | 364 | } | |
| 245 | 365 | var file_ermite_storage_repo_proto_depIdxs = []int32{ | |
| 246 | 366 | 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 | |
| 254 | 377 | } | |
| 255 | 378 | ||
| 256 | 379 | func init() { file_ermite_storage_repo_proto_init() } | |
| @@ -264,7 +387,7 @@ func file_ermite_storage_repo_proto_init() { | |||
| 264 | 387 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |
| 265 | 388 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_repo_proto_rawDesc), len(file_ermite_storage_repo_proto_rawDesc)), | |
| 266 | 389 | NumEnums: 1, | |
| 267 | - | NumMessages: 2, | |
| 390 | + | NumMessages: 3, | |
| 268 | 391 | NumExtensions: 0, | |
| 269 | 392 | NumServices: 0, | |
| 270 | 393 | }, | |
| @@ -200,6 +200,113 @@ func (x *SchemaVersion) GetVersion() uint32 { | |||||
| 200 | return 0 | 200 | 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.FileDescriptor | 310 | 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.Visibility | 359 | (Visibility)(0), // 0: ermite.storage.Visibility | ||
| 241 | (*Repo)(nil), // 1: ermite.storage.Repo | 360 | (*Repo)(nil), // 1: ermite.storage.Repo | ||
| 242 | (*SchemaVersion)(nil), // 2: ermite.storage.SchemaVersion | 361 | (*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 | |||
| 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.Visibility | 366 | 0, // 0: ermite.storage.Repo.visibility:type_name -> ermite.storage.Visibility | ||
| 247 | - | 3, // 1: ermite.storage.Repo.created_at:type_name -> google.protobuf.Timestamp | 367 | + | 4, // 1: ermite.storage.Repo.created_at:type_name -> google.protobuf.Timestamp |
| 248 | - | 3, // 2: ermite.storage.Repo.pushed_at:type_name -> google.protobuf.Timestamp | 368 | + | 4, // 2: ermite.storage.Repo.pushed_at:type_name -> google.protobuf.Timestamp |
| 249 | - | 3, // [3:3] is the sub-list for method output_type | 369 | + | 4, // 3: ermite.storage.Token.expires_at:type_name -> google.protobuf.Timestamp |
| 250 | - | 3, // [3:3] is the sub-list for method input_type | 370 | + | 4, // 4: ermite.storage.Token.created_at:type_name -> google.protobuf.Timestamp |
| 251 | - | 3, // [3:3] is the sub-list for extension type_name | 371 | + | 4, // 5: ermite.storage.Token.last_used_at:type_name -> google.protobuf.Timestamp |
| 252 | - | 3, // [3:3] is the sub-list for extension extendee | 372 | + | 6, // [6:6] is the sub-list for method output_type |
| 253 | - | 0, // [0:3] is the sub-list for field type_name | 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 | |||
| 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 ( | |||
| 12 | 12 | ErrInvalidName = errors.New("invalid repository name") | |
| 13 | 13 | // ErrInvalidVisibility means a visibility value is neither public nor private. | |
| 14 | 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 | 19 | // ErrAlreadyExists means the entity to create already exists. | |
| 16 | 20 | ErrAlreadyExists = errors.New("already exists") | |
| 17 | 21 | // ErrUnauthenticated means the operation needs credentials the caller lacks. | |
| 18 | 22 | ErrUnauthenticated = errors.New("unauthenticated") | |
| 23 | + | // ErrForbidden means the caller is known but may not do this. | |
| 24 | + | ErrForbidden = errors.New("forbidden") | |
| 19 | 25 | // ErrConflict means a concurrent change prevented the operation. | |
| 20 | 26 | ErrConflict = errors.New("conflict") | |
| 21 | 27 | ) | |
| @@ -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 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package domain | |
| 4 | 4 | ||
| 5 | + | import ( | |
| 6 | + | "slices" | |
| 7 | + | "time" | |
| 8 | + | ) | |
| 9 | + | ||
| 5 | 10 | // PrincipalKind tells who a caller is. | |
| 6 | 11 | type PrincipalKind uint8 | |
| 7 | 12 | ||
| @@ -11,11 +16,30 @@ const ( | |||
| 11 | 16 | PrincipalAnonymous PrincipalKind = iota | |
| 12 | 17 | // PrincipalRoot is the holder of the admin token. | |
| 13 | 18 | 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 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 | + | } | |
| 19 | 43 | ||
| 20 | 44 | // Anonymous returns the principal of a caller without credentials. | |
| 21 | 45 | func Anonymous() Principal { return Principal{kind: PrincipalAnonymous} } | |
| @@ -23,6 +47,17 @@ func Anonymous() Principal { return Principal{kind: PrincipalAnonymous} } | |||
| 23 | 47 | // Root returns the principal of the admin token holder. | |
| 24 | 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 | 61 | // Kind returns the kind of principal. | |
| 27 | 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 | 67 | // IsAnonymous reports whether p has no credentials. | |
| 33 | 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 | + | } | |
| @@ -2,6 +2,11 @@ | |||||
| 2 | 2 | ||||
| 3 | package domain | 3 | 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 uint8 | 11 | type PrincipalKind uint8 | ||
| 7 | 12 | ||||
| @@ -11,11 +16,30 @@ const ( | |||||
| 11 | PrincipalAnonymous PrincipalKind = iota | 16 | PrincipalAnonymous PrincipalKind = iota | ||
| 12 | // PrincipalRoot is the holder of the admin token. | 17 | // PrincipalRoot is the holder of the admin token. | ||
| 13 | PrincipalRoot | 18 | 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 add | 23 | + | // 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 { | |||
| 62 | 62 | 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 | 84 | // Clock tells the time. | |
| 66 | 85 | type Clock interface { | |
| 67 | 86 | Now() time.Time | |
| @@ -62,6 +62,25 @@ type GitRepos interface { | |||||
| 62 | Trash(ctx context.Context, name domain.RepoName) error | 62 | 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.Time | 86 | Now() time.Time | ||
M proto/ermite/storage/repo.proto +18 -0
| @@ -32,3 +32,21 @@ message Repo { | |||
| 32 | 32 | message SchemaVersion { | |
| 33 | 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 | + | } | |
| @@ -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 | + | } | |||