Go · 10603 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 need any logged-in user.
141 if (action == ActionStarCreate || action == ActionForkCreate) && actor.IsAnonymous {
142 return deny(DenyAnonymous, "anonymous cannot star/fork")
143 }
144
145 return allow("granted")
146 }
147
148 // IsVisibleTo is a convenience wrapper around Can(actor, repo:read, …).
149 // Used by listing endpoints that need to filter results by visibility
150 // without caring about the deny reason.
151 func IsVisibleTo(ctx context.Context, d Deps, actor Actor, repo RepoRef) bool {
152 return Can(ctx, d, actor, ActionRepoRead, repo).Allow
153 }
154
155 // Maybe404 maps a deny Decision to an HTTP status code that doesn't
156 // leak existence. The convention:
157 //
158 // - allow → 200 (caller handles)
159 // - deny on a private+anonymous (or non-collab+non-owner) → 404
160 // - any other deny → 403
161 //
162 // Handlers should call this after checking Allow, only when serving
163 // the rejection response.
164 func Maybe404(decision Decision, repo RepoRef, actor Actor) int {
165 if decision.Allow {
166 return http.StatusOK
167 }
168 // Anything that turns on private-visibility surfaces as 404.
169 if repo.IsPrivate() {
170 // Owner of a private repo getting denied is a real 403 (e.g.
171 // archived push). We approximate "owner" as having matching
172 // owner_user_id; collaborator status doesn't change the leak
173 // analysis since the row already exists in their world.
174 if actor.UserID != 0 && actor.UserID == repo.OwnerUserID {
175 return http.StatusForbidden
176 }
177 return http.StatusNotFound
178 }
179 return http.StatusForbidden
180 }
181
182 // effectiveRole computes the highest-effective role for actor on repo.
183 // Owner ⇒ implicit admin; collaborator role from repo_collaborators;
184 // nothing otherwise.
185 func effectiveRole(ctx context.Context, d Deps, actor Actor, repo RepoRef) (Role, error) {
186 if actor.UserID != 0 && actor.UserID == repo.OwnerUserID {
187 return RoleAdmin, nil
188 }
189 if actor.UserID == 0 {
190 return RoleNone, nil
191 }
192
193 // Cache check.
194 cache := cacheFromContext(ctx)
195 key := cacheKey{actorUserID: actor.UserID, repoID: repo.ID}
196 if r, ok := cacheGet(cache, key); ok {
197 return r, nil
198 }
199
200 // DB lookup.
201 if d.Pool == nil {
202 // In tests where a Deps without Pool is passed, fail closed.
203 return RoleNone, nil
204 }
205 q := policydb.New()
206 dbRole, err := q.GetCollabRole(ctx, d.Pool, policydb.GetCollabRoleParams{
207 RepoID: repo.ID,
208 UserID: actor.UserID,
209 })
210 if errors.Is(err, pgx.ErrNoRows) {
211 cachePut(cache, key, RoleNone)
212 return RoleNone, nil
213 }
214 if err != nil {
215 return RoleNone, err
216 }
217 r := roleFromDB(dbRole)
218 cachePut(cache, key, r)
219 return r, nil
220 }
221
222 // minRoleFor returns the minimum collaborator role required for the
223 // action against an existing repo. Owner is implicit admin, so this
224 // table is the single source of truth for "what role grants what."
225 //
226 //nolint:gocyclo // exhaustive switch is the readable shape here.
227 func minRoleFor(action Action) Role {
228 switch action {
229 // Read tier — public repos bypass via the early allow in Can; this
230 // is the gate for *private* read access.
231 case ActionRepoRead, ActionIssueRead, ActionPullRead:
232 return RoleRead
233
234 // Triage tier — issue-shape mutations without code write.
235 case ActionIssueClose, ActionIssueLabel, ActionIssueAssign:
236 return RoleTriage
237
238 // Write tier — code push, branch create, PR open/comment.
239 case ActionRepoWrite, ActionPullCreate, ActionPullReview, ActionPullClose,
240 ActionIssueCreate, ActionIssueComment:
241 return RoleWrite
242
243 // Maintain tier — most settings except dangerous ones.
244 case ActionRepoSettingsGeneral, ActionRepoSettingsBranches:
245 return RoleMaintain
246
247 // Admin tier — destructive and ownership-changing actions.
248 case ActionRepoAdmin, ActionRepoSettingsCollaborators,
249 ActionRepoArchive, ActionRepoDelete, ActionRepoTransfer, ActionRepoVisibility,
250 ActionPullMerge:
251 return RoleAdmin
252
253 // Login-required but no role required (any logged-in user).
254 case ActionStarCreate, ActionForkCreate:
255 return RoleNone
256
257 default:
258 // Unknown actions deny by default — RoleAdmin is impossibly
259 // high for a stranger so this acts as a fail-closed gate.
260 return RoleAdmin
261 }
262 }
263
264 // errBadAction is returned by Can when a caller passes a value that
265 // doesn't match any registered Action. Reserved for tests.
266 var errBadAction = errors.New("policy: unregistered action")
267
268 // EffectiveRole returns the actor's resolved role on repo, taking
269 // owner-equals-admin into account and consulting the per-request cache.
270 //
271 // Use this when a handler needs to ask "is this actor at least X" for a
272 // rule that doesn't map cleanly to an Action — for example, the
273 // locked-issue gate which lets triage+ comment despite the lock without
274 // granting them any other write permission.
275 //
276 // Returns RoleNone for anonymous actors and on any DB error; callers
277 // should treat RoleNone as "no permissions."
278 func EffectiveRole(ctx context.Context, d Deps, actor Actor, repo RepoRef) Role {
279 if actor.IsAnonymous {
280 return RoleNone
281 }
282 r, err := effectiveRole(ctx, d, actor, repo)
283 if err != nil {
284 return RoleNone
285 }
286 return r
287 }
288
289 // HasRoleAtLeast is the convenience shorthand for the common case:
290 // "does this actor hold at least `want` on this repo?" Wraps
291 // EffectiveRole + RoleAtLeast.
292 func HasRoleAtLeast(ctx context.Context, d Deps, actor Actor, repo RepoRef, want Role) bool {
293 return RoleAtLeast(EffectiveRole(ctx, d, actor, repo), want)
294 }
295