Go · 11141 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 "time"
10
11 "github.com/go-chi/chi/v5"
12
13 "github.com/tenseleyFlow/shithub/internal/actions/secrets"
14 "github.com/tenseleyFlow/shithub/internal/auth/pat"
15 "github.com/tenseleyFlow/shithub/internal/auth/policy"
16 "github.com/tenseleyFlow/shithub/internal/auth/sealbox"
17 "github.com/tenseleyFlow/shithub/internal/entitlements"
18 "github.com/tenseleyFlow/shithub/internal/web/middleware"
19 )
20
21 // mountActionsSecrets registers the S50 §13 part 3 secrets REST
22 // surface (repo scope + org scope) plus the gh-shaped sealed-box
23 // public-key probe.
24 //
25 // GET /api/v1/repos/{o}/{r}/actions/secrets/public-key
26 // GET /api/v1/repos/{o}/{r}/actions/secrets
27 // GET /api/v1/repos/{o}/{r}/actions/secrets/{name}
28 // PUT /api/v1/repos/{o}/{r}/actions/secrets/{name}
29 // DELETE /api/v1/repos/{o}/{r}/actions/secrets/{name}
30 // GET /api/v1/orgs/{org}/actions/secrets/public-key
31 // GET /api/v1/orgs/{org}/actions/secrets
32 // GET /api/v1/orgs/{org}/actions/secrets/{name}
33 // PUT /api/v1/orgs/{org}/actions/secrets/{name}
34 // DELETE /api/v1/orgs/{org}/actions/secrets/{name}
35 //
36 // Scopes: `repo:read` for the repo GETs, `repo:write` for repo
37 // mutations. Org variants use `repo:read`/`repo:write` likewise —
38 // the policy gate inside resolveAPIOrg + the org-write check
39 // constrains who can act.
40 func (h *Handlers) mountActionsSecrets(r chi.Router) {
41 r.Group(func(r chi.Router) {
42 r.Use(middleware.RequireScope(pat.ScopeRepoRead))
43 r.Get("/api/v1/repos/{owner}/{repo}/actions/secrets/public-key", h.actionsSecretsPublicKeyRepo)
44 r.Get("/api/v1/repos/{owner}/{repo}/actions/secrets", h.actionsSecretsListRepo)
45 r.Get("/api/v1/repos/{owner}/{repo}/actions/secrets/{name}", h.actionsSecretsGetRepo)
46 r.Get("/api/v1/orgs/{org}/actions/secrets/public-key", h.actionsSecretsPublicKeyOrg)
47 r.Get("/api/v1/orgs/{org}/actions/secrets", h.actionsSecretsListOrg)
48 r.Get("/api/v1/orgs/{org}/actions/secrets/{name}", h.actionsSecretsGetOrg)
49 })
50 r.Group(func(r chi.Router) {
51 r.Use(middleware.RequireScope(pat.ScopeRepoWrite))
52 r.Put("/api/v1/repos/{owner}/{repo}/actions/secrets/{name}", h.actionsSecretsPutRepo)
53 r.Delete("/api/v1/repos/{owner}/{repo}/actions/secrets/{name}", h.actionsSecretsDeleteRepo)
54 r.Put("/api/v1/orgs/{org}/actions/secrets/{name}", h.actionsSecretsPutOrg)
55 r.Delete("/api/v1/orgs/{org}/actions/secrets/{name}", h.actionsSecretsDeleteOrg)
56 })
57 }
58
59 // ─── shared types ───────────────────────────────────────────────────
60
61 type secretsPublicKeyResponse struct {
62 KeyID string `json:"key_id"`
63 Key string `json:"key"`
64 }
65
66 type secretMetaResponse struct {
67 Name string `json:"name"`
68 CreatedAt string `json:"created_at"`
69 UpdatedAt string `json:"updated_at"`
70 }
71
72 type secretPutRequest struct {
73 EncryptedValue string `json:"encrypted_value"`
74 KeyID string `json:"key_id"`
75 }
76
77 func presentSecretMeta(m secrets.Meta) secretMetaResponse {
78 return secretMetaResponse{
79 Name: m.Name,
80 CreatedAt: m.CreatedAt.Time.UTC().Format(time.RFC3339),
81 UpdatedAt: m.UpdatedAt.Time.UTC().Format(time.RFC3339),
82 }
83 }
84
85 // ─── repo scope ─────────────────────────────────────────────────────
86
87 func (h *Handlers) actionsSecretsPublicKeyRepo(w http.ResponseWriter, r *http.Request) {
88 if _, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead); !ok {
89 return
90 }
91 h.writeSecretsPublicKey(w, r)
92 }
93
94 func (h *Handlers) actionsSecretsListRepo(w http.ResponseWriter, r *http.Request) {
95 repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
96 if !ok {
97 return
98 }
99 rows, err := h.secretsDeps().List(r.Context(), secrets.RepoScope(repo.ID))
100 if err != nil {
101 h.d.Logger.ErrorContext(r.Context(), "api: list repo secrets", "error", err)
102 writeAPIError(w, http.StatusInternalServerError, "list failed")
103 return
104 }
105 out := make([]secretMetaResponse, 0, len(rows))
106 for _, m := range rows {
107 out = append(out, presentSecretMeta(m))
108 }
109 writeJSON(w, http.StatusOK, out)
110 }
111
112 func (h *Handlers) actionsSecretsGetRepo(w http.ResponseWriter, r *http.Request) {
113 repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
114 if !ok {
115 return
116 }
117 name := chi.URLParam(r, "name")
118 rows, err := h.secretsDeps().List(r.Context(), secrets.RepoScope(repo.ID))
119 if err != nil {
120 writeAPIError(w, http.StatusInternalServerError, "lookup failed")
121 return
122 }
123 for _, m := range rows {
124 if m.Name == name {
125 writeJSON(w, http.StatusOK, presentSecretMeta(m))
126 return
127 }
128 }
129 writeAPIError(w, http.StatusNotFound, "secret not found")
130 }
131
132 func (h *Handlers) actionsSecretsPutRepo(w http.ResponseWriter, r *http.Request) {
133 repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoWrite)
134 if !ok {
135 return
136 }
137 plaintext, ok := h.decodeSecretBody(w, r)
138 if !ok {
139 return
140 }
141 auth := middleware.PATAuthFromContext(r.Context())
142 if err := h.secretsDeps().Set(r.Context(), secrets.RepoScope(repo.ID), chi.URLParam(r, "name"), plaintext, auth.UserID); err != nil {
143 writeSecretsError(w, err)
144 return
145 }
146 w.WriteHeader(http.StatusNoContent)
147 }
148
149 func (h *Handlers) actionsSecretsDeleteRepo(w http.ResponseWriter, r *http.Request) {
150 repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoWrite)
151 if !ok {
152 return
153 }
154 if err := h.secretsDeps().Delete(r.Context(), secrets.RepoScope(repo.ID), chi.URLParam(r, "name")); err != nil {
155 writeSecretsError(w, err)
156 return
157 }
158 w.WriteHeader(http.StatusNoContent)
159 }
160
161 // ─── org scope ──────────────────────────────────────────────────────
162
163 func (h *Handlers) actionsSecretsPublicKeyOrg(w http.ResponseWriter, r *http.Request) {
164 org, ok := h.resolveAPIOrg(w, r, chi.URLParam(r, "org"))
165 if !ok {
166 return
167 }
168 if !h.requireAPIOrgOwner(w, r, org) {
169 return
170 }
171 h.writeSecretsPublicKey(w, r)
172 }
173
174 func (h *Handlers) actionsSecretsListOrg(w http.ResponseWriter, r *http.Request) {
175 org, ok := h.resolveAPIOrg(w, r, chi.URLParam(r, "org"))
176 if !ok {
177 return
178 }
179 if !h.requireAPIOrgOwner(w, r, org) {
180 return
181 }
182 rows, err := h.secretsDeps().List(r.Context(), secrets.OrgScope(org.ID))
183 if err != nil {
184 h.d.Logger.ErrorContext(r.Context(), "api: list org secrets", "error", err)
185 writeAPIError(w, http.StatusInternalServerError, "list failed")
186 return
187 }
188 out := make([]secretMetaResponse, 0, len(rows))
189 for _, m := range rows {
190 out = append(out, presentSecretMeta(m))
191 }
192 writeJSON(w, http.StatusOK, out)
193 }
194
195 func (h *Handlers) actionsSecretsGetOrg(w http.ResponseWriter, r *http.Request) {
196 org, ok := h.resolveAPIOrg(w, r, chi.URLParam(r, "org"))
197 if !ok {
198 return
199 }
200 if !h.requireAPIOrgOwner(w, r, org) {
201 return
202 }
203 name := chi.URLParam(r, "name")
204 rows, err := h.secretsDeps().List(r.Context(), secrets.OrgScope(org.ID))
205 if err != nil {
206 writeAPIError(w, http.StatusInternalServerError, "lookup failed")
207 return
208 }
209 for _, m := range rows {
210 if m.Name == name {
211 writeJSON(w, http.StatusOK, presentSecretMeta(m))
212 return
213 }
214 }
215 writeAPIError(w, http.StatusNotFound, "secret not found")
216 }
217
218 func (h *Handlers) actionsSecretsPutOrg(w http.ResponseWriter, r *http.Request) {
219 org, ok := h.resolveAPIOrg(w, r, chi.URLParam(r, "org"))
220 if !ok {
221 return
222 }
223 if !h.requireAPIOrgOwner(w, r, org) {
224 return
225 }
226 if !h.requireOrgFeature(w, r, org, entitlements.FeatureOrgActionsSecrets, "Organization Actions secrets") {
227 return
228 }
229 plaintext, ok := h.decodeSecretBody(w, r)
230 if !ok {
231 return
232 }
233 auth := middleware.PATAuthFromContext(r.Context())
234 if err := h.secretsDeps().Set(r.Context(), secrets.OrgScope(org.ID), chi.URLParam(r, "name"), plaintext, auth.UserID); err != nil {
235 writeSecretsError(w, err)
236 return
237 }
238 w.WriteHeader(http.StatusNoContent)
239 }
240
241 func (h *Handlers) actionsSecretsDeleteOrg(w http.ResponseWriter, r *http.Request) {
242 org, ok := h.resolveAPIOrg(w, r, chi.URLParam(r, "org"))
243 if !ok {
244 return
245 }
246 if !h.requireAPIOrgOwner(w, r, org) {
247 return
248 }
249 if err := h.secretsDeps().Delete(r.Context(), secrets.OrgScope(org.ID), chi.URLParam(r, "name")); err != nil {
250 writeSecretsError(w, err)
251 return
252 }
253 w.WriteHeader(http.StatusNoContent)
254 }
255
256 // ─── helpers ────────────────────────────────────────────────────────
257
258 // writeSecretsPublicKey emits the sealed-box public key + key_id.
259 // Anonymous-readable on the repo path because the policy gate has
260 // already confirmed `ActionRepoRead`; the org variant goes through
261 // resolveAPIOrg's gate.
262 func (h *Handlers) writeSecretsPublicKey(w http.ResponseWriter, r *http.Request) {
263 if h.d.SecretsBox == nil {
264 writeAPIError(w, http.StatusServiceUnavailable, "secrets keypair not configured")
265 return
266 }
267 writeJSON(w, http.StatusOK, secretsPublicKeyResponse{
268 KeyID: h.d.SecretsBox.KeyID(),
269 Key: h.d.SecretsBox.PublicKeyBase64(),
270 })
271 }
272
273 // decodeSecretBody parses the gh-shaped PUT body and returns the
274 // plaintext (after sealed-box decoding). Maps malformed body / stale
275 // key_id / decrypt-failure into clean 400/422 responses. Returns
276 // (nil, false) and writes the response on any error.
277 func (h *Handlers) decodeSecretBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
278 if h.d.SecretsBox == nil {
279 writeAPIError(w, http.StatusServiceUnavailable, "secrets keypair not configured")
280 return nil, false
281 }
282 var body secretPutRequest
283 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256*1024)).Decode(&body); err != nil {
284 writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
285 return nil, false
286 }
287 if body.EncryptedValue == "" {
288 writeAPIError(w, http.StatusUnprocessableEntity, "encrypted_value is required")
289 return nil, false
290 }
291 if body.KeyID != "" && body.KeyID != h.d.SecretsBox.KeyID() {
292 writeAPIError(w, http.StatusUnprocessableEntity, "stale key_id; re-fetch /actions/secrets/public-key")
293 return nil, false
294 }
295 plaintext, err := h.d.SecretsBox.OpenAnonymous(body.EncryptedValue)
296 if err != nil {
297 switch {
298 case errors.Is(err, sealbox.ErrCiphertextMalformed):
299 writeAPIError(w, http.StatusBadRequest, "encrypted_value not valid base64")
300 default:
301 writeAPIError(w, http.StatusUnprocessableEntity, "encrypted_value did not decrypt")
302 }
303 return nil, false
304 }
305 return plaintext, true
306 }
307
308 func (h *Handlers) secretsDeps() secrets.Deps {
309 return secrets.Deps{Pool: h.d.Pool, Box: h.d.SecretBox, Logger: h.d.Logger}
310 }
311
312 func writeSecretsError(w http.ResponseWriter, err error) {
313 switch {
314 case errors.Is(err, secrets.ErrNotFound):
315 writeAPIError(w, http.StatusNotFound, "secret not found")
316 case errors.Is(err, secrets.ErrInvalidName):
317 writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
318 case errors.Is(err, secrets.ErrEmptyValue):
319 writeAPIError(w, http.StatusUnprocessableEntity, "value must be non-empty")
320 default:
321 writeAPIError(w, http.StatusInternalServerError, "internal error")
322 }
323 }
324