Go · 5395 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 "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")
157