Gate org Actions API writes by plan
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
cda85ba8ed30551ecf44f8eebcb77b76d2e0da20- Parents
-
0945a74 - Tree
65c963a
cda85ba
cda85ba8ed30551ecf44f8eebcb77b76d2e0da200945a74
65c963ainternal/web/handlers/api/actions_secrets.gomodified@@ -14,6 +14,7 @@ import ( | ||
| 14 | 14 | "github.com/tenseleyFlow/shithub/internal/auth/pat" |
| 15 | 15 | "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 16 | 16 | "github.com/tenseleyFlow/shithub/internal/auth/sealbox" |
| 17 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 17 | 18 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 18 | 19 | ) |
| 19 | 20 | |
@@ -160,7 +161,11 @@ func (h *Handlers) actionsSecretsDeleteRepo(w http.ResponseWriter, r *http.Reque | ||
| 160 | 161 | // ─── org scope ────────────────────────────────────────────────────── |
| 161 | 162 | |
| 162 | 163 | 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) { | |
| 164 | 169 | return |
| 165 | 170 | } |
| 166 | 171 | h.writeSecretsPublicKey(w, r) |
@@ -171,6 +176,9 @@ func (h *Handlers) actionsSecretsListOrg(w http.ResponseWriter, r *http.Request) | ||
| 171 | 176 | if !ok { |
| 172 | 177 | return |
| 173 | 178 | } |
| 179 | + if !h.requireAPIOrgOwner(w, r, org) { | |
| 180 | + return | |
| 181 | + } | |
| 174 | 182 | rows, err := h.secretsDeps().List(r.Context(), secrets.OrgScope(org.ID)) |
| 175 | 183 | if err != nil { |
| 176 | 184 | 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) | ||
| 189 | 197 | if !ok { |
| 190 | 198 | return |
| 191 | 199 | } |
| 200 | + if !h.requireAPIOrgOwner(w, r, org) { | |
| 201 | + return | |
| 202 | + } | |
| 192 | 203 | name := chi.URLParam(r, "name") |
| 193 | 204 | rows, err := h.secretsDeps().List(r.Context(), secrets.OrgScope(org.ID)) |
| 194 | 205 | if err != nil { |
@@ -209,6 +220,12 @@ func (h *Handlers) actionsSecretsPutOrg(w http.ResponseWriter, r *http.Request) | ||
| 209 | 220 | if !ok { |
| 210 | 221 | return |
| 211 | 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 | + } | |
| 212 | 229 | plaintext, ok := h.decodeSecretBody(w, r) |
| 213 | 230 | if !ok { |
| 214 | 231 | return |
@@ -226,6 +243,9 @@ func (h *Handlers) actionsSecretsDeleteOrg(w http.ResponseWriter, r *http.Reques | ||
| 226 | 243 | if !ok { |
| 227 | 244 | return |
| 228 | 245 | } |
| 246 | + if !h.requireAPIOrgOwner(w, r, org) { | |
| 247 | + return | |
| 248 | + } | |
| 229 | 249 | if err := h.secretsDeps().Delete(r.Context(), secrets.OrgScope(org.ID), chi.URLParam(r, "name")); err != nil { |
| 230 | 250 | writeSecretsError(w, err) |
| 231 | 251 | return |
internal/web/handlers/api/actions_secrets_test.gomodified@@ -12,7 +12,9 @@ import ( | ||
| 12 | 12 | "log/slog" |
| 13 | 13 | "net/http" |
| 14 | 14 | "net/http/httptest" |
| 15 | + "strconv" | |
| 15 | 16 | "testing" |
| 17 | + "time" | |
| 16 | 18 | |
| 17 | 19 | "github.com/go-chi/chi/v5" |
| 18 | 20 | "github.com/jackc/pgx/v5/pgtype" |
@@ -25,7 +27,9 @@ import ( | ||
| 25 | 27 | "github.com/tenseleyFlow/shithub/internal/auth/sealbox" |
| 26 | 28 | "github.com/tenseleyFlow/shithub/internal/auth/secretbox" |
| 27 | 29 | "github.com/tenseleyFlow/shithub/internal/auth/throttle" |
| 30 | + "github.com/tenseleyFlow/shithub/internal/billing" | |
| 28 | 31 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 32 | + "github.com/tenseleyFlow/shithub/internal/orgs" | |
| 29 | 33 | "github.com/tenseleyFlow/shithub/internal/ratelimit" |
| 30 | 34 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
| 31 | 35 | "github.com/tenseleyFlow/shithub/internal/testing/dbtest" |
@@ -122,6 +126,68 @@ func newSecretsTestEnv(t *testing.T) *secretsTestEnv { | ||
| 122 | 126 | } |
| 123 | 127 | } |
| 124 | 128 | |
| 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 | + | |
| 125 | 191 | func TestActionsSecrets_PublicKeyEndpoint(t *testing.T) { |
| 126 | 192 | env := newSecretsTestEnv(t) |
| 127 | 193 | |
@@ -285,3 +351,51 @@ func TestActionsSecrets_PutRequiresRepoWrite(t *testing.T) { | ||
| 285 | 351 | t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String()) |
| 286 | 352 | } |
| 287 | 353 | } |
| 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 ( | ||
| 13 | 13 | "github.com/tenseleyFlow/shithub/internal/actions/variables" |
| 14 | 14 | "github.com/tenseleyFlow/shithub/internal/auth/pat" |
| 15 | 15 | "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 16 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 16 | 17 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 17 | 18 | ) |
| 18 | 19 | |
@@ -175,6 +176,9 @@ func (h *Handlers) actionsVariablesListOrg(w http.ResponseWriter, r *http.Reques | ||
| 175 | 176 | if !ok { |
| 176 | 177 | return |
| 177 | 178 | } |
| 179 | + if !h.requireAPIOrgOwner(w, r, org) { | |
| 180 | + return | |
| 181 | + } | |
| 178 | 182 | rows, err := h.variablesDeps().List(r.Context(), variables.OrgScope(org.ID)) |
| 179 | 183 | if err != nil { |
| 180 | 184 | 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 | ||
| 193 | 197 | if !ok { |
| 194 | 198 | return |
| 195 | 199 | } |
| 200 | + if !h.requireAPIOrgOwner(w, r, org) { | |
| 201 | + return | |
| 202 | + } | |
| 196 | 203 | v, err := h.variablesDeps().Get(r.Context(), variables.OrgScope(org.ID), chi.URLParam(r, "name")) |
| 197 | 204 | if err != nil { |
| 198 | 205 | writeVariablesError(w, err) |
@@ -206,6 +213,12 @@ func (h *Handlers) actionsVariablesCreateOrg(w http.ResponseWriter, r *http.Requ | ||
| 206 | 213 | if !ok { |
| 207 | 214 | return |
| 208 | 215 | } |
| 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 | + } | |
| 209 | 222 | var body variableCreateRequest |
| 210 | 223 | if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&body); err != nil { |
| 211 | 224 | writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) |
@@ -229,6 +242,12 @@ func (h *Handlers) actionsVariablesUpdateOrg(w http.ResponseWriter, r *http.Requ | ||
| 229 | 242 | if !ok { |
| 230 | 243 | return |
| 231 | 244 | } |
| 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 | + } | |
| 232 | 251 | var body variableUpdateRequest |
| 233 | 252 | if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&body); err != nil { |
| 234 | 253 | writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) |
@@ -252,6 +271,9 @@ func (h *Handlers) actionsVariablesDeleteOrg(w http.ResponseWriter, r *http.Requ | ||
| 252 | 271 | if !ok { |
| 253 | 272 | return |
| 254 | 273 | } |
| 274 | + if !h.requireAPIOrgOwner(w, r, org) { | |
| 275 | + return | |
| 276 | + } | |
| 255 | 277 | if err := h.variablesDeps().Delete(r.Context(), variables.OrgScope(org.ID), chi.URLParam(r, "name")); err != nil { |
| 256 | 278 | writeVariablesError(w, err) |
| 257 | 279 | return |
internal/web/handlers/api/actions_variables_test.gomodified@@ -4,10 +4,13 @@ package api_test | ||
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | 6 | "bytes" |
| 7 | + "context" | |
| 7 | 8 | "encoding/json" |
| 8 | 9 | "net/http" |
| 9 | 10 | "net/http/httptest" |
| 10 | 11 | "testing" |
| 12 | + | |
| 13 | + "github.com/tenseleyFlow/shithub/internal/actions/variables" | |
| 11 | 14 | ) |
| 12 | 15 | |
| 13 | 16 | func TestActionsVariables_CreateListGetUpdateDelete(t *testing.T) { |
@@ -109,3 +112,61 @@ func TestActionsVariables_CreateRequiresRepoWrite(t *testing.T) { | ||
| 109 | 112 | t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String()) |
| 110 | 113 | } |
| 111 | 114 | } |
| 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 | +} | |