tenseleyflow/shithub / e458660

Browse files

api/actions_secrets: sealed-box public-key + repo+org CRUD

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
e458660d3f829213bc5f1f1f5775385a75d6ad42
Parents
abe12ad
Tree
631ed24

1 changed file

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