Go · 8809 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 // 7. Archived repos: writes denied even for owners. Reads still go
109 // through the role check above. (We could short-circuit reads
110 // earlier but keeping the flow uniform makes the matrix readable.)
111 if repo.IsArchived && isWriteAction(action) {
112 return deny(DenyArchived, "repo archived")
113 }
114
115 // 8. Map action → minimum required role; check.
116 want := minRoleFor(action)
117 if want != RoleNone && !RoleAtLeast(role, want) {
118 // No role at all + private repo → look like a visibility deny
119 // so the handler picks the 404-leak guard. Otherwise it's a
120 // genuine role-too-low for a logged-in collaborator.
121 if role == RoleNone && repo.IsPrivate() {
122 return deny(DenyVisibility, "no role on private repo")
123 }
124 return deny(DenyRoleTooLow, "role too low")
125 }
126
127 // 9. Login-required actions: star/fork need any logged-in user.
128 if (action == ActionStarCreate || action == ActionForkCreate) && actor.IsAnonymous {
129 return deny(DenyAnonymous, "anonymous cannot star/fork")
130 }
131
132 return allow("granted")
133 }
134
135 // IsVisibleTo is a convenience wrapper around Can(actor, repo:read, …).
136 // Used by listing endpoints that need to filter results by visibility
137 // without caring about the deny reason.
138 func IsVisibleTo(ctx context.Context, d Deps, actor Actor, repo RepoRef) bool {
139 return Can(ctx, d, actor, ActionRepoRead, repo).Allow
140 }
141
142 // Maybe404 maps a deny Decision to an HTTP status code that doesn't
143 // leak existence. The convention:
144 //
145 // - allow → 200 (caller handles)
146 // - deny on a private+anonymous (or non-collab+non-owner) → 404
147 // - any other deny → 403
148 //
149 // Handlers should call this after checking Allow, only when serving
150 // the rejection response.
151 func Maybe404(decision Decision, repo RepoRef, actor Actor) int {
152 if decision.Allow {
153 return http.StatusOK
154 }
155 // Anything that turns on private-visibility surfaces as 404.
156 if repo.IsPrivate() {
157 // Owner of a private repo getting denied is a real 403 (e.g.
158 // archived push). We approximate "owner" as having matching
159 // owner_user_id; collaborator status doesn't change the leak
160 // analysis since the row already exists in their world.
161 if actor.UserID != 0 && actor.UserID == repo.OwnerUserID {
162 return http.StatusForbidden
163 }
164 return http.StatusNotFound
165 }
166 return http.StatusForbidden
167 }
168
169 // effectiveRole computes the highest-effective role for actor on repo.
170 // Owner ⇒ implicit admin; collaborator role from repo_collaborators;
171 // nothing otherwise.
172 func effectiveRole(ctx context.Context, d Deps, actor Actor, repo RepoRef) (Role, error) {
173 if actor.UserID != 0 && actor.UserID == repo.OwnerUserID {
174 return RoleAdmin, nil
175 }
176 if actor.UserID == 0 {
177 return RoleNone, nil
178 }
179
180 // Cache check.
181 cache := cacheFromContext(ctx)
182 key := cacheKey{actorUserID: actor.UserID, repoID: repo.ID}
183 if r, ok := cacheGet(cache, key); ok {
184 return r, nil
185 }
186
187 // DB lookup.
188 if d.Pool == nil {
189 // In tests where a Deps without Pool is passed, fail closed.
190 return RoleNone, nil
191 }
192 q := policydb.New()
193 dbRole, err := q.GetCollabRole(ctx, d.Pool, policydb.GetCollabRoleParams{
194 RepoID: repo.ID,
195 UserID: actor.UserID,
196 })
197 if errors.Is(err, pgx.ErrNoRows) {
198 cachePut(cache, key, RoleNone)
199 return RoleNone, nil
200 }
201 if err != nil {
202 return RoleNone, err
203 }
204 r := roleFromDB(dbRole)
205 cachePut(cache, key, r)
206 return r, nil
207 }
208
209 // minRoleFor returns the minimum collaborator role required for the
210 // action against an existing repo. Owner is implicit admin, so this
211 // table is the single source of truth for "what role grants what."
212 //
213 //nolint:gocyclo // exhaustive switch is the readable shape here.
214 func minRoleFor(action Action) Role {
215 switch action {
216 // Read tier — public repos bypass via the early allow in Can; this
217 // is the gate for *private* read access.
218 case ActionRepoRead, ActionIssueRead, ActionPullRead:
219 return RoleRead
220
221 // Triage tier — issue-shape mutations without code write.
222 case ActionIssueClose, ActionIssueLabel, ActionIssueAssign:
223 return RoleTriage
224
225 // Write tier — code push, branch create, PR open/comment.
226 case ActionRepoWrite, ActionPullCreate, ActionPullReview, ActionPullClose,
227 ActionIssueCreate, ActionIssueComment:
228 return RoleWrite
229
230 // Maintain tier — most settings except dangerous ones.
231 case ActionRepoSettingsGeneral, ActionRepoSettingsBranches:
232 return RoleMaintain
233
234 // Admin tier — destructive and ownership-changing actions.
235 case ActionRepoAdmin, ActionRepoSettingsCollaborators,
236 ActionRepoArchive, ActionRepoDelete, ActionRepoTransfer, ActionRepoVisibility,
237 ActionPullMerge:
238 return RoleAdmin
239
240 // Login-required but no role required (any logged-in user).
241 case ActionStarCreate, ActionForkCreate:
242 return RoleNone
243
244 default:
245 // Unknown actions deny by default — RoleAdmin is impossibly
246 // high for a stranger so this acts as a fail-closed gate.
247 return RoleAdmin
248 }
249 }
250
251 // errBadAction is returned by Can when a caller passes a value that
252 // doesn't match any registered Action. Reserved for tests.
253 var errBadAction = errors.New("policy: unregistered action")
254