tenseleyflow/shithub / 3cbc8b3

Browse files

web/handlers/repo: branch protection gating fires on personal repos in report-only mode

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
3cbc8b32c5ac944e5b2cc57350cef68c64d188c3
Parents
8b97c97
Tree
a76857f

1 changed file

StatusFile+-
M internal/web/handlers/repo/settings_branches.go 68 13
internal/web/handlers/repo/settings_branches.gomodified
@@ -14,6 +14,7 @@ import (
1414
 
1515
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
1616
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
17
+	"github.com/tenseleyFlow/shithub/internal/billing"
1718
 	"github.com/tenseleyFlow/shithub/internal/entitlements"
1819
 	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
1920
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
@@ -192,31 +193,85 @@ func (h *Handlers) settingsBranchesUpsert(w http.ResponseWriter, r *http.Request
192193
 	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/branches?notice=saved", http.StatusSeeOther)
193194
 }
194195
 
196
+// branchProtectionEntitlementNotice gates the required-reviewers
197
+// and advanced-branch-protection knobs on private repos. PRO05
198
+// extended it to personal private repos in report-only mode — a
199
+// user-kind would-deny logs an "entitlements.report_only_deny"
200
+// event but does not block the save. PRO07 flips per-feature
201
+// enforce on for user kind once production telemetry confirms no
202
+// existing Free user is over-quota.
203
+//
204
+// Public repos and non-private personal/org repos return empty
205
+// (no gating).
195206
 func (h *Handlers) branchProtectionEntitlementNotice(ctx context.Context, row reposdb.Repo, requiredReviews int, dismissStale bool, requiredChecks []string, dismissStaleChecks bool) (string, error) {
196
-	if !row.OwnerOrgID.Valid || row.Visibility != reposdb.RepoVisibilityPrivate {
207
+	if row.Visibility != reposdb.RepoVisibilityPrivate {
208
+		return "", nil
209
+	}
210
+	principal, ok := principalFromRepo(row)
211
+	if !ok {
197212
 		return "", nil
198213
 	}
199214
 	if requiredReviews > 0 || dismissStale {
200
-		decision, err := entitlements.CheckOrgFeature(ctx, entitlements.Deps{Pool: h.d.Pool}, row.OwnerOrgID.Int64, entitlements.FeatureOrgRequiredReviewers)
201
-		if err != nil {
202
-			return "", err
203
-		}
204
-		if !decision.Allowed {
205
-			return branchProtectionNoticeCode(decision, true), nil
215
+		code, err := h.evaluateBranchProtectionFeature(ctx, principal, entitlements.FeatureRequiredReviewers, true)
216
+		if err != nil || code != "" {
217
+			return code, err
206218
 		}
207219
 	}
208220
 	if len(requiredChecks) > 0 || dismissStaleChecks {
209
-		decision, err := entitlements.CheckOrgFeature(ctx, entitlements.Deps{Pool: h.d.Pool}, row.OwnerOrgID.Int64, entitlements.FeatureOrgAdvancedBranchProtection)
210
-		if err != nil {
211
-			return "", err
212
-		}
213
-		if !decision.Allowed {
214
-			return branchProtectionNoticeCode(decision, false), nil
221
+		code, err := h.evaluateBranchProtectionFeature(ctx, principal, entitlements.FeatureAdvancedBranchProtection, false)
222
+		if err != nil || code != "" {
223
+			return code, err
215224
 		}
216225
 	}
217226
 	return "", nil
218227
 }
219228
 
229
+// principalFromRepo returns the billing principal that owns the
230
+// repo. Personal repos resolve to SubjectKindUser; org-owned to
231
+// SubjectKindOrg. Returns ok=false when neither owner column is
232
+// populated (shouldn't happen for a valid repo row).
233
+func principalFromRepo(row reposdb.Repo) (billing.Principal, bool) {
234
+	if row.OwnerOrgID.Valid {
235
+		return billing.PrincipalForOrg(row.OwnerOrgID.Int64), true
236
+	}
237
+	if row.OwnerUserID.Valid {
238
+		return billing.PrincipalForUser(row.OwnerUserID.Int64), true
239
+	}
240
+	return billing.Principal{}, false
241
+}
242
+
243
+// evaluateBranchProtectionFeature checks the feature for the given
244
+// principal. Returns the notice code (empty when allowed or
245
+// report-only-allowed); a non-empty code means the save is blocked
246
+// and the caller redirects to the settings page with a banner.
247
+//
248
+// Report-only semantics: user-kind would-denies log via Logger and
249
+// return empty notice. Org-kind would-denies return the SP05
250
+// notice code.
251
+func (h *Handlers) evaluateBranchProtectionFeature(ctx context.Context, p billing.Principal, feature entitlements.Feature, requiredReviewers bool) (string, error) {
252
+	if !entitlements.FeatureAppliesToKind(feature, p.Kind) {
253
+		return "", nil
254
+	}
255
+	decision, err := entitlements.CheckPrincipalFeature(ctx, entitlements.Deps{Pool: h.d.Pool}, p, feature)
256
+	if err != nil {
257
+		return "", err
258
+	}
259
+	if decision.Allowed {
260
+		return "", nil
261
+	}
262
+	if p.IsUser() {
263
+		h.d.Logger.InfoContext(ctx, "entitlements.report_only_deny",
264
+			"principal", p.String(),
265
+			"principal_kind", string(p.Kind),
266
+			"principal_id", p.ID,
267
+			"feature", string(feature),
268
+			"reason", string(decision.Reason),
269
+			"required_plan", string(decision.RequiredPlan))
270
+		return "", nil
271
+	}
272
+	return branchProtectionNoticeCode(decision, requiredReviewers), nil
273
+}
274
+
220275
 func branchProtectionNoticeCode(decision entitlements.Decision, requiredReviewers bool) string {
221276
 	if requiredReviewers {
222277
 		switch decision.Reason {