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.
156func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {
157 if !validTokenID(id) {
158 return domain.ErrNotFound
159 }
160 for range updateAttempts {
161 item, ok := s.db.Get(ctx, tokenKey(id))
162 if !ok {
163 return domain.ErrNotFound
164 }
165 t, err := decodeToken(item.Value)
166 if err != nil {
167 return err
168 }
169 if !at.After(t.LastUsedAt) {
170 return nil
171 }
172 t.LastUsedAt = at
173 val, err := encodeToken(t)
174 if err != nil {
175 return err
176 }
177 var b kv.Batch
178 b.RequireVersion(tokenKey(id), item.Version)
179 b.Put(tokenKey(id), val)
180 _, err = s.db.Apply(ctx, &b)
181 if errors.Is(err, kv.ErrConflict) {
182 continue
183 }
184 if err != nil {
185 return fmt.Errorf("touch token %q: %w", id, err)
186 }
187 return nil
188 }
189 return fmt.Errorf("touch token %q: %w", id, domain.ErrConflict)
190}
191
192// encodeToken refuses what the use case already refused (scopes, branch
193// prefixes): a malformed prefix must never reach the store, where it would
194// limit a token wrongly or not at all.
195func encodeToken(t domain.Token) ([]byte, error) {
196 if !validTokenID(t.ID) {
197 return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID)
198 }
199 if !utf8.ValidString(t.Label) {
200 return nil, fmt.Errorf("%w: label is not valid UTF-8", domain.ErrInvalidToken)
201 }
202 if len(t.Scopes) == 0 {
203 return nil, fmt.Errorf("%w: no scopes", domain.ErrInvalidToken)
204 }
205 pb := &storagepb.Token{
206 Id: t.ID,
207 Label: t.Label,
208 Hash: t.Hash[:],
209 ExpiresAt: encodeTime(t.ExpiresAt),
210 CreatedAt: encodeTime(t.CreatedAt),
211 LastUsedAt: encodeTime(t.LastUsedAt),
212 }
213 for _, sc := range t.Scopes {
214 if _, err := domain.ParseScope(sc.String()); err != nil {
215 return nil, err
216 }
217 pb.Scopes = append(pb.Scopes, sc.String())
218 }
219 for _, r := range t.Repos {
220 if r.IsZero() {
221 return nil, fmt.Errorf("%w: empty repository name", domain.ErrInvalidName)
222 }
223 pb.Repos = append(pb.Repos, r.String())
224 }
225 for _, bp := range t.BranchPrefixes {
226 if _, err := domain.ParseBranchPrefix(bp); err != nil {
227 return nil, err
228 }
229 pb.BranchPrefixes = append(pb.BranchPrefixes, bp)
230 }
231 return mustMarshal(pb), nil
232}
233
234// decodeToken drops scopes it does not know (written by a newer ermite): a
235// token never gains rights by being read by an older build.
236func decodeToken(val []byte) (domain.Token, error) {
237 var pb storagepb.Token
238 if err := proto.Unmarshal(val, &pb); err != nil {
239 return domain.Token{}, fmt.Errorf("decode token: %w", err)
240 }
241 t := domain.Token{
242 ID: pb.GetId(),
243 Label: pb.GetLabel(),
244 ExpiresAt: decodeTime(pb.GetExpiresAt()),
245 CreatedAt: decodeTime(pb.GetCreatedAt()),
246 LastUsedAt: decodeTime(pb.GetLastUsedAt()),
247 }
248 if len(pb.GetHash()) != len(t.Hash) {
249 return domain.Token{}, fmt.Errorf("decode token %q: hash has %d bytes", t.ID, len(pb.GetHash()))
250 }
251 copy(t.Hash[:], pb.GetHash())
252 for _, s := range pb.GetScopes() {
253 if sc, err := domain.ParseScope(s); err == nil {
254 t.Scopes = append(t.Scopes, sc)
255 }
256 }
257 for _, r := range pb.GetRepos() {
258 name, err := domain.ParseRepoName(r)
259 if err != nil {
260 return domain.Token{}, fmt.Errorf("decode token %q: %w", t.ID, err)
261 }
262 t.Repos = append(t.Repos, name)
263 }
264 // Kept as stored: a token with prefixes stays limited, whatever they say.
265 t.BranchPrefixes = slices.Clone(pb.GetBranchPrefixes())
266 return t, nil
267}