Go · 17617 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/pgtype"
12 "github.com/jackc/pgx/v5/pgxpool"
13
14 policydb "github.com/tenseleyFlow/shithub/internal/auth/policy/sqlc"
15 )
16
17 // Deps wires the policy package against Postgres. Construct once at
18 // boot and keep alive for the process lifetime.
19 type Deps struct {
20 Pool *pgxpool.Pool
21 }
22
23 // DenyCode is a typed enum carried on a deny Decision so handlers can
24 // pick a friendly user-facing message without re-deriving the reason
25 // from the resource. Read this; do not parse Decision.Reason.
26 type DenyCode int
27
28 const (
29 // DenyNone is the zero value used on an allow Decision.
30 DenyNone DenyCode = iota
31 DenyRepoDeleted
32 DenyActorSuspended
33 DenyArchived
34 DenyVisibility // anonymous-or-non-collab on a private repo
35 DenyRoleTooLow // logged-in but role insufficient
36 DenyAnonymous // login required (e.g. star/fork)
37 DenyDBError
38 // DenyOrgSuspended is returned for write actions on a repo whose
39 // owning org is currently suspended. Reads stay allowed (the spec
40 // preserves visibility into suspended-org content); writes flip
41 // off uniformly.
42 DenyOrgSuspended
43 // DenyImpersonationReadOnly is returned when an admin in
44 // impersonation mode attempts a write without first opting into
45 // write-mode (the typed-name confirm step in /admin/impersonate).
46 // The default is read-only on the canonical foot-gun grounds.
47 DenyImpersonationReadOnly
48 )
49
50 // Decision is the verdict from Can. Allow is the only field handlers
51 // should branch on for control flow. DenyCode lets the handler pick a
52 // user-facing message; Reason is for logs and tests, never end-user
53 // surfaces — it can carry implementation details that constitute
54 // existence leaks.
55 type Decision struct {
56 Allow bool
57 Reason string
58 Code DenyCode
59 }
60
61 // allow / deny are convenience constructors used by the rule engine
62 // below to keep each branch one short line.
63 func allow(reason string) Decision { return Decision{Allow: true, Reason: reason} }
64
65 func deny(code DenyCode, reason string) Decision {
66 return Decision{Allow: false, Reason: reason, Code: code}
67 }
68
69 // Can is the single authorization decision function. Every handler,
70 // hook, and worker in shithub funnels through here. Order of evaluation
71 // is significant — higher-precedence denies (deletion, suspension)
72 // short-circuit before role lookups touch the DB.
73 //
74 // The cache (if present in ctx via WithCache) is consulted before any
75 // query and populated on the first lookup.
76 func Can(ctx context.Context, d Deps, actor Actor, action Action, repo RepoRef) Decision {
77 // 1. Soft-deleted repos: nothing is allowed against them, full stop.
78 // (Site admins can technically still hard-delete via admin
79 // tooling, which doesn't go through Can.)
80 if repo.IsDeleted {
81 return deny(DenyRepoDeleted, "repo deleted")
82 }
83
84 // 2. Site admins: read access to anything; explicit-impersonation
85 // needed for writes (S34 wires the impersonation surface — for
86 // now the override only fires on read actions).
87 if actor.IsSiteAdmin && isReadAction(action) {
88 return allow("site admin read")
89 }
90
91 // 3. Suspended actors: writes denied unconditionally; reads against
92 // public repos are still allowed (matches GitHub's "ghost-mode"
93 // suspension semantics).
94 if actor.IsSuspended && isWriteAction(action) {
95 return deny(DenyActorSuspended, "actor suspended")
96 }
97
98 // 3a. Impersonation: an admin viewing-as another user is read-only
99 // by default. Writes require ImpersonateWriteOK to have been
100 // opted into via the typed-name confirmation step.
101 if actor.Impersonating && !actor.ImpersonateWriteOK && isWriteAction(action) {
102 return deny(DenyImpersonationReadOnly, "impersonation in read-only mode")
103 }
104
105 // 4. Anonymous + private: existence-leak-safe deny. Handler maps to
106 // 404 via Maybe404.
107 if actor.IsAnonymous && repo.IsPrivate() {
108 return deny(DenyVisibility, "anonymous on private repo")
109 }
110
111 // 5. Visibility for reads on public repos: anyone (anon or logged-
112 // in) can read public, regardless of suspension.
113 if isReadAction(action) && repo.IsPublic() {
114 return allow("public repo read")
115 }
116
117 // 6. Public issue participation: any logged-in user can open or
118 // comment on issues in a public repo. Private repos still fall
119 // through to the role check below, where read access is required.
120 // Archive/org-suspension write gates stay below role resolution
121 // for the general case, so enforce them explicitly here before
122 // allowing a non-collaborator public-repo issue action.
123 if isIssueParticipationAction(action) && repo.IsPublic() {
124 if actor.IsAnonymous {
125 return deny(DenyAnonymous, "anonymous cannot create/comment on issues")
126 }
127 if repo.IsArchived {
128 return deny(DenyArchived, "repo archived")
129 }
130 if repo.OwnerOrgID != 0 && isOrgSuspended(ctx, d, repo.OwnerOrgID) {
131 return deny(DenyOrgSuspended, "owning org suspended")
132 }
133 return allow("public issue participation")
134 }
135
136 // 7. From here we need the actor's effective role on the repo.
137 // Owner short-circuits to admin; collaborator role from DB
138 // otherwise; org membership stub for S31.
139 role, err := effectiveRole(ctx, d, actor, repo)
140 if err != nil {
141 // DB error: deny rather than allow. Surface in logs via the
142 // reason; the caller is expected to log and fail closed.
143 return deny(DenyDBError, "role lookup failed: "+err.Error())
144 }
145
146 // 7a. Author-self-close on issues and PRs. The author of an issue or
147 // PR is allowed to close (and reopen — same Action) their own
148 // thread regardless of their collaborator role. Handlers populate
149 // `repo.AuthorUserID` on the close path; everywhere else the
150 // field is zero and this branch is dead. Suspension and archived
151 // gates above still apply — they ran before this. Note: we do NOT
152 // extend this to `ActionIssueLabel`/`ActionIssueAssign`; only the
153 // close action is author-self by design (matches GitHub).
154 if (action == ActionIssueClose || action == ActionPullClose) &&
155 repo.AuthorUserID != 0 && repo.AuthorUserID == actor.UserID {
156 return allow("author of thread")
157 }
158
159 // 8. Archived repos: writes denied even for owners. Reads still go
160 // through the role check above. (We could short-circuit reads
161 // earlier but keeping the flow uniform makes the matrix readable.)
162 if repo.IsArchived && isWriteAction(action) {
163 return deny(DenyArchived, "repo archived")
164 }
165
166 // 8b. Org suspension (S30): writes against any repo owned by a
167 // suspended org are denied uniformly. Reads stay allowed (the
168 // org's contributions to the broader graph aren't erased).
169 // The check is gated on a write action AND a non-zero
170 // OwnerOrgID so user-owned repos pay nothing for it.
171 if repo.OwnerOrgID != 0 && isWriteAction(action) && isOrgSuspended(ctx, d, repo.OwnerOrgID) {
172 return deny(DenyOrgSuspended, "owning org suspended")
173 }
174
175 // 9. Map action → minimum required role; check.
176 want := minRoleFor(action)
177 if want != RoleNone && !RoleAtLeast(role, want) {
178 // No role at all + private repo → look like a visibility deny
179 // so the handler picks the 404-leak guard. Otherwise it's a
180 // genuine role-too-low for a logged-in collaborator.
181 if role == RoleNone && repo.IsPrivate() {
182 return deny(DenyVisibility, "no role on private repo")
183 }
184 return deny(DenyRoleTooLow, "role too low")
185 }
186
187 // 10. Login-required actions: star/fork/watch-set need any
188 // logged-in user. Anonymous reaches here only on a public repo
189 // (see step 4); we deny with the anonymous code so the handler
190 // can render a friendly "log in to star" prompt.
191 if (action == ActionStarCreate || action == ActionForkCreate || action == ActionWatchSet) &&
192 actor.IsAnonymous {
193 return deny(DenyAnonymous, "anonymous cannot star/fork/watch")
194 }
195
196 return allow("granted")
197 }
198
199 func isOrgSuspended(ctx context.Context, d Deps, orgID int64) bool {
200 if d.Pool == nil {
201 return false
202 }
203 var suspended bool
204 err := d.Pool.QueryRow(
205 ctx,
206 `SELECT suspended_at IS NOT NULL FROM orgs WHERE id = $1`,
207 orgID,
208 ).Scan(&suspended)
209 return err == nil && suspended
210 }
211
212 // IsVisibleTo is a convenience wrapper around Can(actor, repo:read, …).
213 // Used by listing endpoints that need to filter results by visibility
214 // without caring about the deny reason.
215 func IsVisibleTo(ctx context.Context, d Deps, actor Actor, repo RepoRef) bool {
216 return Can(ctx, d, actor, ActionRepoRead, repo).Allow
217 }
218
219 // Maybe404 maps a deny Decision to an HTTP status code that doesn't
220 // leak existence. The convention:
221 //
222 // - allow → 200 (caller handles)
223 // - deny on a private+anonymous (or non-collab+non-owner) → 404
224 // - any other deny → 403
225 //
226 // Handlers should call this after checking Allow, only when serving
227 // the rejection response.
228 func Maybe404(decision Decision, repo RepoRef, actor Actor) int {
229 if decision.Allow {
230 return http.StatusOK
231 }
232 // Anything that turns on private-visibility surfaces as 404.
233 if repo.IsPrivate() {
234 // Owner of a private repo getting denied is a real 403 (e.g.
235 // archived push). We approximate "owner" as having matching
236 // owner_user_id; collaborator status doesn't change the leak
237 // analysis since the row already exists in their world.
238 if actor.UserID != 0 && actor.UserID == repo.OwnerUserID {
239 return http.StatusForbidden
240 }
241 return http.StatusNotFound
242 }
243 return http.StatusForbidden
244 }
245
246 // effectiveRole computes the highest-effective role for actor on repo.
247 // Owner ⇒ implicit admin; collaborator role from repo_collaborators;
248 // nothing otherwise.
249 //
250 // Org-owned repos: every `org_members.role='owner'` of the owning org
251 // is treated as an implicit admin on every org-owned repo. This is
252 // the S30 owner-implicit-admin contract — without it an org owner
253 // can't push to their own org's repos. Org `member` role grants no
254 // implicit access; teams (S31) and direct collaboration (S15) are the
255 // only paths to repo permission for non-owners.
256 func effectiveRole(ctx context.Context, d Deps, actor Actor, repo RepoRef) (Role, error) {
257 if actor.UserID != 0 && actor.UserID == repo.OwnerUserID {
258 return RoleAdmin, nil
259 }
260 if actor.UserID == 0 {
261 return RoleNone, nil
262 }
263
264 // Cache check.
265 cache := cacheFromContext(ctx)
266 key := cacheKey{actorUserID: actor.UserID, repoID: repo.ID}
267 if r, ok := cacheGet(cache, key); ok {
268 return r, nil
269 }
270
271 if d.Pool == nil {
272 // In tests where a Deps without Pool is passed, fail closed.
273 return RoleNone, nil
274 }
275
276 // Org-owner check fires first because it short-circuits with admin
277 // regardless of any per-repo collaborator row. The lookup is
278 // indexed on (org_id, user_id) — same cost as the collab lookup.
279 if repo.OwnerOrgID != 0 {
280 var dbOrgRole string
281 err := d.Pool.QueryRow(
282 ctx,
283 `SELECT role::text FROM org_members WHERE org_id = $1 AND user_id = $2`,
284 repo.OwnerOrgID, actor.UserID,
285 ).Scan(&dbOrgRole)
286 if err == nil && dbOrgRole == "owner" {
287 cachePut(cache, key, RoleAdmin)
288 return RoleAdmin, nil
289 }
290 // "no rows" or member-only falls through to the collab-row
291 // lookup below.
292 }
293
294 // Effective role = MAX(direct collab, team grants). Team path
295 // runs only for org-owned repos (user-owned repos have no
296 // teams). One hop on parent_team_id captures the inherited
297 // grants per S31's one-level-deep rule.
298 best := RoleNone
299 q := policydb.New()
300 dbRole, err := q.GetCollabRole(ctx, d.Pool, policydb.GetCollabRoleParams{
301 RepoID: repo.ID,
302 UserID: actor.UserID,
303 })
304 switch {
305 case err == nil:
306 best = roleFromDB(dbRole)
307 case errors.Is(err, pgx.ErrNoRows):
308 // no direct collab row — best stays RoleNone
309 default:
310 return RoleNone, err
311 }
312 if repo.OwnerOrgID != 0 {
313 teamRole, terr := teamGrantedRole(ctx, d, actor.UserID, repo.OwnerOrgID, repo.ID)
314 if terr != nil {
315 return RoleNone, terr
316 }
317 // roleStronger picks the higher-rank role. Don't use
318 // RoleAtLeast here — its `want > 0` guard treats RoleNone
319 // as un-comparable and blocks the legitimate "any role
320 // beats no role" branch.
321 if roleStronger(teamRole, best) {
322 best = teamRole
323 }
324 }
325 cachePut(cache, key, best)
326 return best, nil
327 }
328
329 // roleStronger reports whether `a` ranks strictly higher than `b`,
330 // where RoleNone is the bottom (rank 0). Used to compose role
331 // sources (direct collab, team grant, parent-team grant) into a
332 // single max — the spec's "effective role = max of all sources"
333 // rule.
334 func roleStronger(a, b Role) bool {
335 return roleRank(a) > roleRank(b)
336 }
337
338 // teamGrantedRole computes the highest role the actor inherits from
339 // any team in the org that has a grant on the repo. Walks parent
340 // teams one hop (per the one-level-nesting cap from migration 0035).
341 func teamGrantedRole(ctx context.Context, d Deps, userID, orgID, repoID int64) (Role, error) {
342 rows, err := d.Pool.Query(ctx,
343 `SELECT t.id, t.parent_team_id
344 FROM team_members m
345 JOIN teams t ON t.id = m.team_id
346 WHERE t.org_id = $1 AND m.user_id = $2`,
347 orgID, userID)
348 if err != nil {
349 return RoleNone, err
350 }
351 defer rows.Close()
352 teamIDs := []int64{}
353 for rows.Next() {
354 var id int64
355 var parent pgtype.Int8
356 if err := rows.Scan(&id, &parent); err != nil {
357 return RoleNone, err
358 }
359 teamIDs = append(teamIDs, id)
360 if parent.Valid {
361 teamIDs = append(teamIDs, parent.Int64)
362 }
363 }
364 if err := rows.Err(); err != nil {
365 return RoleNone, err
366 }
367 if len(teamIDs) == 0 {
368 return RoleNone, nil
369 }
370 grantRows, err := d.Pool.Query(ctx,
371 `SELECT role::text FROM team_repo_access
372 WHERE repo_id = $1 AND team_id = ANY($2::bigint[])`,
373 repoID, teamIDs)
374 if err != nil {
375 return RoleNone, err
376 }
377 defer grantRows.Close()
378 best := RoleNone
379 for grantRows.Next() {
380 var role string
381 if err := grantRows.Scan(&role); err != nil {
382 return RoleNone, err
383 }
384 if r := teamRepoRoleToPolicyRole(role); roleStronger(r, best) {
385 best = r
386 }
387 }
388 return best, grantRows.Err()
389 }
390
391 // teamRepoRoleToPolicyRole maps the team_repo_role enum string to
392 // the policy.Role string. Names align but the typed enums are in
393 // different packages so the conversion is explicit.
394 func teamRepoRoleToPolicyRole(s string) Role {
395 switch s {
396 case "read":
397 return RoleRead
398 case "triage":
399 return RoleTriage
400 case "write":
401 return RoleWrite
402 case "maintain":
403 return RoleMaintain
404 case "admin":
405 return RoleAdmin
406 }
407 return RoleNone
408 }
409
410 // minRoleFor returns the minimum collaborator role required for the
411 // action against an existing repo. Owner is implicit admin, so this
412 // table is the single source of truth for "what role grants what."
413 //
414 //nolint:gocyclo // exhaustive switch is the readable shape here.
415 func minRoleFor(action Action) Role {
416 switch action {
417 // Read tier — public repos bypass via the early allow in Can; this
418 // is the gate for *private* read access.
419 case ActionRepoRead, ActionIssueRead, ActionPullRead:
420 return RoleRead
421
422 // Triage tier — issue-shape mutations without code write.
423 case ActionIssueClose, ActionIssueLabel, ActionIssueAssign:
424 return RoleTriage
425
426 // Write tier — code push, branch create, PR open/comment.
427 case ActionRepoWrite, ActionPullCreate, ActionPullReview, ActionPullClose:
428 return RoleWrite
429
430 // Issue participation on private repos requires read access. Public
431 // repos are handled by Can's public issue participation branch above.
432 case ActionIssueCreate, ActionIssueComment:
433 return RoleRead
434
435 // Maintain tier — most settings except dangerous ones.
436 case ActionRepoSettingsGeneral, ActionRepoSettingsBranches:
437 return RoleMaintain
438
439 // Admin tier — destructive and ownership-changing actions.
440 case ActionRepoAdmin, ActionRepoSettingsCollaborators, ActionRepoSettingsActions,
441 ActionRepoArchive, ActionRepoDelete, ActionRepoTransfer, ActionRepoVisibility,
442 ActionPullMerge:
443 return RoleAdmin
444
445 // Login-required but no role required (any logged-in user).
446 // Star/fork: any logged-in user can star or fork any repo they
447 // can read — the visibility short-circuit above already gates
448 // private-repo access, and the role check below short-circuits
449 // to allow when minRole is RoleNone.
450 // WatchSet: same shape — any logged-in user with read access
451 // can choose their watch level. The downstream notifications
452 // fan-out (S29) is what enforces level-based delivery.
453 case ActionStarCreate, ActionForkCreate, ActionWatchSet:
454 return RoleNone
455
456 default:
457 // Unknown actions deny by default — RoleAdmin is impossibly
458 // high for a stranger so this acts as a fail-closed gate.
459 return RoleAdmin
460 }
461 }
462
463 // EffectiveRole returns the actor's resolved role on repo, taking
464 // owner-equals-admin into account and consulting the per-request cache.
465 //
466 // Use this when a handler needs to ask "is this actor at least X" for a
467 // rule that doesn't map cleanly to an Action — for example, the
468 // locked-issue gate which lets triage+ comment despite the lock without
469 // granting them any other write permission.
470 //
471 // Returns RoleNone for anonymous actors and on any DB error; callers
472 // should treat RoleNone as "no permissions."
473 func EffectiveRole(ctx context.Context, d Deps, actor Actor, repo RepoRef) Role {
474 if actor.IsAnonymous {
475 return RoleNone
476 }
477 r, err := effectiveRole(ctx, d, actor, repo)
478 if err != nil {
479 return RoleNone
480 }
481 return r
482 }
483
484 // HasRoleAtLeast is the convenience shorthand for the common case:
485 // "does this actor hold at least `want` on this repo?" Wraps
486 // EffectiveRole + RoleAtLeast.
487 func HasRoleAtLeast(ctx context.Context, d Deps, actor Actor, repo RepoRef, want Role) bool {
488 return RoleAtLeast(EffectiveRole(ctx, d, actor, repo), want)
489 }
490