tenseleyflow/shithub / 8b97c97

Browse files

entitlements: RequirePrincipalFeature helper with report-only-for-user default + tests

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
8b97c97f8277321dae467c89fc29dfbc30e55815
Parents
e3db9d9
Tree
11c35cb

2 changed files

StatusFile+-
A internal/entitlements/principal_test.go 159 0
A internal/entitlements/require.go 156 0
internal/entitlements/principal_test.goadded
@@ -0,0 +1,159 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package entitlements_test
4
+
5
+import (
6
+	"context"
7
+	"log/slog"
8
+	"net/http/httptest"
9
+	"testing"
10
+
11
+	"github.com/tenseleyFlow/shithub/internal/billing"
12
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
13
+)
14
+
15
+func TestAppliesToOrgOnlyFeatures(t *testing.T) {
16
+	t.Parallel()
17
+	orgOnly := []entitlements.Feature{
18
+		entitlements.FeatureSecretTeams,
19
+		entitlements.FeatureActionsOrgSecrets,
20
+		entitlements.FeatureActionsOrgVariables,
21
+		entitlements.FeaturePrivateCollaboration,
22
+		entitlements.FeatureStorageQuota,
23
+		entitlements.FeatureActionsMinutesQuota,
24
+	}
25
+	for _, f := range orgOnly {
26
+		if !entitlements.FeatureAppliesToKind(f, billing.SubjectKindOrg) {
27
+			t.Errorf("%s should apply to org kind", f)
28
+		}
29
+		if entitlements.FeatureAppliesToKind(f, billing.SubjectKindUser) {
30
+			t.Errorf("%s should NOT apply to user kind", f)
31
+		}
32
+	}
33
+}
34
+
35
+func TestAppliesToCrossKindFeatures(t *testing.T) {
36
+	t.Parallel()
37
+	crossKind := []entitlements.Feature{
38
+		entitlements.FeatureAdvancedBranchProtection,
39
+		entitlements.FeatureRequiredReviewers,
40
+	}
41
+	for _, f := range crossKind {
42
+		if !entitlements.FeatureAppliesToKind(f, billing.SubjectKindOrg) {
43
+			t.Errorf("%s should apply to org kind", f)
44
+		}
45
+		if !entitlements.FeatureAppliesToKind(f, billing.SubjectKindUser) {
46
+			t.Errorf("%s should apply to user kind", f)
47
+		}
48
+	}
49
+}
50
+
51
+func TestDeprecatedAliasesPointAtCanonical(t *testing.T) {
52
+	t.Parallel()
53
+	// SP-era constants are aliases for the renamed ones. Code that
54
+	// uses FeatureOrgSecretTeams must read the same value as
55
+	// FeatureSecretTeams.
56
+	cases := []struct {
57
+		legacy, canonical entitlements.Feature
58
+	}{
59
+		{entitlements.FeatureOrgSecretTeams, entitlements.FeatureSecretTeams},
60
+		{entitlements.FeatureOrgAdvancedBranchProtection, entitlements.FeatureAdvancedBranchProtection},
61
+		{entitlements.FeatureOrgRequiredReviewers, entitlements.FeatureRequiredReviewers},
62
+		{entitlements.FeatureOrgActionsSecrets, entitlements.FeatureActionsOrgSecrets},
63
+		{entitlements.FeatureOrgActionsVariables, entitlements.FeatureActionsOrgVariables},
64
+		{entitlements.FeatureOrgPrivateCollaboration, entitlements.FeaturePrivateCollaboration},
65
+		{entitlements.FeatureOrgStorageQuota, entitlements.FeatureStorageQuota},
66
+		{entitlements.FeatureOrgActionsMinutesQuota, entitlements.FeatureActionsMinutesQuota},
67
+	}
68
+	for _, tc := range cases {
69
+		if tc.legacy != tc.canonical {
70
+			t.Errorf("alias %q != canonical %q", tc.legacy, tc.canonical)
71
+		}
72
+	}
73
+}
74
+
75
+// TestRequirePrincipalFeatureUnknownKind verifies the short-circuit
76
+// when the feature doesn't apply to the principal's kind. Org-only
77
+// features on personal repos must allow without logging — they
78
+// represent "this resource type isn't gateable here."
79
+func TestRequirePrincipalFeatureUnknownKind(t *testing.T) {
80
+	t.Parallel()
81
+	w := httptest.NewRecorder()
82
+	ok, res := entitlements.RequirePrincipalFeature(
83
+		context.Background(),
84
+		w,
85
+		nil, // pool unused on short-circuit path
86
+		billing.PrincipalForUser(42),
87
+		entitlements.FeatureSecretTeams, // org-only
88
+		entitlements.NewRequireOpts("label", ""),
89
+	)
90
+	if !ok {
91
+		t.Fatalf("inapplicable feature should short-circuit ok=true")
92
+	}
93
+	if !res.UnknownKindForFeature {
94
+		t.Errorf("UnknownKindForFeature should be true")
95
+	}
96
+	if res.WouldDenyButLog {
97
+		t.Errorf("WouldDenyButLog should be false for inapplicable feature")
98
+	}
99
+}
100
+
101
+// TestPrincipalUpgradeBannerForUser verifies the kind-aware banner
102
+// copy: user kind says "Pro" and points at /settings/billing; org
103
+// kind says "Team" and points at the org settings page.
104
+func TestPrincipalUpgradeBannerForUser(t *testing.T) {
105
+	t.Parallel()
106
+	decision := entitlements.Decision{Reason: entitlements.ReasonUpgradeRequired}
107
+	user := billing.PrincipalForUser(7)
108
+	banner := decision.PrincipalUpgradeBanner("Required reviewers", user, "")
109
+	if banner.ActionHref != "/settings/billing" {
110
+		t.Errorf("user banner href: got %q, want /settings/billing", banner.ActionHref)
111
+	}
112
+	if banner.Message == "" || !contains(banner.Message, "Pro") {
113
+		t.Errorf("user banner message should mention Pro: %q", banner.Message)
114
+	}
115
+}
116
+
117
+func TestPrincipalUpgradeBannerForOrg(t *testing.T) {
118
+	t.Parallel()
119
+	decision := entitlements.Decision{Reason: entitlements.ReasonUpgradeRequired}
120
+	org := billing.PrincipalForOrg(7)
121
+	banner := decision.PrincipalUpgradeBanner("Required reviewers", org, "acme")
122
+	if banner.ActionHref != "/organizations/acme/settings/billing" {
123
+		t.Errorf("org banner href: got %q", banner.ActionHref)
124
+	}
125
+	if !contains(banner.Message, "Team") {
126
+		t.Errorf("org banner message should mention Team: %q", banner.Message)
127
+	}
128
+}
129
+
130
+// TestRequirePrincipalFeatureWithLogger smoke-tests that the helper
131
+// runs without panic when given a logger and a nil pool — the
132
+// short-circuit path takes effect for inapplicable features.
133
+func TestRequirePrincipalFeatureWithLogger(t *testing.T) {
134
+	t.Parallel()
135
+	logger := slog.New(slog.DiscardHandler)
136
+	opts := entitlements.NewRequireOpts("label", "acme")
137
+	opts.Logger = logger
138
+	w := httptest.NewRecorder()
139
+	ok, _ := entitlements.RequirePrincipalFeature(
140
+		context.Background(),
141
+		w,
142
+		nil,
143
+		billing.PrincipalForUser(1),
144
+		entitlements.FeatureActionsOrgSecrets, // org-only, inapplicable to user
145
+		opts,
146
+	)
147
+	if !ok {
148
+		t.Errorf("inapplicable feature: expected ok=true")
149
+	}
150
+}
151
+
152
+func contains(s, sub string) bool {
153
+	for i := 0; i+len(sub) <= len(s); i++ {
154
+		if s[i:i+len(sub)] == sub {
155
+			return true
156
+		}
157
+	}
158
+	return false
159
+}
internal/entitlements/require.goadded
@@ -0,0 +1,156 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package entitlements
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"log/slog"
9
+	"net/http"
10
+
11
+	"github.com/jackc/pgx/v5/pgxpool"
12
+
13
+	"github.com/tenseleyFlow/shithub/internal/billing"
14
+)
15
+
16
+// RequireOpts tunes the behavior of RequirePrincipalFeature.
17
+//
18
+// EnforceForUser flips whether a user-kind deny actually blocks
19
+// the request (true) or merely logs a would-deny while letting
20
+// the request proceed (false). PRO05 ships with EnforceForUser =
21
+// false across all gating sites; PRO07 flips it per-feature via
22
+// operator-controlled per-feature config flags.
23
+//
24
+// EnforceForOrg defaults to true — org-tier gating has been enforced
25
+// since SP05 and should not silently flip to report-only. Operators
26
+// who need to relax an org gate set this to false explicitly.
27
+//
28
+// Label is the user-visible feature name baked into the 402 banner
29
+// message; OrgSlug is the path component for the org settings page.
30
+type RequireOpts struct {
31
+	Label          string
32
+	OrgSlug        string // ignored for user kind
33
+	EnforceForOrg  bool   // default true via NewRequireOpts
34
+	EnforceForUser bool   // default false in PRO05; PRO07 flips per feature
35
+	WriteResponse  bool   // default true: write 402; false: just return false for HTML callers
36
+	Logger         *slog.Logger
37
+}
38
+
39
+// NewRequireOpts is the sane-default constructor: enforce on org,
40
+// report-only on user, write 402 on deny, no logger (caller supplies).
41
+func NewRequireOpts(label, orgSlug string) RequireOpts {
42
+	return RequireOpts{
43
+		Label:          label,
44
+		OrgSlug:        orgSlug,
45
+		EnforceForOrg:  true,
46
+		EnforceForUser: false,
47
+		WriteResponse:  true,
48
+	}
49
+}
50
+
51
+// EnforceModeDecision packages the decision plus the chosen
52
+// enforcement mode for the caller. Handlers usually consume `Allow`
53
+// directly; tests and telemetry use the rest for assertions.
54
+type EnforceModeDecision struct {
55
+	Decision              Decision
56
+	Principal             billing.Principal
57
+	Allow                 bool // final answer accounting for report-only
58
+	WouldDenyButLog       bool // user-kind report-only path took this branch
59
+	UnknownKindForFeature bool // feature doesn't apply to principal kind — short-circuit allow
60
+}
61
+
62
+// RequirePrincipalFeature is the canonical handler-side gate.
63
+// Returns ok=true when the request may proceed (either the user has
64
+// the feature OR a report-only mode is allowing the request despite
65
+// a would-deny). Returns ok=false only when the caller should stop;
66
+// the helper has already written the response if WriteResponse=true.
67
+//
68
+// Behavior by principal kind:
69
+//
70
+//   - Org with EnforceForOrg=true (default): existing SP05 semantics.
71
+//     Deny writes 402 + org-flavored banner.
72
+//   - User with EnforceForUser=false (PRO05 default): logs would-deny
73
+//     at info level, returns ok=true. Telemetry counters can then
74
+//     confirm no Free user is currently exercising a Pro-gated path
75
+//     before PRO07 enforces.
76
+//   - Either kind, feature doesn't apply: short-circuit ok=true with
77
+//     no log. Org-only features on personal repos are a no-op.
78
+func RequirePrincipalFeature(
79
+	ctx context.Context,
80
+	w http.ResponseWriter,
81
+	pool *pgxpool.Pool,
82
+	p billing.Principal,
83
+	feature Feature,
84
+	opts RequireOpts,
85
+) (bool, EnforceModeDecision) {
86
+	res := EnforceModeDecision{Principal: p}
87
+
88
+	// Feature applicability short-circuit: org-only features on a
89
+	// user principal (or vice versa) are not enforced here — the
90
+	// caller's intent was "if this principal owns the resource,
91
+	// gate it on this feature", and an inapplicable feature means
92
+	// the resource type isn't gateable for this kind. The handler
93
+	// proceeds with its existing default behavior.
94
+	if !FeatureAppliesToKind(feature, p.Kind) {
95
+		res.UnknownKindForFeature = true
96
+		res.Allow = true
97
+		return true, res
98
+	}
99
+
100
+	decision, err := CheckPrincipalFeature(ctx, Deps{Pool: pool}, p, feature)
101
+	if err != nil {
102
+		if opts.Logger != nil {
103
+			opts.Logger.ErrorContext(ctx, "entitlements: principal feature check failed",
104
+				"principal", p.String(),
105
+				"feature", feature,
106
+				"error", err)
107
+		}
108
+		if opts.WriteResponse {
109
+			http.Error(w, "entitlement check failed", http.StatusInternalServerError)
110
+		}
111
+		return false, res
112
+	}
113
+	res.Decision = decision
114
+
115
+	if decision.Allowed {
116
+		res.Allow = true
117
+		return true, res
118
+	}
119
+
120
+	enforce := false
121
+	switch p.Kind {
122
+	case billing.SubjectKindOrg:
123
+		enforce = opts.EnforceForOrg
124
+	case billing.SubjectKindUser:
125
+		enforce = opts.EnforceForUser
126
+	}
127
+
128
+	if !enforce {
129
+		// Report-only path: log + allow.
130
+		res.WouldDenyButLog = true
131
+		res.Allow = true
132
+		if opts.Logger != nil {
133
+			opts.Logger.InfoContext(ctx, "entitlements.report_only_deny",
134
+				"principal", p.String(),
135
+				"principal_kind", string(p.Kind),
136
+				"principal_id", p.ID,
137
+				"feature", feature,
138
+				"reason", string(decision.Reason),
139
+				"required_plan", string(decision.RequiredPlan))
140
+		}
141
+		return true, res
142
+	}
143
+
144
+	// Enforce path: write 402 + banner, return false.
145
+	if opts.WriteResponse {
146
+		banner := decision.PrincipalUpgradeBanner(opts.Label, p, opts.OrgSlug)
147
+		http.Error(w, banner.Message, banner.StatusCode)
148
+	}
149
+	return false, res
150
+}
151
+
152
+// ErrUnknownPrincipalKind is returned by callers that want to
153
+// surface the inapplicability of a feature to a principal kind as
154
+// a hard error rather than the short-circuit allow that
155
+// RequirePrincipalFeature does.
156
+var ErrUnknownPrincipalKind = errors.New("entitlements: feature does not apply to principal kind")