Go · 18880 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package entitlements
4
5 import (
6 "context"
7 "errors"
8 "net/http"
9 "net/url"
10 "time"
11
12 "github.com/jackc/pgx/v5/pgxpool"
13
14 "github.com/tenseleyFlow/shithub/internal/billing"
15 )
16
17 type Feature string
18
19 // Feature constants. PRO02 Q5 ratified an un-namespaced naming
20 // convention with per-feature AppliesTo() metadata — features
21 // shared between user and org repos drop the `Org` infix; features
22 // scoped to org-owned resources (actions secrets / variables) keep
23 // it because the *resource* is org-scoped, not because the *gate*
24 // is. The kind-applicability registry lives in featureKinds below.
25 const (
26 FeatureSecretTeams Feature = "secret_teams"
27 FeatureAdvancedBranchProtection Feature = "advanced_branch_protection"
28 FeatureRequiredReviewers Feature = "required_reviewers"
29 FeatureActionsOrgSecrets Feature = "actions_org_secrets" // #nosec G101 -- entitlement feature key, not a credential.
30 FeatureActionsOrgVariables Feature = "actions_org_variables"
31 FeaturePrivateCollaboration Feature = "private_collaboration_limit"
32 FeatureStorageQuota Feature = "storage_quota"
33 FeatureActionsMinutesQuota Feature = "actions_minutes_quota"
34 // PRO07 additions.
35 //
36 // FeatureProfilePinsBeyondFree gates raising the personal profile
37 // pin cap above the Free baseline. Free users get LimitProfilePinsFreeCap
38 // pins; Pro users get LimitProfilePinsProCap. PRO01 ratified this as
39 // a shithub-specific differentiator (gh does not gate it). Kinds:
40 // user only — orgs continue to share the visible Free cap.
41 FeatureProfilePinsBeyondFree Feature = "profile_pins_beyond_free"
42 // FeatureCodeOwnersReview is the placeholder hook for CODEOWNERS
43 // enforcement. Registered now so the gating call site can compile
44 // and the per-feature config knob exists; the underlying CODEOWNERS
45 // parser ships in a later sprint and only then does Allowed flip.
46 // PRO07 keeps the enforce path a no-op for both kinds. Kinds: user, org.
47 FeatureCodeOwnersReview Feature = "codeowners_review"
48 )
49
50 // Deprecated aliases. Old call sites continue to compile; PRO05's
51 // sweep migrates them. Remove after PRO07 enforce flip stabilizes.
52 const (
53 FeatureOrgSecretTeams = FeatureSecretTeams
54 FeatureOrgAdvancedBranchProtection = FeatureAdvancedBranchProtection
55 FeatureOrgRequiredReviewers = FeatureRequiredReviewers
56 FeatureOrgActionsSecrets = FeatureActionsOrgSecrets
57 FeatureOrgActionsVariables = FeatureActionsOrgVariables
58 FeatureOrgPrivateCollaboration = FeaturePrivateCollaboration
59 FeatureOrgStorageQuota = FeatureStorageQuota
60 FeatureOrgActionsMinutesQuota = FeatureActionsMinutesQuota
61 )
62
63 type Limit string
64
65 const (
66 FreePrivateCollaborationLimit int64 = 3
67
68 // FreeProfilePinsCap mirrors gh's visible profile pin cap. Ratified
69 // PRO01. Applies to Free users AND to all orgs — PRO07 leaves the
70 // org cap as a hard constant since pins are a user-tier differentiator.
71 FreeProfilePinsCap int64 = 6
72 // ProProfilePinsCap is "effectively unlimited" for product copy, but
73 // bounded for DB sanity per PRO01. A Pro user who pins 100 repos is
74 // the upper bound — beyond that the request errors at the cap.
75 ProProfilePinsCap int64 = 100
76
77 LimitPrivateCollaboration Limit = "private_collaboration_limit"
78 LimitStorageQuota Limit = "storage_quota"
79 LimitActionsMinutesQuota Limit = "actions_minutes_quota"
80 // PRO07: profile-pin cap. Two limit keys are exposed so callers can
81 // ask either "what's my current cap" (Set.Limit resolves to free or
82 // pro based on plan) or "what's the absolute Free/Pro number." The
83 // usual handler-side path queries Set.Limit(LimitProfilePinsFreeCap)
84 // for the entitled cap; tests and the upgrade banner copy use the
85 // Pro variant.
86 LimitProfilePinsFreeCap Limit = "profile_pins_free_cap"
87 LimitProfilePinsProCap Limit = "profile_pins_pro_cap"
88 )
89
90 // Deprecated limit aliases. Same migration story as features.
91 const (
92 LimitOrgPrivateCollaboration = LimitPrivateCollaboration
93 LimitOrgStorageQuota = LimitStorageQuota
94 LimitOrgActionsMinutesQuota = LimitActionsMinutesQuota
95 )
96
97 // featureKinds is the AppliesTo registry. Each feature lists the
98 // principal kinds it applies to. requirePrincipalFeature rejects
99 // calls with a kind the feature doesn't apply to — surfacing the
100 // mistake at handler boot time, not at request time.
101 //
102 // PRO02 Q5 ratified Option A (un-namespaced constants with this
103 // registry) and PRO01 ratified the per-feature applicability.
104 // FeaturePrivateCollaboration stays org-only — PRO01 explicitly
105 // rejected reintroducing a Free user collaborator cap.
106 var featureKinds = map[Feature][]billing.SubjectKind{
107 FeatureSecretTeams: {billing.SubjectKindOrg},
108 FeatureAdvancedBranchProtection: {billing.SubjectKindUser, billing.SubjectKindOrg},
109 FeatureRequiredReviewers: {billing.SubjectKindUser, billing.SubjectKindOrg},
110 FeatureActionsOrgSecrets: {billing.SubjectKindOrg},
111 FeatureActionsOrgVariables: {billing.SubjectKindOrg},
112 FeaturePrivateCollaboration: {billing.SubjectKindOrg},
113 FeatureStorageQuota: {billing.SubjectKindOrg}, // user pending SP08
114 FeatureActionsMinutesQuota: {billing.SubjectKindOrg}, // user pending SP08
115 FeatureProfilePinsBeyondFree: {billing.SubjectKindUser},
116 FeatureCodeOwnersReview: {billing.SubjectKindUser, billing.SubjectKindOrg},
117 }
118
119 // AppliesTo reports the principal kinds a feature applies to.
120 // Returns nil for unknown features.
121 func AppliesTo(feature Feature) []billing.SubjectKind {
122 return featureKinds[feature]
123 }
124
125 // FeatureAppliesToKind is the handler-side guard: returns true when
126 // `feature` is valid for `kind`. Use this to short-circuit gating
127 // when the principal kind doesn't even apply to the feature (org-
128 // only features are inapplicable on personal repos and should
129 // fall through to the basic, ungated behavior).
130 func FeatureAppliesToKind(feature Feature, kind billing.SubjectKind) bool {
131 for _, k := range featureKinds[feature] {
132 if k == kind {
133 return true
134 }
135 }
136 return false
137 }
138
139 type Reason string
140
141 const (
142 ReasonNone Reason = ""
143 ReasonUpgradeRequired Reason = "upgrade_required"
144 ReasonBillingActionNeeded Reason = "billing_action_needed"
145 ReasonEnterpriseContactSales Reason = "enterprise_contact_sales"
146 ReasonUnknownFeature Reason = "unknown_feature"
147 )
148
149 type Deps struct {
150 Pool *pgxpool.Pool
151 Now func() time.Time
152 }
153
154 type Decision struct {
155 Feature Feature
156 Allowed bool
157 RequiredPlan billing.Plan
158 Reason Reason
159 }
160
161 type LimitValue struct {
162 Name Limit
163 Feature Feature
164 Allowed bool
165 Defined bool
166 Unlimited bool
167 Value int64
168 Unit string
169 RequiredPlan billing.Plan
170 Reason Reason
171 }
172
173 type UpgradeBanner struct {
174 Message string
175 ActionText string
176 ActionHref string
177 StatusCode int
178 }
179
180 type Loader struct {
181 deps Deps
182 }
183
184 // Set carries the resolved entitlement state for a principal.
185 // Post-PRO05 the canonical routing key is `Principal`; OrgID and
186 // State are kept populated for org-kind sets so SP-era callers
187 // continue to read them without modification. User-kind sets
188 // populate `UserState` and leave `State` zero.
189 type Set struct {
190 OrgID int64 // populated for org kind only (deprecated; prefer Principal.ID)
191 Principal billing.Principal // PRO05: canonical routing key
192 State billing.State // org-kind billing state (zero for user kind)
193 UserState billing.UserState // user-kind billing state (zero for org kind)
194 now time.Time
195 }
196
197 var (
198 ErrPoolRequired = errors.New("entitlements: pool is required")
199 ErrOrgIDRequired = errors.New("entitlements: org id is required")
200 ErrUnknownFeature = errors.New("entitlements: unknown feature")
201 ErrUnknownLimit = errors.New("entitlements: unknown limit")
202 )
203
204 func New(deps Deps) Loader {
205 return Loader{deps: deps}
206 }
207
208 func ForOrg(ctx context.Context, deps Deps, orgID int64) (Set, error) {
209 return New(deps).ForOrg(ctx, orgID)
210 }
211
212 func (l Loader) ForOrg(ctx context.Context, orgID int64) (Set, error) {
213 return l.ForPrincipal(ctx, billing.PrincipalForOrg(orgID))
214 }
215
216 // ForPrincipal is the kind-agnostic loader. Branches to the org or
217 // user billing-state table based on `p.Kind` and returns a Set
218 // whose CanUse / Limit decisions reflect the principal's
219 // subscription state. PRO05+ callers prefer this entry point over
220 // ForOrg.
221 func ForPrincipal(ctx context.Context, deps Deps, p billing.Principal) (Set, error) {
222 return New(deps).ForPrincipal(ctx, p)
223 }
224
225 func (l Loader) ForPrincipal(ctx context.Context, p billing.Principal) (Set, error) {
226 if l.deps.Pool == nil {
227 return Set{}, ErrPoolRequired
228 }
229 if err := p.Validate(); err != nil {
230 return Set{}, err
231 }
232 now := time.Now().UTC()
233 if l.deps.Now != nil {
234 now = l.deps.Now().UTC()
235 }
236 bd := billing.Deps{Pool: l.deps.Pool}
237 switch p.Kind {
238 case billing.SubjectKindOrg:
239 state, err := billing.GetOrgBillingState(ctx, bd, p.ID)
240 if err != nil {
241 return Set{}, err
242 }
243 return Set{OrgID: p.ID, Principal: p, State: state, now: now}, nil
244 case billing.SubjectKindUser:
245 state, err := billing.GetUserBillingState(ctx, bd, p.ID)
246 if err != nil {
247 return Set{}, err
248 }
249 return Set{Principal: p, UserState: state, now: now}, nil
250 default:
251 return Set{}, billing.ErrInvalidPrincipal
252 }
253 }
254
255 func CheckOrgFeature(ctx context.Context, deps Deps, orgID int64, feature Feature) (Decision, error) {
256 return CheckPrincipalFeature(ctx, deps, billing.PrincipalForOrg(orgID), feature)
257 }
258
259 // CheckPrincipalFeature is the kind-agnostic decision shortcut.
260 // Loads the principal's state and returns the Decision for
261 // `feature`. The unknown-feature check covers both renamed and
262 // deprecated-alias forms.
263 func CheckPrincipalFeature(ctx context.Context, deps Deps, p billing.Principal, feature Feature) (Decision, error) {
264 if !KnownFeature(feature) {
265 return Decision{}, ErrUnknownFeature
266 }
267 set, err := ForPrincipal(ctx, deps, p)
268 if err != nil {
269 return Decision{}, err
270 }
271 return set.CanUse(feature), nil
272 }
273
274 // CanUse evaluates `feature` against the carried Principal's
275 // current state. For org kind, behavior is unchanged from SP05.
276 // For user kind (PRO05+), feature must be in the user AppliesTo
277 // list and the user-state Plan/Status branch decides.
278 func (s Set) CanUse(feature Feature) Decision {
279 if s.Principal.Kind == billing.SubjectKindUser {
280 return decideUserFeature(s.now, s.UserState, feature)
281 }
282 // Default / org kind: existing flow.
283 return decideOrgFeature(s.now, s.State, feature)
284 }
285
286 func (s Set) Limit(name Limit) (LimitValue, error) {
287 feature, unit, ok := limitFeature(name)
288 if !ok {
289 return LimitValue{
290 Name: name,
291 Reason: ReasonUnknownFeature,
292 }, ErrUnknownLimit
293 }
294 decision := s.CanUse(feature)
295 value := LimitValue{
296 Name: name,
297 Feature: feature,
298 Allowed: decision.Allowed,
299 Unit: unit,
300 RequiredPlan: decision.RequiredPlan,
301 Reason: decision.Reason,
302 }
303 // Profile pin caps are concrete on both sides of the gate: the Free
304 // cap and Pro cap are constants ratified by PRO01, so the value is
305 // always Defined regardless of the principal's plan. Handlers ask
306 // CanUse(FeatureProfilePinsBeyondFree) to pick which cap applies.
307 switch name {
308 case LimitProfilePinsFreeCap:
309 value.Defined = true
310 value.Value = FreeProfilePinsCap
311 return value, nil
312 case LimitProfilePinsProCap:
313 value.Defined = true
314 value.Value = ProProfilePinsCap
315 return value, nil
316 }
317 if !decision.Allowed {
318 return value, nil
319 }
320 switch name {
321 case LimitOrgPrivateCollaboration:
322 value.Defined = true
323 if decision.Allowed {
324 value.Unlimited = true
325 } else {
326 value.Value = FreePrivateCollaborationLimit
327 }
328 case LimitOrgStorageQuota, LimitOrgActionsMinutesQuota:
329 // SP08 owns usage accounting and concrete quota numbers. Until
330 // then, expose entitlement state without pretending metering is enforced.
331 value.Defined = false
332 }
333 return value, nil
334 }
335
336 // KnownFeature reports whether `feature` is in the registry. Used
337 // by handler-side validation; PRO05 onwards prefers
338 // FeatureAppliesToKind for kind-aware checks.
339 func KnownFeature(feature Feature) bool {
340 _, ok := featureKinds[feature]
341 return ok
342 }
343
344 func KnownLimit(name Limit) bool {
345 _, _, ok := limitFeature(name)
346 return ok
347 }
348
349 func (d Decision) HTTPStatus() int {
350 if d.Allowed {
351 return http.StatusOK
352 }
353 return http.StatusPaymentRequired
354 }
355
356 // BillingPath returns the settings page URL for the orgSlug. For
357 // PRO06+ user-tier upgrades, use UserBillingPath instead.
358 func (d Decision) BillingPath(orgSlug string) string {
359 return "/organizations/" + url.PathEscape(orgSlug) + "/settings/billing"
360 }
361
362 // UserBillingPath returns the user's settings page URL. PRO06 wires
363 // `/settings/billing` for personal accounts; this helper hides the
364 // route from callers that just need a target.
365 func (d Decision) UserBillingPath() string {
366 return "/settings/billing"
367 }
368
369 // UpgradeBanner returns the org-flavored banner. Kept for SP-era
370 // callers; PRO05+ callers should prefer PrincipalUpgradeBanner
371 // which selects copy + path based on the Decision's required plan.
372 func (d Decision) UpgradeBanner(label, orgSlug string) UpgradeBanner {
373 banner := UpgradeBanner{
374 ActionText: "Manage billing and plans",
375 ActionHref: d.BillingPath(orgSlug),
376 StatusCode: d.HTTPStatus(),
377 }
378 switch d.Reason {
379 case ReasonBillingActionNeeded:
380 banner.Message = label + " are read-only until Team billing is brought back into good standing."
381 case ReasonEnterpriseContactSales:
382 banner.Message = label + " require a supported enterprise plan. Contact sales to continue."
383 default:
384 banner.Message = label + " require Team billing. Upgrade this organization to continue."
385 }
386 return banner
387 }
388
389 // PrincipalUpgradeBanner returns the banner appropriate to the
390 // principal kind. User-kind banners say "Upgrade to Pro" and point
391 // at /settings/billing; org-kind banners say "Upgrade to Team" and
392 // point at the org billing settings page.
393 func (d Decision) PrincipalUpgradeBanner(label string, p billing.Principal, orgSlug string) UpgradeBanner {
394 if p.IsUser() {
395 banner := UpgradeBanner{
396 ActionText: "Manage billing",
397 ActionHref: d.UserBillingPath(),
398 StatusCode: d.HTTPStatus(),
399 }
400 switch d.Reason {
401 case ReasonBillingActionNeeded:
402 banner.Message = label + " are read-only until Pro billing is brought back into good standing."
403 default:
404 banner.Message = label + " require Pro billing. Upgrade this account to continue."
405 }
406 return banner
407 }
408 return d.UpgradeBanner(label, orgSlug)
409 }
410
411 // requiredPlanForFeature returns the plan that unlocks `feature`
412 // for `kind`. Returns "" for unknown features or kinds the feature
413 // doesn't apply to. PRO04+PRO05 ratification: user kind ⇒ PlanPro;
414 // org kind ⇒ PlanTeam. No third option exists in PRO04's scope;
415 // enterprise is contact-sales (not self-serve unlock).
416 func requiredPlanForFeature(feature Feature, kind billing.SubjectKind) billing.Plan {
417 if !FeatureAppliesToKind(feature, kind) {
418 return ""
419 }
420 switch kind {
421 case billing.SubjectKindOrg:
422 return billing.PlanTeam
423 case billing.SubjectKindUser:
424 // Note: PlanPro is a user_plan enum value; billing.PlanFree
425 // etc. are org_plan. The cross-table-ness is deliberate per
426 // PRO02 Q2 (separate enums). The Decision type holds the
427 // returned plan as the org-plan-typed billing.Plan because
428 // pre-PRO05 callers assume that. PRO05 keeps the field
429 // shape and writes "pro" in there as a string — Plan is a
430 // type alias for billingdb.OrgPlan which is just a string
431 // under the hood, so this is safe and PRO06+PRO07 callers
432 // that compare against PlanTeam continue to work.
433 return billing.Plan("pro")
434 }
435 return ""
436 }
437
438 func limitFeature(name Limit) (Feature, string, bool) {
439 switch name {
440 case LimitPrivateCollaboration:
441 return FeaturePrivateCollaboration, "collaborators", true
442 case LimitStorageQuota:
443 return FeatureStorageQuota, "bytes", true
444 case LimitActionsMinutesQuota:
445 return FeatureActionsMinutesQuota, "minutes", true
446 case LimitProfilePinsFreeCap, LimitProfilePinsProCap:
447 return FeatureProfilePinsBeyondFree, "pins", true
448 default:
449 return "", "", false
450 }
451 }
452
453 // decideUserFeature is the user-kind decision body. Mirrors
454 // decideOrgFeature's structure with user_plan ('free' | 'pro') in
455 // place of org_plan and `pro` as the unlocking plan. PRO07 flips
456 // enforcement; PRO05 (and PRO06) keep this path live for the
457 // CanUse return value but handler-side gating runs in report-only
458 // mode so the deny is logged not surfaced.
459 func decideUserFeature(now time.Time, state billing.UserState, feature Feature) Decision {
460 decision := Decision{
461 Feature: feature,
462 RequiredPlan: requiredPlanForFeature(feature, billing.SubjectKindUser),
463 Reason: ReasonUpgradeRequired,
464 }
465 if decision.RequiredPlan == "" {
466 decision.Reason = ReasonUnknownFeature
467 return decision
468 }
469 switch state.Plan {
470 case billing.UserPlanPro:
471 switch state.SubscriptionStatus {
472 case billing.SubscriptionStatusActive,
473 billing.SubscriptionStatusTrialing:
474 decision.Allowed = true
475 decision.Reason = ReasonNone
476 return decision
477 case billing.SubscriptionStatusPastDue:
478 if state.GraceUntil.Valid && !now.After(state.GraceUntil.Time) {
479 decision.Allowed = true
480 decision.Reason = ReasonNone
481 return decision
482 }
483 decision.Reason = ReasonBillingActionNeeded
484 return decision
485 default:
486 decision.Reason = ReasonBillingActionNeeded
487 return decision
488 }
489 default:
490 // Free user — feature gated.
491 return decision
492 }
493 }
494
495 // decideOrgFeature is the org-kind decision body. Kept as the
496 // existing flow so org callers see byte-for-byte identical
497 // behavior; the user-kind path lives in decideUserFeature.
498 func decideOrgFeature(now time.Time, state billing.State, feature Feature) Decision {
499 decision := Decision{
500 Feature: feature,
501 RequiredPlan: requiredPlanForFeature(feature, billing.SubjectKindOrg),
502 Reason: ReasonUpgradeRequired,
503 }
504 if decision.RequiredPlan == "" {
505 decision.Reason = ReasonUnknownFeature
506 return decision
507 }
508 switch state.Plan {
509 case billing.PlanEnterprise:
510 decision.Reason = ReasonEnterpriseContactSales
511 return decision
512 case billing.PlanTeam:
513 switch state.SubscriptionStatus {
514 case billing.SubscriptionStatusActive,
515 billing.SubscriptionStatusTrialing:
516 decision.Allowed = true
517 decision.Reason = ReasonNone
518 return decision
519 case billing.SubscriptionStatusPastDue:
520 if state.GraceUntil.Valid && !now.After(state.GraceUntil.Time) {
521 decision.Allowed = true
522 decision.Reason = ReasonNone
523 return decision
524 }
525 decision.Reason = ReasonBillingActionNeeded
526 return decision
527 default:
528 decision.Reason = ReasonBillingActionNeeded
529 return decision
530 }
531 default:
532 return decision
533 }
534 }
535