Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6e67085
feat: implement plan-based API rate limits
rohan-chaturvedi Dec 4, 2025
ac1c030
feat: disable default throttling for self-hosted environments
rohan-chaturvedi Dec 5, 2025
427d080
fix: apply default rate to unauthenticated requests
rohan-chaturvedi Dec 5, 2025
88b0d4f
feat: add test coverage for rate limits
rohan-chaturvedi Dec 8, 2025
e9418a6
feat: add throttling selectively to public APIs
rohan-chaturvedi Dec 8, 2025
94da683
Merge branch 'main' into feat--api-rate-limits
rohan-chaturvedi Dec 8, 2025
6072d8d
chore: add pytest-django
rohan-chaturvedi Dec 8, 2025
d53d29e
chore: cleanup
rohan-chaturvedi Dec 10, 2025
5b36b44
fix: centralize redis env var parsing
rohan-chaturvedi Dec 13, 2025
d03cd04
Merge branch 'main' into feat--api-rate-limits
nimish-ks Dec 14, 2025
7775600
fix: add support for legacy service tokens in PlanBasedRateThrottle
rohan-chaturvedi Dec 15, 2025
c62b692
Merge branch 'main' into feat--api-rate-limits
rohan-chaturvedi Dec 16, 2025
fbc4cd3
fix: update rate limits and Redis SSL configuration in settings.py
nimish-ks Dec 20, 2025
2a9f219
Merge branch 'main' into feat--api-rate-limits
nimish-ks Dec 20, 2025
194808f
feat: cast pgport as integer
nimish-ks Dec 30, 2025
13a39b4
feat: cast redis port as integrer
nimish-ks Dec 30, 2025
debce9f
feat: enhance Redis SSL configuration in settings.py
nimish-ks Dec 30, 2025
9fb5869
feat: enhance Redis authentication configuration in settings.py
nimish-ks Dec 30, 2025
9eb321a
Merge branch 'main' into feat--api-rate-limits
nimish-ks Dec 30, 2025
7a8c5bb
feat: update Redis username handling in settings.py
nimish-ks Dec 30, 2025
9658ea7
feat: enhance PostgreSQL SSL configuration in settings.py
nimish-ks Dec 30, 2025
5980d35
refactor: clean up settings.py by removing commented-out code
nimish-ks Dec 30, 2025
6874524
chore: update Dockerfile to include ca-certificates
nimish-ks Dec 30, 2025
2323d30
feat: add AWS RDS CA bundle to Dockerfile
nimish-ks Dec 30, 2025
22ea5b6
refactor: remove redundant SSL validation in settings.py
nimish-ks Dec 30, 2025
3b68e4c
refactor: update SSL configuration in settings.py
nimish-ks Dec 30, 2025
dc9de3e
refactor: improve SSL configuration handling in settings.py
nimish-ks Dec 30, 2025
d65d36c
Merge branch 'main' into feat--api-rate-limits
rohan-chaturvedi Jan 1, 2026
00548fa
Merge branch 'main' into feat--api-rate-limits
nimish-ks Jan 8, 2026
9c72103
Merge branch 'main' into feat--api-rate-limits
nimish-ks Jan 11, 2026
f843ec5
refactor: rename REDIS_USERNAME to REDIS_USER for consistency in sett…
nimish-ks Jan 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ RUN set -ex \
| sort -u)" \
&& apk add --virtual rundeps $runDeps \
&& apk del .build-deps
RUN apk add --no-cache curl ca-certificates

# Add AWS RDS eu-central-1 CA bundle
ADD https://truststore.pki.rds.amazonaws.com/eu-central-1/eu-central-1-bundle.pem /etc/ssl/certs/rds-ca-bundle.pem
RUN chmod 644 /etc/ssl/certs/rds-ca-bundle.pem

RUN apk add --no-cache curl
RUN addgroup -S app && adduser -S app -G app
ADD . /app
WORKDIR /app
Expand Down
20 changes: 16 additions & 4 deletions backend/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,20 @@ def authenticate(self, request):
if secret_id:
found = False
try:
secret = Secret.objects.get(id=secret_id)
# Pre-fetch environment, app, and organisation
secret = Secret.objects.select_related(
"environment__app__organisation"
).get(id=secret_id)
env = secret.environment
found = True
except Secret.DoesNotExist:
pass
if not found:
try:
dyn_secret = DynamicSecret.objects.get(id=secret_id)
# Pre-fetch environment, app, and organisation
dyn_secret = DynamicSecret.objects.select_related(
"environment__app__organisation"
).get(id=secret_id)
env = dyn_secret.environment
found = True
except DynamicSecret.DoesNotExist:
Expand All @@ -84,7 +90,10 @@ def authenticate(self, request):
# Try resolving env from header
if env_id:
try:
env = Environment.objects.get(id=env_id)
# Pre-fetch app and organisation
env = Environment.objects.select_related("app__organisation").get(
id=env_id
)
except Environment.DoesNotExist:
raise exceptions.AuthenticationFailed("Environment not found")

Expand All @@ -99,7 +108,10 @@ def authenticate(self, request):
)
if not env_name:
raise exceptions.AuthenticationFailed("Missing env parameter")
env = Environment.objects.get(app_id=app_id, name__iexact=env_name)
# Pre-fetch app and organisation
env = Environment.objects.select_related("app__organisation").get(
app_id=app_id, name__iexact=env_name
)
except Environment.DoesNotExist:
# Check if the app exists to give a more specific error
App = apps.get_model("api", "App")
Expand Down
58 changes: 58 additions & 0 deletions backend/api/throttling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from rest_framework.throttling import SimpleRateThrottle
from django.conf import settings

CLOUD_HOSTED = settings.APP_HOST == "cloud"


class PlanBasedRateThrottle(SimpleRateThrottle):
"""
Limits the rate of API calls based on the Organisation's plan.
Uses the pre-fetched organisation data from request.auth to avoid DB lookups.
"""

scope = "plan_based"

def get_cache_key(self, request, view):
# Identify the user or service account
ident = self.get_ident(request)

if request.user.is_authenticated and request.auth:
if request.auth.get("org_member"):
ident = f"user_{request.auth['org_member'].id}"
elif request.auth.get("service_account"):
ident = f"sa_{request.auth['service_account'].id}"
elif request.auth.get("service_token"):
ident = f"st_{request.auth['service_token'].id}"
else:
ident = f"anon_{ident}"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing fallback in cache key for unmatched auth types

In get_cache_key, when request.user.is_authenticated and request.auth is truthy but none of the three identity checks (org_member, service_account, service_token) match, the ident variable remains as the raw IP address without any prefix. The else clause adding the anon_ prefix only executes when the outer condition is falsy, not when the inner if-elif chain exhausts without a match. While currently unreachable with PhaseTokenAuthentication (which always sets one of these keys), this structure is fragile. Future auth changes could cause authenticated users to share IP-based rate limits instead of identity-based ones.

Fix in CursorΒ Fix in Web


return self.cache_format % {"scope": self.scope, "ident": ident}

def allow_request(self, request, view):
"""
Override allow_request to dynamically set the rate based on the request user's plan.
"""
# Default fallback (reads from REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['plan_based'])
new_rate = self.get_rate()

if request.user.is_authenticated and request.auth:
env = request.auth.get("environment")
if env:
try:
plan = env.app.organisation.plan
new_rate = self.get_rate_for_plan(plan)
except AttributeError:
pass

# Update the throttle configuration for this specific request
self.rate = new_rate
self.num_requests, self.duration = self.parse_rate(self.rate)

return super().allow_request(request, view)

@staticmethod
def get_rate_for_plan(plan):
# If self-hosted return the default rate limit. If not set, this will disable throttling
if not CLOUD_HOSTED:
return settings.PLAN_RATE_LIMITS["DEFAULT"]
return settings.PLAN_RATE_LIMITS.get(plan, settings.PLAN_RATE_LIMITS["DEFAULT"])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plan rate limit fallback fails for unconfigured plans

In get_rate_for_plan, the expression settings.PLAN_RATE_LIMITS.get(plan, settings.PLAN_RATE_LIMITS["DEFAULT"]) doesn't correctly fall back to the default rate when a plan's rate is None. Since PLAN_RATE_LIMITS is populated with os.getenv() calls that return None for unset environment variables, the keys (FR, PR, EN) always exist in the dictionary with None values. Python's dict.get() only uses the default when the key is missing, not when the value is None. In cloud mode, if RATE_LIMIT_FREE is unset but RATE_LIMIT_DEFAULT is configured, Free plan users would get None as their rate, effectively disabling rate limiting for them.

Additional Locations (1)

Fix in CursorΒ Fix in Web

4 changes: 3 additions & 1 deletion backend/api/views/identities/aws/iam.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@
from defusedxml.ElementTree import parse
from django.http import JsonResponse
from django.utils import timezone
from rest_framework.decorators import api_view, permission_classes
from rest_framework.decorators import api_view, permission_classes, throttle_classes
from rest_framework.permissions import AllowAny

from api.utils.identity.common import (
resolve_service_account,
mint_service_account_token,
)
from api.throttling import PlanBasedRateThrottle


def get_normalized_host(uri):
Expand All @@ -27,6 +28,7 @@ def get_normalized_host(uri):

@api_view(["POST"])
@permission_classes([AllowAny])
@throttle_classes([PlanBasedRateThrottle])
def aws_iam_auth(request):
"""Accepts SigV4-signed STS GetCallerIdentity request and issues a ServiceAccount token if trusted."""
try:
Expand Down
3 changes: 3 additions & 0 deletions backend/api/views/secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import json
from api.content_negotiation import CamelCaseContentNegotiation
from api.utils.access.middleware import IsIPAllowed
from api.throttling import PlanBasedRateThrottle
from ee.integrations.secrets.dynamic.exceptions import (
DynamicSecretError,
PlanRestrictionError,
Expand Down Expand Up @@ -60,6 +61,7 @@
class E2EESecretsView(APIView):
authentication_classes = [PhaseTokenAuthentication]
permission_classes = [IsAuthenticated, IsIPAllowed]
throttle_classes = [PlanBasedRateThrottle]
content_negotiation_class = CamelCaseContentNegotiation

def initial(self, request, *args, **kwargs):
Expand Down Expand Up @@ -489,6 +491,7 @@ def delete(self, request, *args, **kwargs):
class PublicSecretsView(APIView):
authentication_classes = [PhaseTokenAuthentication]
permission_classes = [IsAuthenticated, IsIPAllowed]
throttle_classes = [PlanBasedRateThrottle]
renderer_classes = [
CamelCaseJSONRenderer,
]
Expand Down
114 changes: 89 additions & 25 deletions backend/backend/settings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
from pathlib import Path
from urllib.parse import quote as urlquote
import logging.config
from backend.utils.secrets import get_secret
from ee.licensing.verifier import check_license
Expand Down Expand Up @@ -50,16 +51,10 @@ def get_version():


VERSION = get_version()

# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = get_secret("SECRET_KEY")

SERVER_SECRET = get_secret("SERVER_SECRET")

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True if os.getenv("DEBUG") == "True" else False

ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", []).split(",")
Expand All @@ -72,8 +67,6 @@ def get_version():

SESSION_COOKIE_AGE = 604800 # 1 week, in seconds

# Application definition

INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
Expand Down Expand Up @@ -241,13 +234,27 @@ def get_version():
"USER_DETAILS_SERIALIZER": "api.serializers.CustomUserSerializer"
}

# Global rate limit
PLAN_RATE_LIMITS = {
# PHASE CLOUD
"FR": os.getenv("RATE_LIMIT_FREE"),
"PR": os.getenv("RATE_LIMIT_PRO"),
"EN": os.getenv("RATE_LIMIT_ENTERPRISE"),
# PHASE SELF-HOSTED
"DEFAULT": os.getenv("RATE_LIMIT_DEFAULT"),
}

REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_THROTTLE_CLASSES": [],
"DEFAULT_THROTTLE_RATES": {
"plan_based": PLAN_RATE_LIMITS["DEFAULT"],
},
"EXCEPTION_HANDLER": "backend.exceptions.custom_exception_handler",
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
Expand Down Expand Up @@ -290,7 +297,80 @@ def get_version():
"PASSWORD": get_secret("DATABASE_PASSWORD"),
"NAME": os.getenv("DATABASE_NAME"),
"HOST": os.getenv("DATABASE_HOST"),
"PORT": os.getenv("DATABASE_PORT"),
"PORT": int(os.getenv("DATABASE_PORT", "5432")),
"OPTIONS": (
{
"sslmode": "verify-full"
if os.getenv("DATABASE_SSL_CA_PATH")
else "require",
**(
{"sslrootcert": os.getenv("DATABASE_SSL_CA_PATH")}
if os.getenv("DATABASE_SSL_CA_PATH")
else {}
),
}
if os.getenv("DATABASE_SSL", "False").lower() == "true"
else {}
),
},
}

REDIS_HOST = os.getenv("REDIS_HOST")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_USER = os.getenv("REDIS_USER") or None
REDIS_PASSWORD = get_secret("REDIS_PASSWORD")
REDIS_SSL = os.getenv("REDIS_SSL", "False").lower() == "true"
REDIS_PROTOCOL = "rediss" if REDIS_SSL else "redis"

if REDIS_USER and REDIS_PASSWORD:
REDIS_AUTH = f"{urlquote(REDIS_USER, safe='')}:{urlquote(REDIS_PASSWORD, safe='')}@"
elif REDIS_PASSWORD:
REDIS_AUTH = f":{urlquote(REDIS_PASSWORD, safe='')}@"
else:
REDIS_AUTH = ""

CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": f"{REDIS_PROTOCOL}://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/1",
"OPTIONS": (
{
"ssl_cert_reqs": "required",
"ssl_ca_certs": os.getenv("REDIS_SSL_CA_PATH"),
}
if REDIS_SSL
else {}
),
}
}

RQ_SSL_OPTIONS = (
{
"ssl_cert_reqs": "required",
"ssl_ca_certs": os.getenv("REDIS_SSL_CA_PATH"),
}
if REDIS_SSL
else None
)

RQ_QUEUES = {
"default": {
"HOST": REDIS_HOST,
"PORT": REDIS_PORT,
"USERNAME": REDIS_USER,
"PASSWORD": REDIS_PASSWORD,
"SSL": REDIS_SSL,
"SSL_OPTIONS": RQ_SSL_OPTIONS,
"DB": 0,
},
"scheduled-jobs": {
"HOST": REDIS_HOST,
"PORT": REDIS_PORT,
"USERNAME": REDIS_USER,
"PASSWORD": REDIS_PASSWORD,
"SSL": REDIS_SSL,
"SSL_OPTIONS": RQ_SSL_OPTIONS,
"DB": 0,
},
}

Expand Down Expand Up @@ -358,22 +438,6 @@ def get_version():
except:
APP_HOST = "self"

RQ_QUEUES = {
"default": {
"HOST": os.getenv("REDIS_HOST"),
"PORT": os.getenv("REDIS_PORT"),
"PASSWORD": get_secret("REDIS_PASSWORD"),
"SSL": os.getenv("REDIS_SSL", None),
"DB": 0,
},
"scheduled-jobs": {
"HOST": os.getenv("REDIS_HOST"),
"PORT": os.getenv("REDIS_PORT"),
"PASSWORD": get_secret("REDIS_PASSWORD"),
"SSL": os.getenv("REDIS_SSL", None),
"DB": 0,
},
}

PHASE_LICENSE = check_license(get_secret("PHASE_LICENSE_OFFLINE"))

Expand Down
23 changes: 23 additions & 0 deletions backend/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import os
import django

# Set environment variables required for settings.py to import successfully
os.environ.setdefault("ALLOWED_HOSTS", "localhost")
os.environ.setdefault("ALLOWED_ORIGINS", "http://localhost")

# Set dummy Redis values so settings.py generates a valid URL (e.g. redis://localhost:6379/1)
os.environ.setdefault("REDIS_HOST", "localhost")
os.environ.setdefault("REDIS_PORT", "6379")

# Set dummy database config
os.environ.setdefault("DATABASE_HOST", "localhost")
os.environ.setdefault("DATABASE_PORT", "5432")
os.environ.setdefault("DATABASE_NAME", "dummy_db")
os.environ.setdefault("DATABASE_USER", "dummy_user")
os.environ.setdefault("DATABASE_PASSWORD", "dummy_password")

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")


def pytest_configure():
django.setup()
1 change: 1 addition & 0 deletions backend/dev-requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pytest==8.3.4
pytest-django==4.11.1
pytest-cov==7.0.0
Faker==37.4.0
colorama==0.4.6
Expand Down
3 changes: 3 additions & 0 deletions backend/ee/integrations/secrets/dynamic/rest/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
)

from api.utils.access.middleware import IsIPAllowed
from api.throttling import PlanBasedRateThrottle
from ee.integrations.secrets.dynamic.aws.utils import (
revoke_aws_dynamic_secret_lease,
)
Expand Down Expand Up @@ -50,6 +51,7 @@
class DynamicSecretsView(APIView):
authentication_classes = [PhaseTokenAuthentication]
permission_classes = [IsAuthenticated, IsIPAllowed]
throttle_classes = [PlanBasedRateThrottle]
renderer_classes = [
CamelCaseJSONRenderer,
]
Expand Down Expand Up @@ -200,6 +202,7 @@ def get(self, request, *args, **kwargs):
class DynamicSecretLeaseView(APIView):
authentication_classes = [PhaseTokenAuthentication]
permission_classes = [IsAuthenticated, IsIPAllowed]
throttle_classes = [PlanBasedRateThrottle]
renderer_classes = [CamelCaseJSONRenderer]

def _get_account_and_org(self, request):
Expand Down
2 changes: 2 additions & 0 deletions backend/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[pytest]
python_files = tests.py test_*.py *_tests.py
Loading