tenseleyflow/shithub / 2c82cd2

Browse files

Harden org entitlement decisions

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
2c82cd28286b8ac350cdc90e66541e39dc816ba4
Parents
fd6b7b3
Tree
d9f0787

4 changed files

StatusFile+-
M docs/internal/billing.md 24 0
M docs/internal/permissions.md 7 0
M internal/entitlements/entitlements.go 164 18
M internal/entitlements/entitlements_test.go 135 31
docs/internal/billing.mdmodified
@@ -189,6 +189,13 @@ as scattered `orgs.plan` checks in handlers.
189189
 store and scan the plan value, but product behavior should ask the
190190
 entitlement package whether a feature key is available.
191191
 
192
+The package entrypoint is `entitlements.ForOrg(ctx, deps, orgID)`,
193
+which loads the local `org_billing_states` projection and returns a
194
+request-scoped entitlement set. Callers then use `CanUse(feature)` for
195
+feature decisions and `Limit(name)` for paid limit metadata. The legacy
196
+`CheckOrgFeature` helper is a thin wrapper for handlers that need only
197
+one feature. These calls are deterministic and never call Stripe.
198
+
192199
 Expected feature keys:
193200
 
194201
 - `org.secret_teams`
@@ -205,6 +212,23 @@ the policy permission and the paid entitlement for gated writes. Denials
205212
 must preserve existing `policy.Maybe404` behavior where existence leaks
206213
 matter.
207214
 
215
+Entitlement outcomes are:
216
+
217
+- Free organizations receive `upgrade_required` for Team features.
218
+- Team organizations with `active` or `trialing` subscriptions receive
219
+  the feature.
220
+- Team organizations in `past_due` remain usable only while their local
221
+  grace window has not expired.
222
+- Team organizations with incomplete, unpaid, paused, canceled, or
223
+  expired-grace billing receive `billing_action_needed`.
224
+- Enterprise remains a contact-sales stub and receives
225
+  `enterprise_contact_sales` until a later Enterprise sprint defines a
226
+  real feature set.
227
+
228
+Handler upgrade helpers map paid-feature denials to HTTP 402 metadata
229
+and a billing-settings path, but handlers must still perform normal
230
+authorization first and preserve 404 masking for private resources.
231
+
208232
 ## Downgrade behavior
209233
 
210234
 Downgrades must preserve customer data. Moving from Team to Free should
docs/internal/permissions.mdmodified
@@ -131,6 +131,13 @@ That keeps security authorization independent from commercial product
131131
 packaging, and makes downgrades/grace periods possible without
132132
 rewriting role checks.
133133
 
134
+For paid-feature UI, handlers should use the entitlement decision's
135
+upgrade metadata instead of inventing per-surface billing state. A
136
+missing Team feature maps to HTTP 402 semantics and a billing-settings
137
+path after the normal authorization result has already been accepted.
138
+Enterprise is not implicitly Team-plus; it is a contact-sales
139
+entitlement result until the Enterprise product contract ships.
140
+
134141
 ## Existence-leak guard
135142
 
136143
 `policy.Maybe404(decision, repo, actor)` maps a denial to a status
internal/entitlements/entitlements.gomodified
@@ -5,6 +5,8 @@ package entitlements
55
 import (
66
 	"context"
77
 	"errors"
8
+	"net/http"
9
+	"net/url"
810
 	"time"
911
 
1012
 	"github.com/jackc/pgx/v5/pgxpool"
@@ -25,12 +27,22 @@ const (
2527
 	FeatureOrgActionsMinutesQuota      Feature = "org.actions_minutes_quota"
2628
 )
2729
 
30
+type Limit string
31
+
32
+const (
33
+	LimitOrgPrivateCollaboration Limit = "org.private_collaboration_limit"
34
+	LimitOrgStorageQuota         Limit = "org.storage_quota"
35
+	LimitOrgActionsMinutesQuota  Limit = "org.actions_minutes_quota"
36
+)
37
+
2838
 type Reason string
2939
 
3040
 const (
31
-	ReasonNone                Reason = ""
32
-	ReasonUpgradeRequired     Reason = "upgrade_required"
33
-	ReasonBillingActionNeeded Reason = "billing_action_needed"
41
+	ReasonNone                   Reason = ""
42
+	ReasonUpgradeRequired        Reason = "upgrade_required"
43
+	ReasonBillingActionNeeded    Reason = "billing_action_needed"
44
+	ReasonEnterpriseContactSales Reason = "enterprise_contact_sales"
45
+	ReasonUnknownFeature         Reason = "unknown_feature"
3446
 )
3547
 
3648
 type Deps struct {
@@ -45,31 +57,150 @@ type Decision struct {
4557
 	Reason       Reason
4658
 }
4759
 
60
+type LimitValue struct {
61
+	Name         Limit
62
+	Feature      Feature
63
+	Allowed      bool
64
+	Defined      bool
65
+	Unlimited    bool
66
+	Value        int64
67
+	Unit         string
68
+	RequiredPlan billing.Plan
69
+	Reason       Reason
70
+}
71
+
72
+type UpgradeBanner struct {
73
+	Message    string
74
+	ActionText string
75
+	ActionHref string
76
+	StatusCode int
77
+}
78
+
79
+type Loader struct {
80
+	deps Deps
81
+}
82
+
83
+type Set struct {
84
+	OrgID int64
85
+	State billing.State
86
+	now   time.Time
87
+}
88
+
4889
 var (
4990
 	ErrPoolRequired   = errors.New("entitlements: pool is required")
5091
 	ErrOrgIDRequired  = errors.New("entitlements: org id is required")
5192
 	ErrUnknownFeature = errors.New("entitlements: unknown feature")
93
+	ErrUnknownLimit   = errors.New("entitlements: unknown limit")
5294
 )
5395
 
54
-func CheckOrgFeature(ctx context.Context, deps Deps, orgID int64, feature Feature) (Decision, error) {
55
-	if deps.Pool == nil {
56
-		return Decision{}, ErrPoolRequired
96
+func New(deps Deps) Loader {
97
+	return Loader{deps: deps}
98
+}
99
+
100
+func ForOrg(ctx context.Context, deps Deps, orgID int64) (Set, error) {
101
+	return New(deps).ForOrg(ctx, orgID)
102
+}
103
+
104
+func (l Loader) ForOrg(ctx context.Context, orgID int64) (Set, error) {
105
+	if l.deps.Pool == nil {
106
+		return Set{}, ErrPoolRequired
57107
 	}
58108
 	if orgID == 0 {
59
-		return Decision{}, ErrOrgIDRequired
109
+		return Set{}, ErrOrgIDRequired
110
+	}
111
+	state, err := billing.GetOrgBillingState(ctx, billing.Deps{Pool: l.deps.Pool}, orgID)
112
+	if err != nil {
113
+		return Set{}, err
114
+	}
115
+	now := time.Now().UTC()
116
+	if l.deps.Now != nil {
117
+		now = l.deps.Now().UTC()
60118
 	}
61
-	if requiredPlanForFeature(feature) == "" {
119
+	return Set{OrgID: orgID, State: state, now: now}, nil
120
+}
121
+
122
+func CheckOrgFeature(ctx context.Context, deps Deps, orgID int64, feature Feature) (Decision, error) {
123
+	if !KnownFeature(feature) {
62124
 		return Decision{}, ErrUnknownFeature
63125
 	}
64
-	state, err := billing.GetOrgBillingState(ctx, billing.Deps{Pool: deps.Pool}, orgID)
126
+	set, err := ForOrg(ctx, deps, orgID)
65127
 	if err != nil {
66128
 		return Decision{}, err
67129
 	}
68
-	now := time.Now().UTC()
69
-	if deps.Now != nil {
70
-		now = deps.Now().UTC()
130
+	return set.CanUse(feature), nil
131
+}
132
+
133
+func (s Set) CanUse(feature Feature) Decision {
134
+	return decideFeature(s.now, s.State, feature)
135
+}
136
+
137
+func (s Set) Limit(name Limit) (LimitValue, error) {
138
+	feature, unit, ok := limitFeature(name)
139
+	if !ok {
140
+		return LimitValue{
141
+			Name:   name,
142
+			Reason: ReasonUnknownFeature,
143
+		}, ErrUnknownLimit
144
+	}
145
+	decision := s.CanUse(feature)
146
+	value := LimitValue{
147
+		Name:         name,
148
+		Feature:      feature,
149
+		Allowed:      decision.Allowed,
150
+		Unit:         unit,
151
+		RequiredPlan: decision.RequiredPlan,
152
+		Reason:       decision.Reason,
153
+	}
154
+	if !decision.Allowed {
155
+		return value, nil
156
+	}
157
+	switch name {
158
+	case LimitOrgPrivateCollaboration:
159
+		value.Defined = true
160
+		value.Unlimited = true
161
+	case LimitOrgStorageQuota, LimitOrgActionsMinutesQuota:
162
+		// SP08 owns usage accounting and concrete quota numbers. Until
163
+		// then, expose entitlement state without pretending metering is enforced.
164
+		value.Defined = false
165
+	}
166
+	return value, nil
167
+}
168
+
169
+func KnownFeature(feature Feature) bool {
170
+	return requiredPlanForFeature(feature) != ""
171
+}
172
+
173
+func KnownLimit(name Limit) bool {
174
+	_, _, ok := limitFeature(name)
175
+	return ok
176
+}
177
+
178
+func (d Decision) HTTPStatus() int {
179
+	if d.Allowed {
180
+		return http.StatusOK
181
+	}
182
+	return http.StatusPaymentRequired
183
+}
184
+
185
+func (d Decision) BillingPath(orgSlug string) string {
186
+	return "/organizations/" + url.PathEscape(orgSlug) + "/settings/billing"
187
+}
188
+
189
+func (d Decision) UpgradeBanner(label, orgSlug string) UpgradeBanner {
190
+	banner := UpgradeBanner{
191
+		ActionText: "Manage billing and plans",
192
+		ActionHref: d.BillingPath(orgSlug),
193
+		StatusCode: d.HTTPStatus(),
194
+	}
195
+	switch d.Reason {
196
+	case ReasonBillingActionNeeded:
197
+		banner.Message = label + " are read-only until Team billing is brought back into good standing."
198
+	case ReasonEnterpriseContactSales:
199
+		banner.Message = label + " require a supported enterprise plan. Contact sales to continue."
200
+	default:
201
+		banner.Message = label + " require Team billing. Upgrade this organization to continue."
71202
 	}
72
-	return decideFeature(now, state, feature), nil
203
+	return banner
73204
 }
74205
 
75206
 func requiredPlanForFeature(feature Feature) billing.Plan {
@@ -88,27 +219,42 @@ func requiredPlanForFeature(feature Feature) billing.Plan {
88219
 	}
89220
 }
90221
 
222
+func limitFeature(name Limit) (Feature, string, bool) {
223
+	switch name {
224
+	case LimitOrgPrivateCollaboration:
225
+		return FeatureOrgPrivateCollaboration, "collaborators", true
226
+	case LimitOrgStorageQuota:
227
+		return FeatureOrgStorageQuota, "bytes", true
228
+	case LimitOrgActionsMinutesQuota:
229
+		return FeatureOrgActionsMinutesQuota, "minutes", true
230
+	default:
231
+		return "", "", false
232
+	}
233
+}
234
+
91235
 func decideFeature(now time.Time, state billing.State, feature Feature) Decision {
92236
 	decision := Decision{
93237
 		Feature:      feature,
94238
 		RequiredPlan: requiredPlanForFeature(feature),
95239
 		Reason:       ReasonUpgradeRequired,
96240
 	}
241
+	if decision.RequiredPlan == "" {
242
+		decision.Reason = ReasonUnknownFeature
243
+		return decision
244
+	}
97245
 	switch state.Plan {
98246
 	case billing.PlanEnterprise:
99
-		decision.Allowed = true
100
-		decision.Reason = ReasonNone
247
+		decision.Reason = ReasonEnterpriseContactSales
101248
 		return decision
102249
 	case billing.PlanTeam:
103250
 		switch state.SubscriptionStatus {
104251
 		case billing.SubscriptionStatusActive,
105
-			billing.SubscriptionStatusTrialing,
106
-			billing.SubscriptionStatusIncomplete:
252
+			billing.SubscriptionStatusTrialing:
107253
 			decision.Allowed = true
108254
 			decision.Reason = ReasonNone
109255
 			return decision
110256
 		case billing.SubscriptionStatusPastDue:
111
-			if !state.GraceUntil.Valid || !now.After(state.GraceUntil.Time) {
257
+			if state.GraceUntil.Valid && !now.After(state.GraceUntil.Time) {
112258
 				decision.Allowed = true
113259
 				decision.Reason = ReasonNone
114260
 				return decision
internal/entitlements/entitlements_test.gomodified
@@ -4,8 +4,11 @@ package entitlements_test
44
 
55
 import (
66
 	"context"
7
+	"errors"
78
 	"io"
89
 	"log/slog"
10
+	"net/http"
11
+	"strings"
912
 	"testing"
1013
 	"time"
1114
 
@@ -39,34 +42,31 @@ func TestCheckOrgFeature(t *testing.T) {
3942
 		{
4043
 			name: "team active allows feature",
4144
 			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
42
-				_, err := billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{
43
-					OrgID:                    orgID,
44
-					Plan:                     billing.PlanTeam,
45
-					Status:                   billing.SubscriptionStatusActive,
46
-					StripeSubscriptionID:     "sub_active",
47
-					StripeSubscriptionItemID: "si_active",
48
-					CurrentPeriodStart:       now,
49
-					CurrentPeriodEnd:         now.Add(30 * 24 * time.Hour),
50
-					LastWebhookEventID:       "evt_active",
51
-				})
52
-				return err
45
+				return setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusActive, "active")
46
+			},
47
+			want:   true,
48
+			reason: entitlements.ReasonNone,
49
+		},
50
+		{
51
+			name: "team trialing allows feature",
52
+			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
53
+				return setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusTrialing, "trialing")
5354
 			},
5455
 			want:   true,
5556
 			reason: entitlements.ReasonNone,
5657
 		},
58
+		{
59
+			name: "team incomplete needs billing action",
60
+			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
61
+				return setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusIncomplete, "incomplete")
62
+			},
63
+			want:   false,
64
+			reason: entitlements.ReasonBillingActionNeeded,
65
+		},
5766
 		{
5867
 			name: "team past due within grace still allows feature",
5968
 			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
60
-				if _, err := billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{
61
-					OrgID:                    orgID,
62
-					Plan:                     billing.PlanTeam,
63
-					Status:                   billing.SubscriptionStatusActive,
64
-					StripeSubscriptionID:     "sub_grace",
65
-					StripeSubscriptionItemID: "si_grace",
66
-					CurrentPeriodStart:       now,
67
-					CurrentPeriodEnd:         now.Add(30 * 24 * time.Hour),
68
-					LastWebhookEventID:       "evt_active",
69
-				}); err != nil {
69
+				if err := setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusActive, "grace"); err != nil {
7070
 					return err
7171
 				}
7272
 				_, err := billing.MarkPastDue(ctx, deps, orgID, now.Add(24*time.Hour), "evt_past_due")
@@ -79,16 +79,7 @@ func TestCheckOrgFeature(t *testing.T) {
7979
 		{
8080
 			name: "team past due after grace needs billing action",
8181
 			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
82
-				if _, err := billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{
83
-					OrgID:                    orgID,
84
-					Plan:                     billing.PlanTeam,
85
-					Status:                   billing.SubscriptionStatusActive,
86
-					StripeSubscriptionID:     "sub_lapsed",
87
-					StripeSubscriptionItemID: "si_lapsed",
88
-					CurrentPeriodStart:       now,
89
-					CurrentPeriodEnd:         now.Add(30 * 24 * time.Hour),
90
-					LastWebhookEventID:       "evt_active",
91
-				}); err != nil {
82
+				if err := setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusActive, "lapsed"); err != nil {
9283
 					return err
9384
 				}
9485
 				_, err := billing.MarkPastDue(ctx, deps, orgID, now.Add(24*time.Hour), "evt_past_due")
@@ -98,6 +89,22 @@ func TestCheckOrgFeature(t *testing.T) {
9889
 			want:   false,
9990
 			reason: entitlements.ReasonBillingActionNeeded,
10091
 		},
92
+		{
93
+			name: "team locked without grace needs billing action",
94
+			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
95
+				return setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusPastDue, "locked")
96
+			},
97
+			want:   false,
98
+			reason: entitlements.ReasonBillingActionNeeded,
99
+		},
100
+		{
101
+			name: "enterprise stub does not unlock team features",
102
+			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
103
+				return setSubscription(ctx, deps, orgID, now, billing.PlanEnterprise, billing.SubscriptionStatusActive, "enterprise")
104
+			},
105
+			want:   false,
106
+			reason: entitlements.ReasonEnterpriseContactSales,
107
+		},
101108
 	}
102109
 
103110
 	for _, tt := range tests {
@@ -130,6 +137,103 @@ func TestCheckOrgFeature(t *testing.T) {
130137
 	}
131138
 }
132139
 
140
+func TestForOrgCanUseAndLimit(t *testing.T) {
141
+	t.Parallel()
142
+	ctx := context.Background()
143
+	pool, orgID := setupEntitlementOrg(t)
144
+	now := time.Now().UTC().Truncate(time.Second)
145
+	if err := setSubscription(ctx, billing.Deps{Pool: pool}, orgID, now, billing.PlanTeam, billing.SubscriptionStatusActive, "limits"); err != nil {
146
+		t.Fatalf("set subscription: %v", err)
147
+	}
148
+
149
+	set, err := entitlements.ForOrg(ctx, entitlements.Deps{
150
+		Pool: pool,
151
+		Now:  func() time.Time { return now },
152
+	}, orgID)
153
+	if err != nil {
154
+		t.Fatalf("ForOrg: %v", err)
155
+	}
156
+	for _, feature := range []entitlements.Feature{
157
+		entitlements.FeatureOrgSecretTeams,
158
+		entitlements.FeatureOrgAdvancedBranchProtection,
159
+		entitlements.FeatureOrgRequiredReviewers,
160
+		entitlements.FeatureOrgActionsSecrets,
161
+		entitlements.FeatureOrgActionsVariables,
162
+		entitlements.FeatureOrgPrivateCollaboration,
163
+		entitlements.FeatureOrgStorageQuota,
164
+		entitlements.FeatureOrgActionsMinutesQuota,
165
+	} {
166
+		if decision := set.CanUse(feature); !decision.Allowed {
167
+			t.Fatalf("feature %s decision=%+v, want allowed", feature, decision)
168
+		}
169
+	}
170
+	collab, err := set.Limit(entitlements.LimitOrgPrivateCollaboration)
171
+	if err != nil {
172
+		t.Fatalf("Limit private collaboration: %v", err)
173
+	}
174
+	if !collab.Allowed || !collab.Defined || !collab.Unlimited || collab.Unit != "collaborators" {
175
+		t.Fatalf("private collaboration limit = %+v", collab)
176
+	}
177
+	storage, err := set.Limit(entitlements.LimitOrgStorageQuota)
178
+	if err != nil {
179
+		t.Fatalf("Limit storage: %v", err)
180
+	}
181
+	if !storage.Allowed || storage.Defined || storage.Unit != "bytes" {
182
+		t.Fatalf("storage limit = %+v, want allowed but deferred concrete quota", storage)
183
+	}
184
+}
185
+
186
+func TestUnknownFeatureAndLimit(t *testing.T) {
187
+	t.Parallel()
188
+	ctx := context.Background()
189
+	pool, orgID := setupEntitlementOrg(t)
190
+	_, err := entitlements.CheckOrgFeature(ctx, entitlements.Deps{Pool: pool}, orgID, entitlements.Feature("org.mystery"))
191
+	if !errors.Is(err, entitlements.ErrUnknownFeature) {
192
+		t.Fatalf("CheckOrgFeature unknown err=%v, want ErrUnknownFeature", err)
193
+	}
194
+	set, err := entitlements.ForOrg(ctx, entitlements.Deps{Pool: pool}, orgID)
195
+	if err != nil {
196
+		t.Fatalf("ForOrg: %v", err)
197
+	}
198
+	_, err = set.Limit(entitlements.Limit("org.mystery_limit"))
199
+	if !errors.Is(err, entitlements.ErrUnknownLimit) {
200
+		t.Fatalf("Limit unknown err=%v, want ErrUnknownLimit", err)
201
+	}
202
+}
203
+
204
+func TestDecisionUpgradeBanner(t *testing.T) {
205
+	t.Parallel()
206
+	decision := entitlements.Decision{
207
+		Feature:      entitlements.FeatureOrgSecretTeams,
208
+		RequiredPlan: billing.PlanTeam,
209
+		Reason:       entitlements.ReasonUpgradeRequired,
210
+	}
211
+	banner := decision.UpgradeBanner("Secret teams", "acme inc")
212
+	if banner.StatusCode != http.StatusPaymentRequired {
213
+		t.Fatalf("status=%d, want 402", banner.StatusCode)
214
+	}
215
+	if banner.ActionHref != "/organizations/acme%20inc/settings/billing" {
216
+		t.Fatalf("href=%q", banner.ActionHref)
217
+	}
218
+	if !strings.Contains(banner.Message, "require Team billing") {
219
+		t.Fatalf("message=%q", banner.Message)
220
+	}
221
+}
222
+
223
+func setSubscription(ctx context.Context, deps billing.Deps, orgID int64, now time.Time, plan billing.Plan, status billing.SubscriptionStatus, suffix string) error {
224
+	_, err := billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{
225
+		OrgID:                    orgID,
226
+		Plan:                     plan,
227
+		Status:                   status,
228
+		StripeSubscriptionID:     "sub_" + suffix,
229
+		StripeSubscriptionItemID: "si_" + suffix,
230
+		CurrentPeriodStart:       now,
231
+		CurrentPeriodEnd:         now.Add(30 * 24 * time.Hour),
232
+		LastWebhookEventID:       "evt_" + suffix,
233
+	})
234
+	return err
235
+}
236
+
133237
 func setupEntitlementOrg(t *testing.T) (*pgxpool.Pool, int64) {
134238
 	t.Helper()
135239
 	pool := dbtest.NewTestDB(t)