oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package roster
2
3import (
4 "context"
5 "crypto/rand"
6 "encoding/base64"
7 "io"
8 "net/http"
9 "net/url"
10 "sync"
11 "time"
12
13 "amadan.net/oficina/home/handoff"
14 "github.com/go-json-experiment/json"
15)
16
17const (
18 RefreshAfter = 30 * time.Second
19 MaximumAge = 60 * time.Second
20 FetchTimeout = 5 * time.Second
21 RetryAfter = 5 * time.Second
22 // A backward wall-clock discrepancy beyond two seconds fences standing.
23 // Comparing wall advancement to monotonic advancement tolerates ordinary slew.
24 ClockTolerance = 2 * time.Second
25 recordLimit = 1024
26)
27
28// Clock returns wall time and monotonic elapsed time from one observation. The
29// elapsed value must never go backwards. Nil Config.Clock uses Go's monotonic clock.
30type Clock interface {
31 Now() (time.Time, time.Duration)
32}
33type realClock struct{ start time.Time }
34
35func (c realClock) Now() (time.Time, time.Duration) { n := time.Now(); return n, n.Sub(c.start) }
36
37// Durable stores one opaque envelope. Save must atomically replace it, retaining
38// the previous value on error. Load reports ErrNoEvidence only for absent data.
39// Its implementation must honor context cancellation and use bounded local IO.
40type Durable interface {
41 Load(context.Context) ([]byte, error)
42 Save(context.Context, []byte) error
43}
44
45type Config struct {
46 Record VerifiedRecord
47 Token string
48 Transport http.RoundTripper
49 Durable Durable
50 Sealer handoff.Sealer
51 Clock Clock
52}
53
54type State uint8
55
56const (
57 Unknown State = iota
58 NotMember
59 Member
60)
61
62type Decision struct {
63 State State
64 Role string
65 Lease Lease
66}
67
68// Lease is an observation, not a stored authorization grant. Call Valid at the
69// protected operation boundary; stream users must recheck and re-arm their timer
70// from a new decision after renewal, or close when renewal cannot authorize it.
71// Both accessors refuse immediately on cache contention: a caller may hold the
72// database writer that an in-flight durable publication needs to acquire.
73type Lease struct {
74 cache *Cache
75 version uint64
76 deadline time.Duration
77}
78
79func (l Lease) Valid() bool {
80 if l.cache == nil {
81 return false
82 }
83 c := l.cache
84 if !c.mu.TryLock() {
85 return false
86 }
87 defer c.mu.Unlock()
88 _, n := c.observe()
89 return c.version == l.version && c.usable && n < l.deadline
90}
91func (l Lease) Remaining() time.Duration {
92 if l.cache == nil {
93 return 0
94 }
95 c := l.cache
96 if !c.mu.TryLock() {
97 return 0
98 }
99 defer c.mu.Unlock()
100 _, n := c.observe()
101 if c.version != l.version || !c.usable || n >= l.deadline {
102 return 0
103 }
104 return l.deadline - n
105}
106
107type receipt struct {
108 Operation string `json:"operation"`
109 Digest string `json:"digest"`
110}
111type evidence struct {
112 Snapshot bool `json:"snapshot"`
113 Blocked bool `json:"blocked"`
114 Version int `json:"version"`
115 Sequence int64 `json:"sequence"`
116 RecordDigest string `json:"record_digest"`
117 Generation string `json:"generation"`
118 Epoch int64 `json:"epoch"`
119 Members []MemberEntry `json:"members"`
120 Digest string `json:"digest"`
121 Receipts []receipt `json:"receipts"`
122}
123
124type flight struct {
125 done chan struct{}
126 err error
127 cancel context.CancelFunc
128}
129type Cache struct {
130 mu sync.Mutex
131 record VerifiedRecord
132 token string
133 client *http.Client
134 durable Durable
135 sealer handoff.Sealer
136 purpose string
137 clock Clock
138 seen bool
139 wall time.Time
140 mono time.Duration
141 version uint64
142 usable bool
143 start time.Duration
144 members map[string]string
145 evidence evidence
146 flight *flight
147 attempted bool
148 lastAttempt time.Duration
149}
150
151func New(ctx context.Context, cfg Config) (*Cache, error) {
152 if !cfg.Record.valid || !ValidChallenge(cfg.Token) || cfg.Durable == nil || cfg.Sealer == nil {
153 return nil, ErrRefused
154 }
155 if cfg.Clock == nil {
156 cfg.Clock = realClock{time.Now()}
157 }
158 if cfg.Transport == nil {
159 tr := http.DefaultTransport.(*http.Transport).Clone()
160 tr.DisableCompression = true
161 cfg.Transport = tr
162 }
163 b, _ := json.Marshal(cfg.Record.record.Bindings)
164 c := &Cache{record: cfg.Record, token: cfg.Token, client: &http.Client{Transport: cfg.Transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, durable: cfg.Durable, sealer: cfg.Sealer, purpose: "oficina/roster/evidence/v1/" + hash(b), clock: cfg.Clock, version: 1}
165 raw, e := cfg.Durable.Load(ctx)
166 if e != nil && e != ErrNoEvidence {
167 return nil, ErrUnavailable
168 }
169 if e == nil {
170 if len(raw) > EvidenceLimit+128 {
171 return nil, ErrRefused
172 }
173 plain, err := cfg.Sealer.Open(c.purpose, raw)
174 if err != nil || len(plain) > EvidenceLimit || strict(plain, &c.evidence) != nil || !validEvidence(c.evidence) {
175 return nil, ErrRefused
176 }
177 }
178 ev, e := nextEvidence(c.evidence, cfg.Record)
179 if e != nil {
180 return nil, e
181 }
182 if e = c.save(ctx, ev); e != nil {
183 return nil, e
184 }
185 c.evidence = ev
186 return c, nil
187}
188func validEvidence(e evidence) bool {
189 if e.Version != 1 || e.Sequence < 1 || e.Epoch < 1 || !ValidChallenge(e.RecordDigest) || !ValidChallenge(e.Generation) || len(e.Receipts) == 0 || len(e.Receipts) > recordLimit || len(e.Members) > MemberLimit {
190 return false
191 }
192 seen := map[string]bool{}
193 for _, r := range e.Receipts {
194 if !atom(r.Operation, 128) || !ValidChallenge(r.Digest) || seen[r.Operation] {
195 return false
196 }
197 seen[r.Operation] = true
198 }
199 for i, m := range e.Members {
200 if !validMember(m) || (i > 0 && e.Members[i-1].Subject >= m.Subject) {
201 return false
202 }
203 }
204 return e.Digest == memberDigest(e.Members)
205}
206func memberDigest(m []MemberEntry) string {
207 if m == nil {
208 m = []MemberEntry{}
209 }
210 b, _ := json.Marshal(m)
211 return hash(b)
212}
213func nextEvidence(old evidence, r VerifiedRecord) (evidence, error) {
214 p := r.record
215 if old.Version != 0 {
216 if p.Sequence < old.Sequence {
217 return evidence{}, ErrRefused
218 }
219 if p.Sequence == old.Sequence {
220 if r.digest != old.RecordDigest {
221 return evidence{}, ErrRefused
222 }
223 return old, nil
224 }
225 if p.Generation != old.Generation && p.OwnerConfirmation == "" {
226 return evidence{}, ErrRefused
227 }
228 }
229 for _, a := range old.Receipts {
230 if a.Operation == p.Operation {
231 return evidence{}, ErrRefused
232 }
233 }
234 if len(old.Receipts) >= recordLimit {
235 return evidence{}, ErrRefused
236 }
237 receipts := append(append([]receipt(nil), old.Receipts...), receipt{p.Operation, r.digest})
238 ev := evidence{Version: 1, Sequence: p.Sequence, RecordDigest: r.digest, Generation: p.Generation, Epoch: p.MinEpoch, Members: []MemberEntry{}, Digest: memberDigest(nil), Receipts: receipts}
239 if old.Generation == p.Generation && old.Epoch >= p.MinEpoch {
240 ev.Epoch = old.Epoch
241 ev.Members = old.Members
242 ev.Digest = old.Digest
243 ev.Snapshot = old.Snapshot
244 }
245 if old.Generation == p.Generation {
246 ev.Blocked = old.Blocked
247 }
248 return ev, nil
249}
250func (c *Cache) save(ctx context.Context, e evidence) error {
251 raw, err := json.Marshal(e)
252 if err != nil || len(raw) > EvidenceLimit {
253 return ErrUnavailable
254 }
255 ct, err := c.sealer.Seal(c.purpose, raw)
256 if err != nil {
257 return ErrUnavailable
258 }
259 if err = c.durable.Save(ctx, ct); err != nil {
260 return ErrUnavailable
261 }
262 return nil
263}
264
265// UpdateRecord accepts only independently operator-verified records for the same
266// bindings. Durable failure changes neither the current floor nor usable lease.
267func (c *Cache) UpdateRecord(ctx context.Context, r VerifiedRecord) error {
268 c.mu.Lock()
269 defer c.mu.Unlock()
270 if !r.valid || r.record.Bindings != c.record.record.Bindings || r.record.Sequence <= c.record.record.Sequence {
271 return ErrRefused
272 }
273 ev, e := nextEvidence(c.evidence, r)
274 if e != nil {
275 return e
276 }
277 if e = c.save(ctx, ev); e != nil {
278 return e
279 }
280 c.record = r
281 c.evidence = ev
282 c.fence()
283 c.attempted = false
284 return nil
285}
286func (c *Cache) fence() {
287 c.usable = false
288 c.version++
289 if c.flight != nil {
290 c.flight.cancel()
291 }
292}
293func (c *Cache) observe() (time.Time, time.Duration) {
294 w, m := c.clock.Now()
295 if c.seen && (m < c.mono || w.Round(0).Sub(c.wall.Round(0)).Nanoseconds()-(m-c.mono).Nanoseconds() < -ClockTolerance.Nanoseconds()) {
296 c.fence()
297 }
298 c.wall = w
299 c.mono = m
300 c.seen = true
301 return w, m
302}
303
304func (c *Cache) Check(ctx context.Context, issuer, teamID, subject string) Decision {
305 c.mu.Lock()
306 b := c.record.record.Bindings
307 if issuer != b.Issuer || teamID != b.Team || !validMember(MemberEntry{subject, "member"}) {
308 c.mu.Unlock()
309 return Decision{State: Unknown}
310 }
311 _, now := c.observe()
312 usable := c.usable && now < c.start+MaximumAge
313 due := !c.usable || now >= c.start+RefreshAfter
314 var f *flight
315 if due {
316 f = c.startRefresh(now)
317 }
318 if usable {
319 d := c.decision(subject)
320 c.mu.Unlock()
321 return d
322 }
323 c.mu.Unlock()
324 if f != nil {
325 select {
326 case <-ctx.Done():
327 return Decision{State: Unknown}
328 case <-f.done:
329 }
330 }
331 c.mu.Lock()
332 defer c.mu.Unlock()
333 _, now = c.observe()
334 if !c.usable || now >= c.start+MaximumAge {
335 return Decision{State: Unknown}
336 }
337 return c.decision(subject)
338}
339func (c *Cache) decision(subject string) Decision {
340 role, ok := c.members[subject]
341 if !ok {
342 return Decision{State: NotMember}
343 }
344 return Decision{State: Member, Role: role, Lease: Lease{c, c.version, c.start + MaximumAge}}
345}
346
347// Refresh joins the same bounded flight as Check. Caller cancellation stops
348// waiting, not another request's shared refresh. Backoff never changes lease age.
349func (c *Cache) Refresh(ctx context.Context) error {
350 c.mu.Lock()
351 _, now := c.observe()
352 f := c.startRefresh(now)
353 c.mu.Unlock()
354 if f == nil {
355 return ErrUnavailable
356 }
357 select {
358 case <-ctx.Done():
359 return ErrUnavailable
360 case <-f.done:
361 return f.err
362 }
363}
364func (c *Cache) startRefresh(now time.Duration) *flight {
365 if c.flight != nil {
366 return c.flight
367 }
368 if c.evidence.Blocked {
369 return nil
370 }
371 if c.attempted && now-c.lastAttempt < RetryAfter {
372 return nil
373 }
374 ctx, cancel := context.WithTimeout(context.Background(), FetchTimeout)
375 f := &flight{done: make(chan struct{}), cancel: cancel}
376 c.flight = f
377 c.attempted = true
378 c.lastAttempt = now
379 r := c.record
380 version := c.version
381 go func() {
382 defer cancel()
383 err := c.fetch(ctx, r, version, now)
384 c.mu.Lock()
385 f.err = err
386 c.flight = nil
387 close(f.done)
388 c.mu.Unlock()
389 }()
390 return f
391}
392func (c *Cache) fetch(ctx context.Context, r VerifiedRecord, version uint64, start time.Duration) error {
393 random := make([]byte, 32)
394 if _, e := rand.Read(random); e != nil {
395 return ErrUnavailable
396 }
397 challenge := base64.RawURLEncoding.EncodeToString(random)
398 var members []MemberEntry
399 cursor := ""
400 epoch := int64(0)
401 seen := map[string]bool{}
402 for index := 0; index < 100; index++ {
403 q := url.Values{"team": {r.record.Bindings.Team}, "fetch": {challenge}}
404 if cursor != "" {
405 q.Set("cursor", cursor)
406 }
407 target := r.record.Bindings.Directory + "/roster?" + q.Encode()
408 if len(target) > 4096 {
409 return ErrRefused
410 }
411 req, e := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
412 if e != nil {
413 return ErrUnavailable
414 }
415 req.Header.Set("Authorization", "Bearer "+c.token)
416 req.Header.Set("Accept", "application/json")
417 req.Header.Set("Accept-Encoding", "identity")
418 response, e := c.client.Do(req)
419 if e != nil {
420 return ErrUnavailable
421 }
422 raw, readErr := io.ReadAll(io.LimitReader(response.Body, BodyLimit+1))
423 response.Body.Close()
424 if readErr != nil || len(raw) > BodyLimit || response.Header.Get("Content-Type") != "application/json" || response.Header.Get("Content-Encoding") != "" || response.Uncompressed {
425 return ErrRefused
426 }
427 p, e := verifyPage(raw, r, challenge, cursor, response.StatusCode)
428 if e != nil {
429 return ErrRefused
430 }
431 if p.Generation != r.record.Generation {
432 c.mu.Lock()
433 if c.record.digest == r.digest {
434 ev := c.evidence
435 ev.Blocked = true
436 _ = c.save(ctx, ev)
437 c.evidence.Blocked = true
438 c.fence()
439 }
440 c.mu.Unlock()
441 return ErrRefused
442 }
443 if p.Type != "page" || *p.Index != index || p.Epoch < r.record.MinEpoch || (epoch != 0 && p.Epoch != epoch) {
444 return ErrRefused
445 }
446 epoch = p.Epoch
447 if len(members) > 0 && len(*p.Members) > 0 && members[len(members)-1].Subject >= (*p.Members)[0].Subject {
448 return ErrRefused
449 }
450 members = append(members, (*p.Members)...)
451 if len(members) > MemberLimit {
452 return ErrRefused
453 }
454 if *p.Complete {
455 return c.publish(ctx, r, version, start, epoch, members)
456 }
457 cursor = *p.Cursor
458 if seen[cursor] {
459 return ErrRefused
460 }
461 seen[cursor] = true
462 }
463 return ErrRefused
464}
465func (c *Cache) publish(ctx context.Context, r VerifiedRecord, version uint64, start time.Duration, epoch int64, members []MemberEntry) error {
466 c.mu.Lock()
467 defer c.mu.Unlock()
468 _, now := c.observe()
469 if ctx.Err() != nil || c.version != version || c.record.digest != r.digest || now-start >= FetchTimeout || epoch < c.evidence.Epoch {
470 return ErrUnavailable
471 }
472 digest := memberDigest(members)
473 // A provisioned floor without a fetched snapshot has no member digest yet.
474 if epoch == c.evidence.Epoch && c.evidence.Digest != "" && c.evidenceSnapshot() && digest != c.evidence.Digest {
475 return ErrRefused
476 }
477 ev := c.evidence
478 ev.Epoch = epoch
479 ev.Members = append([]MemberEntry{}, members...)
480 ev.Digest = digest
481 ev.Snapshot = true
482 if e := c.save(ctx, ev); e != nil {
483 return e
484 }
485 c.evidence = ev
486 _, now = c.observe()
487 if ctx.Err() != nil || c.version != version || now-start >= FetchTimeout {
488 return ErrUnavailable
489 }
490 c.members = make(map[string]string, len(members))
491 for _, m := range members {
492 c.members[m.Subject] = m.Role
493 }
494 c.start = start
495 c.usable = true
496 c.version++
497 return nil
498}
499func (c *Cache) evidenceSnapshot() bool {
500 return c.evidence.Snapshot
501}
502