| 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, ActionActionsRun, 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, ActionActionsApprove: |
| 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 |