coverage~bigbes/sr-ht-compare9720ccc2authz/authz.go

Coverage
92.7% 51/55 statements
Δ
Blob
dbc56e4
1 package authz
2
3 import (
4 "context"
5 "fmt"
6 "strings"
7 "sync"
8 "time"
9
10 "sourcecraft.dev/bigbes/sr-ht-core/client"
11
12 "sourcecraft.dev/bigbes/sr-ht-compare/core"
13 )
14
15 // RepoInfo is the subset of git.sr.ht repository metadata diff.sr.ht needs
16 // to render a page. It carries no ownership or ACL data: the mere fact that the
17 // authorizer returned a RepoInfo means the viewer is allowed to see the repo.
18 type RepoInfo struct {
19 ID int
20 Name string
21 Description string
22 Visibility string
23 }
24
25 // Authorizer decides, for a given viewer, whether a repository may be seen and
26 // returns its metadata. A nil *RepoInfo is never returned alongside a nil
27 // error; an unauthorized or missing repo is reported as core.ErrNotFound so
28 // callers cannot distinguish "forbidden" from "absent".
29 type Authorizer interface {
30 // Repo resolves a single repository owned by owner (with or without a
31 // leading "~") as seen by viewer ("" for anonymous). A null user or null
32 // repository yields core.ErrNotFound; transport/GraphQL failures yield a
33 // wrapped error that is NOT core.ErrNotFound.
34 Repo(ctx context.Context, viewer, owner, name string) (*RepoInfo, error)
35 // MyRepos lists every repository owned by viewer. viewer must be non-empty.
36 MyRepos(ctx context.Context, viewer string) ([]RepoInfo, error)
37 }
38
39 // maxMyRepos caps the number of repositories MyRepos will accumulate across
40 // pages, bounding memory and request count for pathological accounts.
41 const maxMyRepos = 500
42
43 type cacheEntry struct {
44 info *RepoInfo // nil when notFound
45 notFound bool
46 expiry time.Time
47 }
48
49 // GQLAuthorizer implements Authorizer against git.sr.ht's internal GraphQL API
50 // via core-go's client.Do, memoizing Repo results in a TTL cache. It is safe
51 // for concurrent use.
52 type GQLAuthorizer struct {
53 ttl time.Duration
54
55 mu sync.Mutex
56 cache map[string]cacheEntry
57 lastSweep time.Time
58 }
59
60 // NewAuthorizer returns a GQLAuthorizer whose positive and not-found Repo
61 // results are cached for ttl (the caller supplies 60s in production).
62 13 func NewAuthorizer(ttl time.Duration) *GQLAuthorizer {
63 13 return &GQLAuthorizer{
64 13 ttl: ttl,
65 13 cache: make(map[string]cacheEntry),
66 13 }
67 13 }
68
69 // repoQuery asks for one repository under a user; both user and repository come
70 // back null when the viewer may not see them. username is passed WITHOUT "~".
71 const repoQuery = `query($u:String!,$r:String!){user(username:$u){repository(name:$r){id name description visibility}}}`
72
73 17 func (a *GQLAuthorizer) Repo(ctx context.Context, viewer, owner, name string) (*RepoInfo, error) {
74 17 owner = strings.TrimPrefix(owner, "~")
75 17 key := cacheKey(viewer, owner, name)
76 17
77 17 if info, notFound, ok := a.load(key); ok {
78 4 if notFound {
79 2 return nil, core.ErrNotFound
80 2 }
81 2 return info, nil
82 }
83
84 13 var result struct {
85 13 User *struct {
86 13 Repository *struct {
87 13 ID int `json:"id"`
88 13 Name string `json:"name"`
89 13 Description string `json:"description"`
90 13 Visibility string `json:"visibility"`
91 13 } `json:"repository"`
92 13 } `json:"user"`
93 13 }
94 13 query := client.GraphQLQuery{
95 13 Query: repoQuery,
96 13 Variables: map[string]any{"u": owner, "r": name},
97 13 }
98 13 if err := client.Do(ctx, viewer, "git.sr.ht", query, &result); err != nil {
99 2 // Transport or GraphQL error — do NOT cache and do NOT mask as
100 2 // not-found; the web layer distinguishes 404 from 502.
101 2 return nil, fmt.Errorf("git.sr.ht repository query for ~%s/%s: %w", owner, name, err)
102 2 }
103 11 if result.User == nil || result.User.Repository == nil {
104 3 a.store(key, cacheEntry{notFound: true})
105 3 return nil, core.ErrNotFound
106 3 }
107
108 8 repo := result.User.Repository
109 8 info := &RepoInfo{
110 8 ID: repo.ID,
111 8 Name: repo.Name,
112 8 Description: repo.Description,
113 8 Visibility: repo.Visibility,
114 8 }
115 8 a.store(key, cacheEntry{info: info})
116 8 return info, nil
117 }
118
119 // myReposQuery paginates the viewer's own repositories via the cursor scalar.
120 const myReposQuery = `query($c:Cursor){me{repositories(cursor:$c){results{id name description visibility} cursor}}}`
121
122 4 func (a *GQLAuthorizer) MyRepos(ctx context.Context, viewer string) ([]RepoInfo, error) {
123 4 if viewer == "" {
124 1 return nil, fmt.Errorf("authz: MyRepos requires an authenticated viewer")
125 1 }
126
127 3 var repos []RepoInfo
128 3 var cursor *string
129 4 for {
130 4 var result struct {
131 4 Me struct {
132 4 Repositories struct {
133 4 Results []struct {
134 4 ID int `json:"id"`
135 4 Name string `json:"name"`
136 4 Description string `json:"description"`
137 4 Visibility string `json:"visibility"`
138 4 } `json:"results"`
139 4 Cursor *string `json:"cursor"`
140 4 } `json:"repositories"`
141 4 } `json:"me"`
142 4 }
143 4 query := client.GraphQLQuery{
144 4 Query: myReposQuery,
145 4 Variables: map[string]any{"c": cursor},
146 4 }
147 4 if err := client.Do(ctx, viewer, "git.sr.ht", query, &result); err != nil {
148 0 return nil, fmt.Errorf("git.sr.ht repositories query for ~%s: %w", viewer, err)
149 0 }
150
151 5 for _, r := range result.Me.Repositories.Results {
152 5 repos = append(repos, RepoInfo{
153 5 ID: r.ID,
154 5 Name: r.Name,
155 5 Description: r.Description,
156 5 Visibility: r.Visibility,
157 5 })
158 5 if len(repos) >= maxMyRepos {
159 0 return repos, nil
160 0 }
161 }
162
163 4 if result.Me.Repositories.Cursor == nil {
164 3 break
165 }
166 1 cursor = result.Me.Repositories.Cursor
167 }
168 3 return repos, nil
169 }
170
171 17 func cacheKey(viewer, owner, name string) string {
172 17 return viewer + "\x00" + owner + "\x00" + name
173 17 }
174
175 // load returns a cached entry if present and unexpired, pruning it lazily on a
176 // hit that has aged out.
177 17 func (a *GQLAuthorizer) load(key string) (info *RepoInfo, notFound, ok bool) {
178 17 a.mu.Lock()
179 17 defer a.mu.Unlock()
180 17 e, exists := a.cache[key]
181 17 if !exists {
182 12 return nil, false, false
183 12 }
184 5 if time.Now().After(e.expiry) {
185 1 delete(a.cache, key)
186 1 return nil, false, false
187 1 }
188 4 return e.info, e.notFound, true
189 }
190
191 // store records an entry with a fresh expiry and opportunistically sweeps the
192 // whole map at most once per ttl, so no background goroutine is needed.
193 11 func (a *GQLAuthorizer) store(key string, e cacheEntry) {
194 11 a.mu.Lock()
195 11 defer a.mu.Unlock()
196 11 now := time.Now()
197 11 e.expiry = now.Add(a.ttl)
198 11 if now.Sub(a.lastSweep) > a.ttl {
199 10 for k, v := range a.cache {
200 0 if now.After(v.expiry) {
201 0 delete(a.cache, k)
202 0 }
203 }
204 10 a.lastSweep = now
205 }
206 11 a.cache[key] = e
207 }
208
209 // compile-time assertion that GQLAuthorizer satisfies Authorizer.
210 var _ Authorizer = (*GQLAuthorizer)(nil)