Go · 8743 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package api
4
5 import (
6 "encoding/json"
7 "errors"
8 "net/http"
9 "strings"
10
11 "github.com/go-chi/chi/v5"
12 "github.com/jackc/pgx/v5"
13 "github.com/jackc/pgx/v5/pgtype"
14
15 "github.com/tenseleyFlow/shithub/internal/auth/pat"
16 "github.com/tenseleyFlow/shithub/internal/auth/policy"
17 policydb "github.com/tenseleyFlow/shithub/internal/auth/policy/sqlc"
18 "github.com/tenseleyFlow/shithub/internal/entitlements"
19 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
20 "github.com/tenseleyFlow/shithub/internal/web/middleware"
21 )
22
23 // mountCollaborators registers the S50 §10 collaborators REST surface.
24 //
25 // GET /api/v1/repos/{o}/{r}/collaborators list
26 // GET /api/v1/repos/{o}/{r}/collaborators/{username} membership probe (204 / 404)
27 // GET /api/v1/repos/{o}/{r}/collaborators/{username}/permission permission level
28 // PUT /api/v1/repos/{o}/{r}/collaborators/{username} add or upgrade
29 // DELETE /api/v1/repos/{o}/{r}/collaborators/{username} remove
30 //
31 // Scope: `repo:read` on GETs, `repo:write` on mutations. Policy gate
32 // for writes is `ActionRepoAdmin` — only repo owners + admin
33 // collaborators can grant / revoke (we layer the role check on top of
34 // the broader scope, since shithub PATs don't currently mint a
35 // separate admin-only scope). Owner ownership is surfaced from the
36 // `repo` row, not the collaborator table.
37 func (h *Handlers) mountCollaborators(r chi.Router) {
38 r.Group(func(r chi.Router) {
39 r.Use(middleware.RequireScope(pat.ScopeRepoRead))
40 r.Get("/api/v1/repos/{owner}/{repo}/collaborators", h.collaboratorsList)
41 r.Get("/api/v1/repos/{owner}/{repo}/collaborators/{username}", h.collaboratorMembership)
42 r.Get("/api/v1/repos/{owner}/{repo}/collaborators/{username}/permission", h.collaboratorPermission)
43 })
44 r.Group(func(r chi.Router) {
45 r.Use(middleware.RequireScope(pat.ScopeRepoWrite))
46 r.Put("/api/v1/repos/{owner}/{repo}/collaborators/{username}", h.collaboratorPut)
47 r.Delete("/api/v1/repos/{owner}/{repo}/collaborators/{username}", h.collaboratorDelete)
48 })
49 }
50
51 type collaboratorResponse struct {
52 UserID int64 `json:"user_id"`
53 Username string `json:"username"`
54 DisplayName string `json:"display_name,omitempty"`
55 Role string `json:"role"`
56 }
57
58 func (h *Handlers) collaboratorsList(w http.ResponseWriter, r *http.Request) {
59 repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
60 if !ok {
61 return
62 }
63 rows, err := policydb.New().ListCollabs(r.Context(), h.d.Pool, repo.ID)
64 if err != nil {
65 h.d.Logger.ErrorContext(r.Context(), "api: list collabs", "error", err)
66 writeAPIError(w, http.StatusInternalServerError, "list failed")
67 return
68 }
69 out := make([]collaboratorResponse, 0, len(rows))
70 for _, c := range rows {
71 out = append(out, collaboratorResponse{
72 UserID: c.UserID,
73 Username: c.Username,
74 DisplayName: c.DisplayName,
75 Role: string(c.Role),
76 })
77 }
78 writeJSON(w, http.StatusOK, out)
79 }
80
81 // collaboratorMembership mirrors GitHub's GET .../{username} which
82 // returns 204 if the user is a collaborator (any role) and 404
83 // otherwise. We don't include a body — clients use the 204 vs 404
84 // status code directly.
85 func (h *Handlers) collaboratorMembership(w http.ResponseWriter, r *http.Request) {
86 repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
87 if !ok {
88 return
89 }
90 user, ok := h.lookupCollabUser(w, r, chi.URLParam(r, "username"))
91 if !ok {
92 return
93 }
94 _, err := policydb.New().GetCollabRole(r.Context(), h.d.Pool, policydb.GetCollabRoleParams{
95 RepoID: repo.ID, UserID: user.ID,
96 })
97 if err != nil {
98 if errors.Is(err, pgx.ErrNoRows) {
99 writeAPIError(w, http.StatusNotFound, "not a collaborator")
100 return
101 }
102 h.d.Logger.ErrorContext(r.Context(), "api: get collab role", "error", err)
103 writeAPIError(w, http.StatusInternalServerError, "lookup failed")
104 return
105 }
106 w.WriteHeader(http.StatusNoContent)
107 }
108
109 func (h *Handlers) collaboratorPermission(w http.ResponseWriter, r *http.Request) {
110 repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
111 if !ok {
112 return
113 }
114 user, ok := h.lookupCollabUser(w, r, chi.URLParam(r, "username"))
115 if !ok {
116 return
117 }
118 role, err := policydb.New().GetCollabRole(r.Context(), h.d.Pool, policydb.GetCollabRoleParams{
119 RepoID: repo.ID, UserID: user.ID,
120 })
121 if err != nil {
122 if errors.Is(err, pgx.ErrNoRows) {
123 writeJSON(w, http.StatusOK, map[string]any{
124 "user": user.Username,
125 "permission": "none",
126 })
127 return
128 }
129 writeAPIError(w, http.StatusInternalServerError, "lookup failed")
130 return
131 }
132 writeJSON(w, http.StatusOK, map[string]any{
133 "user": user.Username,
134 "permission": string(role),
135 })
136 }
137
138 type collaboratorPutRequest struct {
139 Role string `json:"role"`
140 }
141
142 func (h *Handlers) collaboratorPut(w http.ResponseWriter, r *http.Request) {
143 repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoAdmin)
144 if !ok {
145 return
146 }
147 auth := middleware.PATAuthFromContext(r.Context())
148 user, ok := h.lookupCollabUser(w, r, chi.URLParam(r, "username"))
149 if !ok {
150 return
151 }
152 // Refuse to enroll the owner as a collaborator — they already
153 // implicitly hold every permission, so a row would be confusing
154 // at best (and could lock the legitimate owner into a downgraded
155 // role at worst).
156 if policy.NewRepoRefFromRepo(*repo).IsOwnedByUser(user.ID) {
157 writeAPIError(w, http.StatusUnprocessableEntity, "owner already has full access")
158 return
159 }
160 var body collaboratorPutRequest
161 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
162 writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
163 return
164 }
165 role, err := parseCollabRole(body.Role)
166 if err != nil {
167 writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
168 return
169 }
170 check, err := entitlements.CheckDirectPrivateCollaborator(r.Context(), entitlements.Deps{Pool: h.d.Pool}, repo.ID, user.ID)
171 if err != nil {
172 h.d.Logger.ErrorContext(r.Context(), "api: private collaborator entitlement check", "error", err)
173 writeAPIError(w, http.StatusInternalServerError, "collaborator entitlement check failed")
174 return
175 }
176 if err := check.Err(); err != nil {
177 writeAPIError(w, http.StatusPaymentRequired, err.Error())
178 return
179 }
180 if err := policydb.New().UpsertCollabRole(r.Context(), h.d.Pool, policydb.UpsertCollabRoleParams{
181 RepoID: repo.ID,
182 UserID: user.ID,
183 Role: role,
184 AddedByUserID: pgtype.Int8{
185 Int64: auth.UserID,
186 Valid: auth.UserID != 0,
187 },
188 }); err != nil {
189 h.d.Logger.ErrorContext(r.Context(), "api: upsert collab", "error", err)
190 writeAPIError(w, http.StatusInternalServerError, "upsert failed")
191 return
192 }
193 writeJSON(w, http.StatusOK, collaboratorResponse{
194 UserID: user.ID,
195 Username: user.Username,
196 DisplayName: user.DisplayName,
197 Role: string(role),
198 })
199 }
200
201 func (h *Handlers) collaboratorDelete(w http.ResponseWriter, r *http.Request) {
202 repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoAdmin)
203 if !ok {
204 return
205 }
206 user, ok := h.lookupCollabUser(w, r, chi.URLParam(r, "username"))
207 if !ok {
208 return
209 }
210 if err := policydb.New().RemoveCollab(r.Context(), h.d.Pool, policydb.RemoveCollabParams{
211 RepoID: repo.ID, UserID: user.ID,
212 }); err != nil {
213 h.d.Logger.ErrorContext(r.Context(), "api: remove collab", "error", err)
214 writeAPIError(w, http.StatusInternalServerError, "remove failed")
215 return
216 }
217 w.WriteHeader(http.StatusNoContent)
218 }
219
220 // lookupCollabUser resolves the username path segment to a user row.
221 // Returns 404 (with no existence-leak) if the username doesn't match.
222 func (h *Handlers) lookupCollabUser(w http.ResponseWriter, r *http.Request, raw string) (usersdb.User, bool) {
223 name := strings.TrimSpace(raw)
224 if name == "" {
225 writeAPIError(w, http.StatusNotFound, "user not found")
226 return usersdb.User{}, false
227 }
228 user, err := h.q.GetUserByUsername(r.Context(), h.d.Pool, name)
229 if err != nil {
230 writeAPIError(w, http.StatusNotFound, "user not found")
231 return usersdb.User{}, false
232 }
233 return user, true
234 }
235
236 // parseCollabRole maps the JSON-body role string onto the schema enum.
237 // GitHub accepts a `permission` field with values pull / triage / push /
238 // maintain / admin; we accept both the gh-style names and our internal
239 // names. Returns 422-friendly errors.
240 func parseCollabRole(raw string) (policydb.CollabRole, error) {
241 switch strings.ToLower(strings.TrimSpace(raw)) {
242 case "", "read", "pull":
243 return policydb.CollabRoleRead, nil
244 case "triage":
245 return policydb.CollabRoleTriage, nil
246 case "write", "push":
247 return policydb.CollabRoleWrite, nil
248 case "maintain":
249 return policydb.CollabRoleMaintain, nil
250 case "admin":
251 return policydb.CollabRoleAdmin, nil
252 }
253 return "", errors.New("role must be read|triage|write|maintain|admin (or gh: pull/push)")
254 }
255