1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore
4
5import (
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
22const (
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.
33type Tokens struct {
34 db *kv.DB
35}
36
37var _ ports.TokenStore = (*Tokens)(nil)
38
39// Tokens returns the token store over the same database. It is valid until
40// the Store is closed.
41func (s *Store) Tokens() *Tokens { return &Tokens{db: s.db} }
42
43func tokenKey(id string) []byte { return []byte(tokenPrefix + id) }
44func tokenHashKey(h domain.TokenHash) []byte { return []byte(tokenHashPrefix + h.String()) }
45
46// validTokenID reports whether id is usable inside a key.
47func 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.
53func (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// Get reads token/<id>.
73func (s *Tokens) Get(ctx context.Context, id string) (domain.Token, error) {
74 if !validTokenID(id) {
75 return domain.Token{}, domain.ErrNotFound
76 }
77 item, ok := s.db.Get(ctx, tokenKey(id))
78 if !ok {
79 return domain.Token{}, domain.ErrNotFound
80 }
81 return decodeToken(item.Value)
82}
83
84// FindByHash resolves the hash index, then reads the token.
85func (s *Tokens) FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error) {
86 snap := s.db.Snapshot(ctx) // one snapshot: the index and the token agree
87 idx, ok := snap.Get(ctx, tokenHashKey(hash))
88 if !ok {
89 return domain.Token{}, domain.ErrNotFound
90 }
91 item, ok := snap.Get(ctx, tokenKey(string(idx.Value)))
92 if !ok {
93 return domain.Token{}, domain.ErrNotFound
94 }
95 t, err := decodeToken(item.Value)
96 if err != nil {
97 return domain.Token{}, err
98 }
99 if t.Hash != hash {
100 return domain.Token{}, fmt.Errorf("token %q: hash index does not match the stored hash", t.ID)
101 }
102 return t, nil
103}
104
105// List returns every token ordered by creation time, then id.
106func (s *Tokens) List(ctx context.Context) ([]domain.Token, error) {
107 var out []domain.Token
108 for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
109 t, err := decodeToken(item.Value)
110 if err != nil {
111 return nil, fmt.Errorf("decode %s: %w", key, err)
112 }
113 out = append(out, t)
114 }
115 slices.SortStableFunc(out, func(a, b domain.Token) int {
116 if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 {
117 return c
118 }
119 return strings.Compare(a.ID, b.ID)
120 })
121 return out, nil
122}
123
124// Revoke deletes token/<id> and its hash index in one batch.
125func (s *Tokens) Revoke(ctx context.Context, id string) error {
126 if !validTokenID(id) {
127 return domain.ErrNotFound
128 }
129 for range updateAttempts {
130 item, ok := s.db.Get(ctx, tokenKey(id))
131 if !ok {
132 return domain.ErrNotFound
133 }
134 t, err := decodeToken(item.Value)
135 if err != nil {
136 return err
137 }
138 var b kv.Batch
139 b.RequireVersion(tokenKey(id), item.Version)
140 b.Delete(tokenKey(id))
141 b.Delete(tokenHashKey(t.Hash))
142 _, err = s.db.Apply(ctx, &b)
143 if errors.Is(err, kv.ErrConflict) {
144 continue // touched or revoked meanwhile: read again
145 }
146 if err != nil {
147 return fmt.Errorf("revoke token %q: %w", id, err)
148 }
149 return nil
150 }
151 return fmt.Errorf("revoke token %q: %w", id, domain.ErrConflict)
152}
153
154// Touch sets last_used_at when the given time is later than the stored one,
155// so that racing writers never move it back.
156//
157//hexcheck:ignore suspicious-business-logic-in-adapter monotonic write guard, not a policy
158func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {
159 if !validTokenID(id) {
160 return domain.ErrNotFound
161 }
162 for range updateAttempts {
163 item, ok := s.db.Get(ctx, tokenKey(id))
164 if !ok {
165 return domain.ErrNotFound
166 }
167 t, err := decodeToken(item.Value)
168 if err != nil {
169 return err
170 }
171 if !at.After(t.LastUsedAt) {
172 return nil
173 }
174 t.LastUsedAt = at
175 val, err := encodeToken(t)
176 if err != nil {
177 return err
178 }
179 var b kv.Batch
180 b.RequireVersion(tokenKey(id), item.Version)
181 b.Put(tokenKey(id), val)
182 _, err = s.db.Apply(ctx, &b)
183 if errors.Is(err, kv.ErrConflict) {
184 continue
185 }
186 if err != nil {
187 return fmt.Errorf("touch token %q: %w", id, err)
188 }
189 return nil
190 }
191 return fmt.Errorf("touch token %q: %w", id, domain.ErrConflict)
192}
193
194// encodeToken refuses what the use case already refused (scopes, branch
195// prefixes): a malformed prefix must never reach the store, where it would
196// limit a token wrongly or not at all.
197//
198//hexcheck:ignore suspicious-business-logic-in-adapter schema encoding: it re-checks the syntax the use case enforced, it decides no policy
199func encodeToken(t domain.Token) ([]byte, error) {
200 if !validTokenID(t.ID) {
201 return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID)
202 }
203 if !utf8.ValidString(t.Label) {
204 return nil, fmt.Errorf("%w: label is not valid UTF-8", domain.ErrInvalidToken)
205 }
206 if len(t.Scopes) == 0 {
207 return nil, fmt.Errorf("%w: no scopes", domain.ErrInvalidToken)
208 }
209 pb := &storagepb.Token{
210 Id: t.ID,
211 Label: t.Label,
212 Hash: t.Hash[:],
213 ExpiresAt: encodeTime(t.ExpiresAt),
214 CreatedAt: encodeTime(t.CreatedAt),
215 LastUsedAt: encodeTime(t.LastUsedAt),
216 }
217 for _, sc := range t.Scopes {
218 if _, err := domain.ParseScope(sc.String()); err != nil {
219 return nil, err
220 }
221 pb.Scopes = append(pb.Scopes, sc.String())
222 }
223 for _, r := range t.Repos {
224 if r.IsZero() {
225 return nil, fmt.Errorf("%w: empty repository name", domain.ErrInvalidName)
226 }
227 pb.Repos = append(pb.Repos, r.String())
228 }
229 for _, bp := range t.BranchPrefixes {
230 if _, err := domain.ParseBranchPrefix(bp); err != nil {
231 return nil, err
232 }
233 pb.BranchPrefixes = append(pb.BranchPrefixes, bp)
234 }
235 return mustMarshal(pb), nil
236}
237
238// decodeToken drops scopes it does not know (written by a newer ermite): a
239// token never gains rights by being read by an older build.
240//
241//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
242func decodeToken(val []byte) (domain.Token, error) {
243 var pb storagepb.Token
244 if err := proto.Unmarshal(val, &pb); err != nil {
245 return domain.Token{}, fmt.Errorf("decode token: %w", err)
246 }
247 t := domain.Token{
248 ID: pb.GetId(),
249 Label: pb.GetLabel(),
250 ExpiresAt: decodeTime(pb.GetExpiresAt()),
251 CreatedAt: decodeTime(pb.GetCreatedAt()),
252 LastUsedAt: decodeTime(pb.GetLastUsedAt()),
253 }
254 if len(pb.GetHash()) != len(t.Hash) {
255 return domain.Token{}, fmt.Errorf("decode token %q: hash has %d bytes", t.ID, len(pb.GetHash()))
256 }
257 copy(t.Hash[:], pb.GetHash())
258 for _, s := range pb.GetScopes() {
259 if sc, err := domain.ParseScope(s); err == nil {
260 t.Scopes = append(t.Scopes, sc)
261 }
262 }
263 for _, r := range pb.GetRepos() {
264 name, err := domain.ParseRepoName(r)
265 if err != nil {
266 return domain.Token{}, fmt.Errorf("decode token %q: %w", t.ID, err)
267 }
268 t.Repos = append(t.Repos, name)
269 }
270 // Kept as stored: a token with prefixes stays limited, whatever they say.
271 t.BranchPrefixes = slices.Clone(pb.GetBranchPrefixes())
272 return t, nil
273}