tenseleyflow/shithub / cda85ba

Browse files

Gate org Actions API writes by plan

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
cda85ba8ed30551ecf44f8eebcb77b76d2e0da20
Parents
0945a74
Tree
65c963a

5 changed files

StatusFile+-
M internal/web/handlers/api/actions_secrets.go 21 1
M internal/web/handlers/api/actions_secrets_test.go 114 0
M internal/web/handlers/api/actions_variables.go 22 0
M internal/web/handlers/api/actions_variables_test.go 61 0
A internal/web/handlers/api/org_entitlements.go 66 0
internal/web/handlers/api/actions_secrets.gomodified
@@ -14,6 +14,7 @@ import (
1414
 	"github.com/tenseleyFlow/shithub/internal/auth/pat"
1515
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
1616
 	"github.com/tenseleyFlow/shithub/internal/auth/sealbox"
17
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
1718
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
1819
 )
1920
 
@@ -160,7 +161,11 @@ func (h *Handlers) actionsSecretsDeleteRepo(w http.ResponseWriter, r *http.Reque
160161
 // ─── org scope ──────────────────────────────────────────────────────
161162
 
162163
 func (h *Handlers) actionsSecretsPublicKeyOrg(w http.ResponseWriter, r *http.Request) {
163
-	if _, ok := h.resolveAPIOrg(w, r, chi.URLParam(r, "org")); !ok {
164
+	org, ok := h.resolveAPIOrg(w, r, chi.URLParam(r, "org"))
165
+	if !ok {
166
+		return
167
+	}
168
+	if !h.requireAPIOrgOwner(w, r, org) {
164169
 		return
165170
 	}
166171
 	h.writeSecretsPublicKey(w, r)
@@ -171,6 +176,9 @@ func (h *Handlers) actionsSecretsListOrg(w http.ResponseWriter, r *http.Request)
171176
 	if !ok {
172177
 		return
173178
 	}
179
+	if !h.requireAPIOrgOwner(w, r, org) {
180
+		return
181
+	}
174182
 	rows, err := h.secretsDeps().List(r.Context(), secrets.OrgScope(org.ID))
175183
 	if err != nil {
176184
 		h.d.Logger.ErrorContext(r.Context(), "api: list org secrets", "error", err)
@@ -189,6 +197,9 @@ func (h *Handlers) actionsSecretsGetOrg(w http.ResponseWriter, r *http.Request)
189197
 	if !ok {
190198
 		return
191199
 	}
200
+	if !h.requireAPIOrgOwner(w, r, org) {
201
+		return
202
+	}
192203
 	name := chi.URLParam(r, "name")
193204
 	rows, err := h.secretsDeps().List(r.Context(), secrets.OrgScope(org.ID))
194205
 	if err != nil {
@@ -209,6 +220,12 @@ func (h *Handlers) actionsSecretsPutOrg(w http.ResponseWriter, r *http.Request)
209220
 	if !ok {
210221
 		return
211222
 	}
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
+	}
212229
 	plaintext, ok := h.decodeSecretBody(w, r)
213230
 	if !ok {
214231
 		return
@@ -226,6 +243,9 @@ func (h *Handlers) actionsSecretsDeleteOrg(w http.ResponseWriter, r *http.Reques
226243
 	if !ok {
227244
 		return
228245
 	}
246
+	if !h.requireAPIOrgOwner(w, r, org) {
247
+		return
248
+	}
229249
 	if err := h.secretsDeps().Delete(r.Context(), secrets.OrgScope(org.ID), chi.URLParam(r, "name")); err != nil {
230250
 		writeSecretsError(w, err)
231251
 		return
internal/web/handlers/api/actions_secrets_test.gomodified
@@ -12,7 +12,9 @@ import (
1212
 	"log/slog"
1313
 	"net/http"
1414
 	"net/http/httptest"
15
+	"strconv"
1516
 	"testing"
17
+	"time"
1618
 
1719
 	"github.com/go-chi/chi/v5"
1820
 	"github.com/jackc/pgx/v5/pgtype"
@@ -25,7 +27,9 @@ import (
2527
 	"github.com/tenseleyFlow/shithub/internal/auth/sealbox"
2628
 	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
2729
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
30
+	"github.com/tenseleyFlow/shithub/internal/billing"
2831
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
32
+	"github.com/tenseleyFlow/shithub/internal/orgs"
2933
 	"github.com/tenseleyFlow/shithub/internal/ratelimit"
3034
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
3135
 	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
@@ -122,6 +126,68 @@ func newSecretsTestEnv(t *testing.T) *secretsTestEnv {
122126
 	}
123127
 }
124128
 
129
+func (e *secretsTestEnv) seedOrg(t *testing.T, slug string) int64 {
130
+	t.Helper()
131
+	org, err := orgs.Create(context.Background(), orgs.Deps{Pool: e.pool}, orgs.CreateParams{
132
+		Slug:            slug,
133
+		DisplayName:     slug,
134
+		CreatedByUserID: e.userID,
135
+	})
136
+	if err != nil {
137
+		t.Fatalf("create org: %v", err)
138
+	}
139
+	return org.ID
140
+}
141
+
142
+func (e *secretsTestEnv) activateTeamPlan(t *testing.T, orgID int64) {
143
+	t.Helper()
144
+	now := time.Now().UTC().Truncate(time.Second)
145
+	_, err := billing.ApplySubscriptionSnapshot(context.Background(), billing.Deps{Pool: e.pool}, billing.SubscriptionSnapshot{
146
+		OrgID:                    orgID,
147
+		Plan:                     billing.PlanTeam,
148
+		Status:                   billing.SubscriptionStatusActive,
149
+		StripeSubscriptionID:     "sub_api_actions_" + strconv.FormatInt(orgID, 10),
150
+		StripeSubscriptionItemID: "si_api_actions_" + strconv.FormatInt(orgID, 10),
151
+		CurrentPeriodStart:       now,
152
+		CurrentPeriodEnd:         now.Add(30 * 24 * time.Hour),
153
+		LastWebhookEventID:       "evt_api_actions_" + strconv.FormatInt(orgID, 10),
154
+	})
155
+	if err != nil {
156
+		t.Fatalf("activate team plan: %v", err)
157
+	}
158
+}
159
+
160
+func (e *secretsTestEnv) encryptedSecretBody(t *testing.T) []byte {
161
+	t.Helper()
162
+	pubReq := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/actions/secrets/public-key", nil)
163
+	pubReq.Header.Set("Authorization", "Bearer "+e.tokenRO)
164
+	pubRR := httptest.NewRecorder()
165
+	e.router.ServeHTTP(pubRR, pubReq)
166
+	if pubRR.Code != http.StatusOK {
167
+		t.Fatalf("public key status: got %d; body=%s", pubRR.Code, pubRR.Body.String())
168
+	}
169
+	var pk apiSecretsPublicKey
170
+	if err := json.Unmarshal(pubRR.Body.Bytes(), &pk); err != nil {
171
+		t.Fatalf("decode public key: %v", err)
172
+	}
173
+	pubBytes, err := base64.StdEncoding.DecodeString(pk.Key)
174
+	if err != nil {
175
+		t.Fatalf("decode public key bytes: %v", err)
176
+	}
177
+	var pubKey [32]byte
178
+	copy(pubKey[:], pubBytes)
179
+
180
+	sealed, err := box.SealAnonymous(nil, []byte("org-secret-value"), &pubKey, rand.Reader)
181
+	if err != nil {
182
+		t.Fatalf("SealAnonymous: %v", err)
183
+	}
184
+	body, _ := json.Marshal(map[string]string{
185
+		"encrypted_value": base64.StdEncoding.EncodeToString(sealed),
186
+		"key_id":          pk.KeyID,
187
+	})
188
+	return body
189
+}
190
+
125191
 func TestActionsSecrets_PublicKeyEndpoint(t *testing.T) {
126192
 	env := newSecretsTestEnv(t)
127193
 
@@ -285,3 +351,51 @@ func TestActionsSecrets_PutRequiresRepoWrite(t *testing.T) {
285351
 		t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
286352
 	}
287353
 }
354
+
355
+func TestActionsSecrets_OrgPutRequiresTeamEntitlement(t *testing.T) {
356
+	env := newSecretsTestEnv(t)
357
+	orgID := env.seedOrg(t, "acme")
358
+	body := env.encryptedSecretBody(t)
359
+
360
+	req := httptest.NewRequest(http.MethodPut, "/api/v1/orgs/acme/actions/secrets/MY_TOKEN", bytes.NewReader(body))
361
+	req.Header.Set("Authorization", "Bearer "+env.tokenRW)
362
+	req.Header.Set("Content-Type", "application/json")
363
+	rr := httptest.NewRecorder()
364
+	env.router.ServeHTTP(rr, req)
365
+	if rr.Code != http.StatusPaymentRequired {
366
+		t.Fatalf("free org PUT: got %d, want 402; body=%s", rr.Code, rr.Body.String())
367
+	}
368
+	var count int
369
+	if err := env.pool.QueryRow(context.Background(), `SELECT count(*) FROM workflow_secrets WHERE org_id = $1`, orgID).Scan(&count); err != nil {
370
+		t.Fatalf("count org secrets: %v", err)
371
+	}
372
+	if count != 0 {
373
+		t.Fatalf("free org secret count=%d, want 0", count)
374
+	}
375
+
376
+	env.activateTeamPlan(t, orgID)
377
+	req = httptest.NewRequest(http.MethodPut, "/api/v1/orgs/acme/actions/secrets/MY_TOKEN", bytes.NewReader(body))
378
+	req.Header.Set("Authorization", "Bearer "+env.tokenRW)
379
+	req.Header.Set("Content-Type", "application/json")
380
+	rr = httptest.NewRecorder()
381
+	env.router.ServeHTTP(rr, req)
382
+	if rr.Code != http.StatusNoContent {
383
+		t.Fatalf("team org PUT: got %d, want 204; body=%s", rr.Code, rr.Body.String())
384
+	}
385
+}
386
+
387
+func TestActionsSecrets_OrgDeleteAllowedWithoutTeamEntitlement(t *testing.T) {
388
+	env := newSecretsTestEnv(t)
389
+	orgID := env.seedOrg(t, "acme")
390
+	if err := (secrets.Deps{Pool: env.pool, Box: env.secretBox}).Set(context.Background(), secrets.OrgScope(orgID), "OLD_TOKEN", []byte("legacy"), env.userID); err != nil {
391
+		t.Fatalf("seed org secret: %v", err)
392
+	}
393
+
394
+	req := httptest.NewRequest(http.MethodDelete, "/api/v1/orgs/acme/actions/secrets/OLD_TOKEN", nil)
395
+	req.Header.Set("Authorization", "Bearer "+env.tokenRW)
396
+	rr := httptest.NewRecorder()
397
+	env.router.ServeHTTP(rr, req)
398
+	if rr.Code != http.StatusNoContent {
399
+		t.Fatalf("delete: got %d, want 204; body=%s", rr.Code, rr.Body.String())
400
+	}
401
+}
internal/web/handlers/api/actions_variables.gomodified
@@ -13,6 +13,7 @@ import (
1313
 	"github.com/tenseleyFlow/shithub/internal/actions/variables"
1414
 	"github.com/tenseleyFlow/shithub/internal/auth/pat"
1515
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
16
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
1617
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
1718
 )
1819
 
@@ -175,6 +176,9 @@ func (h *Handlers) actionsVariablesListOrg(w http.ResponseWriter, r *http.Reques
175176
 	if !ok {
176177
 		return
177178
 	}
179
+	if !h.requireAPIOrgOwner(w, r, org) {
180
+		return
181
+	}
178182
 	rows, err := h.variablesDeps().List(r.Context(), variables.OrgScope(org.ID))
179183
 	if err != nil {
180184
 		h.d.Logger.ErrorContext(r.Context(), "api: list org variables", "error", err)
@@ -193,6 +197,9 @@ func (h *Handlers) actionsVariablesGetOrg(w http.ResponseWriter, r *http.Request
193197
 	if !ok {
194198
 		return
195199
 	}
200
+	if !h.requireAPIOrgOwner(w, r, org) {
201
+		return
202
+	}
196203
 	v, err := h.variablesDeps().Get(r.Context(), variables.OrgScope(org.ID), chi.URLParam(r, "name"))
197204
 	if err != nil {
198205
 		writeVariablesError(w, err)
@@ -206,6 +213,12 @@ func (h *Handlers) actionsVariablesCreateOrg(w http.ResponseWriter, r *http.Requ
206213
 	if !ok {
207214
 		return
208215
 	}
216
+	if !h.requireAPIOrgOwner(w, r, org) {
217
+		return
218
+	}
219
+	if !h.requireOrgFeature(w, r, org, entitlements.FeatureOrgActionsVariables, "Organization Actions variables") {
220
+		return
221
+	}
209222
 	var body variableCreateRequest
210223
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&body); err != nil {
211224
 		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
@@ -229,6 +242,12 @@ func (h *Handlers) actionsVariablesUpdateOrg(w http.ResponseWriter, r *http.Requ
229242
 	if !ok {
230243
 		return
231244
 	}
245
+	if !h.requireAPIOrgOwner(w, r, org) {
246
+		return
247
+	}
248
+	if !h.requireOrgFeature(w, r, org, entitlements.FeatureOrgActionsVariables, "Organization Actions variables") {
249
+		return
250
+	}
232251
 	var body variableUpdateRequest
233252
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&body); err != nil {
234253
 		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
@@ -252,6 +271,9 @@ func (h *Handlers) actionsVariablesDeleteOrg(w http.ResponseWriter, r *http.Requ
252271
 	if !ok {
253272
 		return
254273
 	}
274
+	if !h.requireAPIOrgOwner(w, r, org) {
275
+		return
276
+	}
255277
 	if err := h.variablesDeps().Delete(r.Context(), variables.OrgScope(org.ID), chi.URLParam(r, "name")); err != nil {
256278
 		writeVariablesError(w, err)
257279
 		return
internal/web/handlers/api/actions_variables_test.gomodified
@@ -4,10 +4,13 @@ package api_test
44
 
55
 import (
66
 	"bytes"
7
+	"context"
78
 	"encoding/json"
89
 	"net/http"
910
 	"net/http/httptest"
1011
 	"testing"
12
+
13
+	"github.com/tenseleyFlow/shithub/internal/actions/variables"
1114
 )
1215
 
1316
 func TestActionsVariables_CreateListGetUpdateDelete(t *testing.T) {
@@ -109,3 +112,61 @@ func TestActionsVariables_CreateRequiresRepoWrite(t *testing.T) {
109112
 		t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
110113
 	}
111114
 }
115
+
116
+func TestActionsVariables_OrgWritesRequireTeamEntitlement(t *testing.T) {
117
+	env := newSecretsTestEnv(t)
118
+	orgID := env.seedOrg(t, "acme")
119
+
120
+	body, _ := json.Marshal(map[string]string{"name": "API_URL", "value": "https://api.example"})
121
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/orgs/acme/actions/variables", bytes.NewReader(body))
122
+	req.Header.Set("Authorization", "Bearer "+env.tokenRW)
123
+	req.Header.Set("Content-Type", "application/json")
124
+	rr := httptest.NewRecorder()
125
+	env.router.ServeHTTP(rr, req)
126
+	if rr.Code != http.StatusPaymentRequired {
127
+		t.Fatalf("free org create: got %d, want 402; body=%s", rr.Code, rr.Body.String())
128
+	}
129
+	var count int
130
+	if err := env.pool.QueryRow(context.Background(), `SELECT count(*) FROM actions_variables WHERE org_id = $1`, orgID).Scan(&count); err != nil {
131
+		t.Fatalf("count org variables: %v", err)
132
+	}
133
+	if count != 0 {
134
+		t.Fatalf("free org variable count=%d, want 0", count)
135
+	}
136
+
137
+	env.activateTeamPlan(t, orgID)
138
+	req = httptest.NewRequest(http.MethodPost, "/api/v1/orgs/acme/actions/variables", bytes.NewReader(body))
139
+	req.Header.Set("Authorization", "Bearer "+env.tokenRW)
140
+	req.Header.Set("Content-Type", "application/json")
141
+	rr = httptest.NewRecorder()
142
+	env.router.ServeHTTP(rr, req)
143
+	if rr.Code != http.StatusCreated {
144
+		t.Fatalf("team org create: got %d, want 201; body=%s", rr.Code, rr.Body.String())
145
+	}
146
+}
147
+
148
+func TestActionsVariables_OrgUpdateBlockedButDeleteAllowedWithoutTeam(t *testing.T) {
149
+	env := newSecretsTestEnv(t)
150
+	orgID := env.seedOrg(t, "acme")
151
+	if err := (variables.Deps{Pool: env.pool}).Set(context.Background(), variables.OrgScope(orgID), "API_URL", "https://api.example", env.userID); err != nil {
152
+		t.Fatalf("seed org variable: %v", err)
153
+	}
154
+
155
+	updateBody, _ := json.Marshal(map[string]string{"value": "https://api.example/v2"})
156
+	req := httptest.NewRequest(http.MethodPatch, "/api/v1/orgs/acme/actions/variables/API_URL", bytes.NewReader(updateBody))
157
+	req.Header.Set("Authorization", "Bearer "+env.tokenRW)
158
+	req.Header.Set("Content-Type", "application/json")
159
+	rr := httptest.NewRecorder()
160
+	env.router.ServeHTTP(rr, req)
161
+	if rr.Code != http.StatusPaymentRequired {
162
+		t.Fatalf("free org update: got %d, want 402; body=%s", rr.Code, rr.Body.String())
163
+	}
164
+
165
+	req = httptest.NewRequest(http.MethodDelete, "/api/v1/orgs/acme/actions/variables/API_URL", nil)
166
+	req.Header.Set("Authorization", "Bearer "+env.tokenRW)
167
+	rr = httptest.NewRecorder()
168
+	env.router.ServeHTTP(rr, req)
169
+	if rr.Code != http.StatusNoContent {
170
+		t.Fatalf("free org delete: got %d, want 204; body=%s", rr.Code, rr.Body.String())
171
+	}
172
+}
internal/web/handlers/api/org_entitlements.goadded
@@ -0,0 +1,66 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api
4
+
5
+import (
6
+	"net/http"
7
+
8
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
9
+	"github.com/tenseleyFlow/shithub/internal/orgs"
10
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
11
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
12
+)
13
+
14
+func (h *Handlers) requireAPIOrgOwner(w http.ResponseWriter, r *http.Request, org orgsdb.Org) bool {
15
+	auth := middleware.PATAuthFromContext(r.Context())
16
+	if auth.UserID == 0 {
17
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
18
+		return false
19
+	}
20
+	if auth.IsSuspended {
21
+		writeAPIError(w, http.StatusForbidden, "account is suspended")
22
+		return false
23
+	}
24
+	if auth.IsSiteAdmin {
25
+		return true
26
+	}
27
+
28
+	odeps := orgs.Deps{Pool: h.d.Pool, Logger: h.d.Logger}
29
+	isMember, err := orgs.IsMember(r.Context(), odeps, org.ID, auth.UserID)
30
+	if err != nil {
31
+		h.d.Logger.ErrorContext(r.Context(), "api: org member check", "org_id", org.ID, "error", err)
32
+		writeAPIError(w, http.StatusInternalServerError, "authorization failed")
33
+		return false
34
+	}
35
+	if !isMember {
36
+		writeAPIError(w, http.StatusNotFound, "org not found")
37
+		return false
38
+	}
39
+
40
+	isOwner, err := orgs.IsOwner(r.Context(), odeps, org.ID, auth.UserID)
41
+	if err != nil {
42
+		h.d.Logger.ErrorContext(r.Context(), "api: org owner check", "org_id", org.ID, "error", err)
43
+		writeAPIError(w, http.StatusInternalServerError, "authorization failed")
44
+		return false
45
+	}
46
+	if !isOwner {
47
+		writeAPIError(w, http.StatusForbidden, "organization owner access required")
48
+		return false
49
+	}
50
+	return true
51
+}
52
+
53
+func (h *Handlers) requireOrgFeature(w http.ResponseWriter, r *http.Request, org orgsdb.Org, feature entitlements.Feature, label string) bool {
54
+	decision, err := entitlements.CheckOrgFeature(r.Context(), entitlements.Deps{Pool: h.d.Pool}, org.ID, feature)
55
+	if err != nil {
56
+		h.d.Logger.ErrorContext(r.Context(), "api: org entitlement check", "org_id", org.ID, "feature", feature, "error", err)
57
+		writeAPIError(w, http.StatusInternalServerError, "entitlement check failed")
58
+		return false
59
+	}
60
+	if !decision.Allowed {
61
+		banner := decision.UpgradeBanner(label, org.Slug)
62
+		writeAPIError(w, banner.StatusCode, banner.Message)
63
+		return false
64
+	}
65
+	return true
66
+}