Skip to content

Commit 6f44996

Browse files
committed
feat(entitlements): enforce plans on every write, limit, redirect and edge path
Gated writes answer 403 plan_required and count limits answer 403 limit_reached from the resolved map, never from config. The redirect path shapes owned links by the owner's entitlement version through the URL cache, custom domains stop serving once their owner lost them, and the analytics window clamps instead of rejecting. Edge promotion picks the tier threshold from the owner's plan and flags tracked entries; over-limit items pause newest first.
1 parent 3bacc13 commit 6f44996

62 files changed

Lines changed: 1947 additions & 330 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,6 @@ GITHUB_REPO="" # org/repo format
143143
# Access is feature-flag gated per user on top of this master switch.
144144
# WEBHOOKS_ENABLED="false"
145145
# WEBHOOKS_RUNTIME="auto" # auto | worker | embedded | off
146-
# WEBHOOKS_MAX_ENDPOINTS="5" # per user
147146
# WEBHOOKS_DELIVERY_TIMEOUT_SECONDS="15"
148147
# WEBHOOKS_MAX_CONSECUTIVE_FAILURES="10" # exhausted deliveries → auto-disable
149148
# WEBHOOKS_DELIVERY_LOG_TTL_DAYS="30" # events + delivery log retention

config.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ class CustomDomainSettings(BaseSettings):
161161
even when False so the rollout has a clean code path to flip.
162162
163163
All env vars must be prefixed ``CUSTOM_DOMAINS_`` so generic names
164-
like ``ENABLED`` or ``MAX_PER_USER`` set elsewhere in the deploy
164+
like ``ENABLED`` or ``MOCK_DCV`` set elsewhere in the deploy
165165
environment don't accidentally configure this feature.
166166
"""
167167

@@ -186,7 +186,6 @@ class CustomDomainSettings(BaseSettings):
186186
# All counts must be >= 1 — a zero quota silently bricks the feature
187187
# (every create raises QuotaExceeded with no log signal that the cause
188188
# is config, not abuse). Validators below fail container startup instead.
189-
max_per_user: int = Field(default=1, ge=1)
190189
# Generous because CF's own DCV cadence can take 5-15 min per probe;
191190
# legitimate users may poll many times during initial activation.
192191
verify_attempts_per_hour: int = Field(default=60, ge=1)
@@ -457,7 +456,6 @@ class WebhookSettings(BaseSettings):
457456
enabled: bool = False
458457
runtime: Literal["auto", "worker", "embedded", "off"] = "auto"
459458

460-
max_endpoints: int = Field(default=5, ge=1)
461459
delivery_timeout_seconds: float = Field(default=15.0, gt=0)
462460
max_payload_bytes: int = Field(default=20_480, ge=1024)
463461
max_consecutive_failures: int = Field(default=10, ge=1)
@@ -755,8 +753,6 @@ def blocked_self_domains(self) -> tuple[str, ...]:
755753
hcaptcha_sitekey: str = ""
756754

757755
# Service limits (overridable by self-hosters via env vars)
758-
max_active_api_keys: int = 20
759-
max_date_range_days: int = 90
760756
http_client_timeout: float = 5.0
761757
# Account deletion (GDPR Art. 17): days between the deletion request
762758
# and the erasure sweep purging the account (0 = purge on the next
@@ -798,8 +794,6 @@ def blocked_self_domains(self) -> tuple[str, ...]:
798794
# ── Field validators for safety-critical config ────────────────────
799795

800796
@field_validator(
801-
"max_active_api_keys",
802-
"max_date_range_days",
803797
"max_emoji_alias_length",
804798
"emoji_generated_alias_length",
805799
"geo_rules_max_countries",

dependencies/auth.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ async def get_current_user(
121121
)
122122
return None
123123

124+
if key.paused_by_limit:
125+
log.warning(
126+
"api_key_auth_failed",
127+
reason="paused_by_limit",
128+
key_prefix=key.token_prefix,
129+
key_id=str(key.id),
130+
user_id=str(key.user_id),
131+
)
132+
return None
133+
124134
now = datetime.now(timezone.utc)
125135
exp = as_aware_utc(key.expires_at)
126136
if exp is not None and exp <= now:

dependencies/entitlements.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ async def get_entitlements(
2929
user.user_id if user else None,
3030
plan_hint=user.plan_claim if user else None,
3131
)
32+
request.state.entitlements = resolved
3233
if user is not None and not resolved.degraded:
3334
request.state.entitlements_version = resolved.version
3435
return resolved

dependencies/wiring.py

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
from services.domain_intel_service import DomainIntelService
8787
from services.edge_cache.og_writethrough import OgEdgeWritethrough
8888
from services.entitlements import EntitlementService
89+
from services.entitlements.over_limit import OverLimitService
8990
from services.events.sinks import (
9091
InlineDomainEventSink,
9192
NullDomainEventSink,
@@ -291,21 +292,34 @@ def build_entitlement_service(
291292
cache, events, subscriptions, overrides = store or build_entitlement_store(
292293
db, redis_client
293294
)
295+
domain_repo = CustomDomainRepository(db["custom_domains"])
296+
endpoint_repo = WebhookEndpointRepository(db["webhook-endpoints"])
297+
key_repo = ApiKeyRepository(db["api-keys"])
298+
reconciler = OverLimitService(
299+
endpoints=endpoint_repo,
300+
domains=domain_repo,
301+
keys=key_repo,
302+
tenant_resolver=CachedMongoTenantResolver(
303+
repo=domain_repo,
304+
redis_client=redis_client,
305+
system_default_domain=settings.system_default_domain,
306+
),
307+
webhook_owner_cache=OwnerSubscriptionCache(
308+
redis_client, ttl_seconds=settings.webhooks.matcher_cache_ttl_seconds
309+
),
310+
)
294311
return EntitlementService(
295312
subscriptions,
296313
overrides,
297314
events,
298315
cache,
299316
selfhost=settings.billing.selfhost,
300317
usage={
301-
"custom_domains_max": CustomDomainRepository(
302-
db["custom_domains"]
303-
).count_by_owner,
304-
"webhook_endpoints_max": WebhookEndpointRepository(
305-
db["webhook-endpoints"]
306-
).count_by_user,
307-
"api_keys_max": ApiKeyRepository(db["api-keys"]).count_by_user,
318+
"custom_domains_max": domain_repo.count_by_owner,
319+
"webhook_endpoints_max": endpoint_repo.count_by_user,
320+
"api_keys_max": key_repo.count_by_user,
308321
},
322+
over_limit=reconciler,
309323
)
310324

311325

@@ -601,7 +615,6 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
601615
webhook_executor,
602616
webhook_owner_cache,
603617
master_secret=settings.secret_key,
604-
max_endpoints=wh_settings.max_endpoints,
605618
)
606619

607620
# ── Safety pipeline ──────────────────────────────────────────────
@@ -764,6 +777,7 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
764777
events=app.state.domain_event_sink,
765778
user_repo=user_repo,
766779
tag_service=app.state.tag_service,
780+
entitlements=app.state.entitlement_service,
767781
)
768782
app.state.bulk_url_service = BulkUrlService(
769783
url_repo,
@@ -778,7 +792,6 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
778792
app.state.stats_service = StatsService(
779793
click_repo,
780794
url_repo,
781-
max_date_range_days=settings.max_date_range_days,
782795
tag_service=app.state.tag_service,
783796
)
784797
# One resolver serves BOTH public read-only surfaces (preview + stats)
@@ -790,7 +803,15 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
790803
emoji_repo,
791804
system_default_domain=settings.system_default_domain,
792805
)
793-
app.state.public_preview_service = PublicPreviewService(public_link_resolver)
806+
app.state.feature_flag_service = FeatureFlagService(
807+
feature_flag_repo, feature_flag_cache
808+
)
809+
810+
app.state.public_preview_service = PublicPreviewService(
811+
public_link_resolver,
812+
app.state.entitlement_service,
813+
app.state.feature_flag_service,
814+
)
794815
app.state.url_expand_service = UrlExpandService(
795816
blocked_url_repo,
796817
MetaFetchCache(redis_client, prefix="url_expand"),
@@ -808,7 +829,8 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
808829
app.state.public_stats_service = PublicStatsService(
809830
public_link_resolver,
810831
app.state.stats_service,
811-
max_date_range_days=settings.max_date_range_days,
832+
app.state.entitlement_service,
833+
app.state.feature_flag_service,
812834
)
813835
# Report intake shares the resolver (existence checks answer from the
814836
# same generation the redirect serves) and the ops notifier + captcha
@@ -832,7 +854,6 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
832854
)
833855
app.state.api_key_service = ApiKeyService(
834856
api_key_repo,
835-
max_active_keys=settings.max_active_api_keys,
836857
)
837858
app.state.page_layout_service = PageLayoutService(page_layout_repo)
838859
token_factory = TokenFactory(
@@ -940,10 +961,6 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
940961

941962
app.state.app_grant_repo = app_grant_repo
942963

943-
app.state.feature_flag_service = FeatureFlagService(
944-
feature_flag_repo, feature_flag_cache
945-
)
946-
947964
# Whenever clicks are tracked inline, link.clicked webhooks must fan
948965
# out at emit time — the worker's stream group only sees clicks that
949966
# ride the stream. Keying on the CLICK sink (not the domain sink)

errors.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,41 @@ class InvalidTransitionError(ConflictError):
131131
error_code = "invalid_transition"
132132

133133

134+
class PlanRequiredError(ForbiddenError):
135+
"""The account's plan does not include the feature being written."""
136+
137+
error_code = "plan_required"
138+
139+
def __init__(self, feature: str) -> None:
140+
super().__init__(f"{feature} is not included in your plan")
141+
self.feature = feature
142+
143+
def to_dict(self) -> ErrorBody:
144+
d = super().to_dict()
145+
d["feature"] = self.feature
146+
return d
147+
148+
149+
class LimitReachedError(ForbiddenError):
150+
"""A per-plan count limit is full; ``max`` and ``current`` let the UI
151+
render the counter."""
152+
153+
error_code = "limit_reached"
154+
155+
def __init__(self, limit: str, *, max_: int, current: int) -> None:
156+
super().__init__(f"{limit} limit reached ({current}/{max_})")
157+
self.limit = limit
158+
self.max = max_
159+
self.current = current
160+
161+
def to_dict(self) -> ErrorBody:
162+
d = super().to_dict()
163+
d["limit"] = self.limit
164+
d["max"] = self.max
165+
d["current"] = self.current
166+
return d
167+
168+
134169
class BlockedUrlError(AppError):
135170
status_code = 451
136171
error_code = "blocked"

infrastructure/cache/url_cache.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ class UrlCacheData(BaseModel):
6161
# Entries cached before this field existed deserialize to None.
6262
start_time: int | None = None
6363
pre_start_url: str | None = None
64+
# Owner entitlement version this entry was shaped under; None for
65+
# unowned links and for entries written before the owner was consulted.
66+
owner_ent_version: int | None = None
6467

6568
@classmethod
6669
def from_v2_doc(cls, doc: UrlV2Doc) -> UrlCacheData:

middleware/rate_limiter.py

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@
1010
import hashlib
1111
import math
1212
import os
13+
import re
14+
from collections.abc import Callable
1315

1416
from fastapi import Request
1517
from slowapi import Limiter
1618
from starlette.datastructures import MutableHeaders
1719

1820
from infrastructure.logging import get_logger
21+
from services.entitlements.resolver import Resolved
22+
from services.features.catalog import UNLIMITED
1923
from shared.ip_utils import get_client_ip
2024

2125
log = get_logger(__name__)
@@ -91,8 +95,8 @@ class Limits:
9195
# so the request budget must never make bulk scarcer than looping the
9296
# per-item routes — that would push clients back to the fan-out these
9397
# endpoints exist to kill. Per-minute is kept high for bursts (a mass
94-
# takedown chunks at 100 ids/request); the daily cap is a lid, not a
95-
# ration. Blast radius per request is bounded by the 100-id cap and
98+
# takedown chunks at the plan's bulk_batch_max); the daily cap is a lid, not a
99+
# ration. Blast radius per request is bounded by that batch cap and
96100
# ownership scoping, and each batch is ~4 local calls plus at most
97101
# one CF call, so these are cheap requests. Delete stays the tighter
98102
# pair because it is irreversible.
@@ -190,12 +194,26 @@ class Limits:
190194
# ── Key resolution ───────────────────────────────────────────────────────────
191195

192196

197+
# Plan multiplier suffix on the key, so slowapi counts each tier in its own
198+
# bucket and the limit function can scale the string it applies.
199+
_MULTIPLIER_SEP = "|x"
200+
# What "unlimited" means for a rate: still bounded, just far above any client.
201+
_UNLIMITED_MULTIPLIER = 1000
202+
203+
193204
def rate_limit_key(request: Request) -> str:
194-
"""Three-tier rate limit key: API key hash → JWT hash → client IP.
205+
"""Three-tier rate limit key: API key hash → JWT hash → client IP, plus
206+
the caller's plan rate multiplier when the route resolved entitlements.
195207
196208
Lightweight header inspection only — no DB queries, no JWT verification.
197209
Provides consistent per-session bucketing for rate limiting purposes.
198210
"""
211+
key = _identity_key(request)
212+
multiplier = plan_multiplier(request)
213+
return f"{key}{_MULTIPLIER_SEP}{multiplier}" if multiplier > 1 else key
214+
215+
216+
def _identity_key(request: Request) -> str:
199217
auth_header = request.headers.get("Authorization", "")
200218

201219
if auth_header.lower().startswith("bearer "):
@@ -214,6 +232,49 @@ def rate_limit_key(request: Request) -> str:
214232
return get_client_ip(request)
215233

216234

235+
def plan_multiplier(request: Request) -> int:
236+
"""The ``api_rate_multiplier`` of the entitlements the ``Entitled``
237+
dependency stored on this request; 1 when the route did not resolve them."""
238+
resolved = getattr(request.state, "entitlements", None)
239+
if not isinstance(resolved, Resolved):
240+
return 1
241+
value = resolved.limit("api_rate_multiplier")
242+
if value == UNLIMITED:
243+
return _UNLIMITED_MULTIPLIER
244+
return value if value > 1 else 1
245+
246+
247+
def multiplier_of(key: str) -> int:
248+
_, sep, suffix = key.rpartition(_MULTIPLIER_SEP)
249+
return int(suffix) if sep and suffix.isdigit() else 1
250+
251+
252+
def scale_limit(limit: str, factor: int) -> str:
253+
"""``"60 per minute; 5000 per day"`` times ``factor`` on every count."""
254+
if factor <= 1:
255+
return limit
256+
return "; ".join(
257+
re.sub(r"^\d+", lambda m: str(int(m.group(0)) * factor), part.strip())
258+
for part in limit.split(";")
259+
)
260+
261+
262+
def plan_scaled(limit: str) -> Callable[[str], str]:
263+
"""Limit callable for ``@limiter.limit``: the base string scaled by the
264+
plan multiplier carried on the key. The route must declare ``Entitled``
265+
so the multiplier is known before the limiter runs.
266+
267+
Scaled: the per-request API budgets an integration spends on links,
268+
stats and exports. Flat: auth, keys, domains, webhooks and bulk, which
269+
are admin actions (bulk already scales through ``bulk_batch_max``).
270+
"""
271+
272+
def _limit(key: str) -> str:
273+
return scale_limit(limit, multiplier_of(key))
274+
275+
return _limit
276+
277+
217278
# ── Limiter singleton ────────────────────────────────────────────────────────
218279

219280
_redis_uri = os.environ.get("REDIS_URI")
@@ -306,7 +367,7 @@ async def endpoint(request: Request, ...): ...
306367

307368
def _limit(key: str) -> str:
308369
if key.startswith("apikey:") or key.startswith("jwt:"):
309-
return authenticated
370+
return scale_limit(authenticated, multiplier_of(key))
310371
return anonymous
311372

312373
return _limit, rate_limit_key

0 commit comments

Comments
 (0)