Go · 12247 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package policy
4
5 import (
6 "context"
7 "errors"
8 "net/http"
9
10 "github.com/jackc/pgx/v5"
11 "github.com/jackc/pgx/v5/pgxpool"
12
13 policydb "github.com/tenseleyFlow/shithub/internal/auth/policy/sqlc"
14 )
15
16 // Deps wires the policy package against Postgres. Construct once at
17 // boot and keep alive for the process lifetime.
18 type Deps struct {
19 Pool *pgxpool.Pool
20 }
21
22 // DenyCode is a typed enum carried on a deny Decision so handlers can
23 // pick a friendly user-facing message without re-deriving the reason
24 // from the resource. Read this; do not parse Decision.Reason.
25 type DenyCode int
26
27 const (
28 // DenyNone is the zero value used on an allow Decision.
29 DenyNone DenyCode = iota
30 DenyRepoDeleted
31 DenyActorSuspended
32 DenyArchived
33 DenyVisibility // anonymous-or-non-collab on a private repo
34 DenyRoleTooLow // logged-in but role insufficient
35 DenyAnonymous // login required (e.g. star/fork)
36 DenyDBError
37 )
38
39 // Decision is the verdict from Can. Allow is the only field handlers
40 // should branch on for control flow. DenyCode lets the handler pick a
41 // user-facing message; Reason is for logs and tests, never end-user
42 // surfaces — it can carry implementation details that constitute
43 // existence leaks.
44 type Decision struct {
45 Allow bool
46 Reason string
47 Code DenyCode
48 }
49
50 // allow / deny are convenience constructors used by the rule engine
51 // below to keep each branch one short line.
52 func allow(reason string) Decision { return Decision{Allow: true, Reason: reason} }
53 func deny(code DenyCode, reason string) Decision {
54 return Decision{Allow: false, Reason: reason, Code: code}
55 }
56
57 // Can is the single authorization decision function. Every handler,
58 // hook, and worker in shithub funnels through here. Order of evaluation
59 // is significant — higher-precedence denies (deletion, suspension)
60 // short-circuit before role lookups touch the DB.
61 //
62 // The cache (if present in ctx via WithCache) is consulted before any
63 // query and populated on the first lookup.
64 func Can(ctx context.Context, d Deps, actor Actor, action Action, repo RepoRef) Decision {
65 // 1. Soft-deleted repos: nothing is allowed against them, full stop.
66 // (Site admins can technically still hard-delete via admin
67 // tooling, which doesn't go through Can.)
68 if repo.IsDeleted {
69 return deny(DenyRepoDeleted, "repo deleted")
70 }
71
72 // 2. Site admins: read access to anything; explicit-impersonation
73 // needed for writes (S34 wires the impersonation surface — for
74 // now the override only fires on read actions).
75 if actor.IsSiteAdmin && isReadAction(action) {
76 return allow("site admin read")
77 }
78
79 // 3. Suspended actors: writes denied unconditionally; reads against
80 // public repos are still allowed (matches GitHub's "ghost-mode"
81 // suspension semantics).
82 if actor.IsSuspended && isWriteAction(action) {
83 return deny(DenyActorSuspended, "actor suspended")
84 }
85
86 // 4. Anonymous + private: existence-leak-safe deny. Handler maps to
87 // 404 via Maybe404.
88 if actor.IsAnonymous && repo.IsPrivate() {
89 return deny(DenyVisibility, "anonymous on private repo")
90 }
91
92 // 5. Visibility for reads on public repos: anyone (anon or logged-
93 // in) can read public, regardless of suspension.
94 if isReadAction(action) && repo.IsPublic() {
95 return allow("public repo read")
96 }
97
98 // 6. From here we need the actor's effective role on the repo.
99 // Owner short-circuits to admin; collaborator role from DB
100 // otherwise; org membership stub for S31.
101 role, err := effectiveRole(ctx, d, actor, repo)
102 if err != nil {
103 // DB error: deny rather than allow. Surface in logs via the
104 // reason; the caller is expected to log and fail closed.
105 return deny(DenyDBError, "role lookup failed: "+err.Error())
106 }
107
108 // 6a. Author-self-close on issues and PRs. The author of an issue or
109 // PR is allowed to close (and reopen — same Action) their own
110 // thread regardless of their collaborator role. Handlers populate
111 // `repo.AuthorUserID` on the close path; everywhere else the
112 // field is zero and this branch is dead. Suspension and archived
113 // gates above still apply — they ran before this. Note: we do NOT
114 // extend this to `ActionIssueLabel`/`ActionIssueAssign`; only the
115 // close action is author-self by design (matches GitHub).
116 if (action == ActionIssueClose || action == ActionPullClose) &&
117 repo.AuthorUserID != 0 && repo.AuthorUserID == actor.UserID {
118 return allow("author of thread")
119 }
120
121 // 7. Archived repos: writes denied even for owners. Reads still go
122 // through the role check above. (We could short-circuit reads
123 // earlier but keeping the flow uniform makes the matrix readable.)
124 if repo.IsArchived && isWriteAction(action) {
125 return deny(DenyArchived, "repo archived")
126 }
127
128 // 8. Map action → minimum required role; check.
129 want := minRoleFor(action)
130 if want != RoleNone && !RoleAtLeast(role, want) {
131 // No role at all + private repo → look like a visibility deny
132 // so the handler picks the 404-leak guard. Otherwise it's a
133 // genuine role-too-low for a logged-in collaborator.
134 if role == RoleNone && repo.IsPrivate() {
135 return deny(DenyVisibility, "no role on private repo")
136 }
137 return deny(DenyRoleTooLow, "role too low")
138 }
139
140 // 9. Login-required actions: star/fork/watch-set need any
141 // logged-in user. Anonymous reaches here only on a public repo
142 // (see step 4); we deny with the anonymous code so the handler
143 // can render a friendly "log in to star" prompt.
144 if (action == ActionStarCreate || action == ActionForkCreate || action == ActionWatchSet) &&
145 actor.IsAnonymous {
146 return deny(DenyAnonymous, "anonymous cannot star/fork/watch")
147 }
148
149 return allow("granted")
150 }
151
152 // IsVisibleTo is a convenience wrapper around Can(actor, repo:read, …).
153 // Used by listing endpoints that need to filter results by visibility
154 // without caring about the deny reason.
155 func IsVisibleTo(ctx context.Context, d Deps, actor Actor, repo RepoRef) bool {
156 return Can(ctx, d, actor, ActionRepoRead, repo).Allow
157 }
158
159 // Maybe404 maps a deny Decision to an HTTP status code that doesn't
160 // leak existence. The convention:
161 //
162 // - allow → 200 (caller handles)
163 // - deny on a private+anonymous (or non-collab+non-owner) → 404
164 // - any other deny → 403
165 //
166 // Handlers should call this after checking Allow, only when serving
167 // the rejection response.
168 func Maybe404(decision Decision, repo RepoRef, actor Actor) int {
169 if decision.Allow {
170 return http.StatusOK
171 }
172 // Anything that turns on private-visibility surfaces as 404.
173 if repo.IsPrivate() {
174 // Owner of a private repo getting denied is a real 403 (e.g.
175 // archived push). We approximate "owner" as having matching
176 // owner_user_id; collaborator status doesn't change the leak
177 // analysis since the row already exists in their world.
178 if actor.UserID != 0 && actor.UserID == repo.OwnerUserID {
179 return http.StatusForbidden
180 }
181 return http.StatusNotFound
182 }
183 return http.StatusForbidden
184 }
185
186 // effectiveRole computes the highest-effective role for actor on repo.
187 // Owner ⇒ implicit admin; collaborator role from repo_collaborators;
188 // nothing otherwise.
189 //
190 // Org-owned repos: every `org_members.role='owner'` of the owning org
191 // is treated as an implicit admin on every org-owned repo. This is
192 // the S30 owner-implicit-admin contract — without it an org owner
193 // can't push to their own org's repos. Org `member` role grants no
194 // implicit access; teams (S31) and direct collaboration (S15) are the
195 // only paths to repo permission for non-owners.
196 func effectiveRole(ctx context.Context, d Deps, actor Actor, repo RepoRef) (Role, error) {
197 if actor.UserID != 0 && actor.UserID == repo.OwnerUserID {
198 return RoleAdmin, nil
199 }
200 if actor.UserID == 0 {
201 return RoleNone, nil
202 }
203
204 // Cache check.
205 cache := cacheFromContext(ctx)
206 key := cacheKey{actorUserID: actor.UserID, repoID: repo.ID}
207 if r, ok := cacheGet(cache, key); ok {
208 return r, nil
209 }
210
211 if d.Pool == nil {
212 // In tests where a Deps without Pool is passed, fail closed.
213 return RoleNone, nil
214 }
215
216 // Org-owner check fires first because it short-circuits with admin
217 // regardless of any per-repo collaborator row. The lookup is
218 // indexed on (org_id, user_id) — same cost as the collab lookup.
219 if repo.OwnerOrgID != 0 {
220 var dbOrgRole string
221 err := d.Pool.QueryRow(ctx,
222 `SELECT role::text FROM org_members WHERE org_id = $1 AND user_id = $2`,
223 repo.OwnerOrgID, actor.UserID,
224 ).Scan(&dbOrgRole)
225 if err == nil && dbOrgRole == "owner" {
226 cachePut(cache, key, RoleAdmin)
227 return RoleAdmin, nil
228 }
229 // "no rows" or member-only falls through to the collab-row
230 // lookup below.
231 }
232
233 q := policydb.New()
234 dbRole, err := q.GetCollabRole(ctx, d.Pool, policydb.GetCollabRoleParams{
235 RepoID: repo.ID,
236 UserID: actor.UserID,
237 })
238 if errors.Is(err, pgx.ErrNoRows) {
239 cachePut(cache, key, RoleNone)
240 return RoleNone, nil
241 }
242 if err != nil {
243 return RoleNone, err
244 }
245 r := roleFromDB(dbRole)
246 cachePut(cache, key, r)
247 return r, nil
248 }
249
250 // minRoleFor returns the minimum collaborator role required for the
251 // action against an existing repo. Owner is implicit admin, so this
252 // table is the single source of truth for "what role grants what."
253 //
254 //nolint:gocyclo // exhaustive switch is the readable shape here.
255 func minRoleFor(action Action) Role {
256 switch action {
257 // Read tier — public repos bypass via the early allow in Can; this
258 // is the gate for *private* read access.
259 case ActionRepoRead, ActionIssueRead, ActionPullRead:
260 return RoleRead
261
262 // Triage tier — issue-shape mutations without code write.
263 case ActionIssueClose, ActionIssueLabel, ActionIssueAssign:
264 return RoleTriage
265
266 // Write tier — code push, branch create, PR open/comment.
267 case ActionRepoWrite, ActionPullCreate, ActionPullReview, ActionPullClose,
268 ActionIssueCreate, ActionIssueComment:
269 return RoleWrite
270
271 // Maintain tier — most settings except dangerous ones.
272 case ActionRepoSettingsGeneral, ActionRepoSettingsBranches:
273 return RoleMaintain
274
275 // Admin tier — destructive and ownership-changing actions.
276 case ActionRepoAdmin, ActionRepoSettingsCollaborators,
277 ActionRepoArchive, ActionRepoDelete, ActionRepoTransfer, ActionRepoVisibility,
278 ActionPullMerge:
279 return RoleAdmin
280
281 // Login-required but no role required (any logged-in user).
282 // Star/fork: any logged-in user can star or fork any repo they
283 // can read — the visibility short-circuit above already gates
284 // private-repo access, and the role check below short-circuits
285 // to allow when minRole is RoleNone.
286 // WatchSet: same shape — any logged-in user with read access
287 // can choose their watch level. The downstream notifications
288 // fan-out (S29) is what enforces level-based delivery.
289 case ActionStarCreate, ActionForkCreate, ActionWatchSet:
290 return RoleNone
291
292 default:
293 // Unknown actions deny by default — RoleAdmin is impossibly
294 // high for a stranger so this acts as a fail-closed gate.
295 return RoleAdmin
296 }
297 }
298
299 // errBadAction is returned by Can when a caller passes a value that
300 // doesn't match any registered Action. Reserved for tests.
301 var errBadAction = errors.New("policy: unregistered action")
302
303 // EffectiveRole returns the actor's resolved role on repo, taking
304 // owner-equals-admin into account and consulting the per-request cache.
305 //
306 // Use this when a handler needs to ask "is this actor at least X" for a
307 // rule that doesn't map cleanly to an Action — for example, the
308 // locked-issue gate which lets triage+ comment despite the lock without
309 // granting them any other write permission.
310 //
311 // Returns RoleNone for anonymous actors and on any DB error; callers
312 // should treat RoleNone as "no permissions."
313 func EffectiveRole(ctx context.Context, d Deps, actor Actor, repo RepoRef) Role {
314 if actor.IsAnonymous {
315 return RoleNone
316 }
317 r, err := effectiveRole(ctx, d, actor, repo)
318 if err != nil {
319 return RoleNone
320 }
321 return r
322 }
323
324 // HasRoleAtLeast is the convenience shorthand for the common case:
325 // "does this actor hold at least `want` on this repo?" Wraps
326 // EffectiveRole + RoleAtLeast.
327 func HasRoleAtLeast(ctx context.Context, d Deps, actor Actor, repo RepoRef, want Role) bool {
328 return RoleAtLeast(EffectiveRole(ctx, d, actor, repo), want)
329 }
330