Skip to content

Commit ef3d42e

Browse files
committed
feat: Add FastAPI backend with rate limiting service and update security documentation.
1 parent a0fe281 commit ef3d42e

File tree

3 files changed

+194
-1
lines changed

3 files changed

+194
-1
lines changed

SECURITY.md

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,65 @@ This ensures:
118118

119119
---
120120

121-
## API Key Storage
121+
## Multi-Tenant Isolation Guarantees
122+
123+
### Design Principle
124+
125+
```
126+
┌───────────────────────────────────────────────────────────────────┐
127+
│ MULTI-TENANT ISOLATION GUARANTEES │
128+
├───────────────────────────────────────────────────────────────────┤
129+
│ │
130+
│ ✅ User A can ONLY access: ❌ User A can NEVER access: │
131+
│ - Their own OAuth tokens - User B's tokens │
132+
│ - Their own GitHub activity - User B's activity │
133+
│ - Their own LinkedIn posts - User B's posts │
134+
│ - Their own settings - User B's settings │
135+
│ │
136+
└───────────────────────────────────────────────────────────────────┘
137+
```
138+
139+
### Implementation Details
140+
141+
**Database Level:**
142+
- Every query includes `WHERE user_id = ?`
143+
- User ID is the Clerk authentication ID
144+
- No admin endpoints return all users' data
145+
146+
**API Level:**
147+
- User ID extracted from JWT claims
148+
- All endpoints scoped to authenticated user
149+
- Cross-user access returns 404/403
150+
151+
**Service Level:**
152+
- GitHub activity: Scoped by username/token
153+
- AI generation: Receives only user's activity
154+
- LinkedIn posting: Uses only user's OAuth token
155+
156+
### Token Validation
157+
158+
Before any operation that requires an OAuth token:
159+
160+
```python
161+
# 1. Verify user is authenticated (Clerk JWT)
162+
# 2. Retrieve token by user_id (tenant isolation)
163+
# 3. Check token exists
164+
# 4. Check token not expired
165+
# 5. Proceed or return error
166+
```
167+
168+
**Graceful Failure Handling:**
169+
- Missing token → "Please connect your account"
170+
- Expired token → "Please reconnect your account"
171+
- Invalid token → "Authentication failed, please reconnect"
172+
- Rate limited → "Too many requests, please wait"
173+
174+
### Cross-User Prevention
175+
176+
1. **No token enumeration** — Tokens keyed by user_id, not sequential IDs
177+
2. **No URN guessing** — LinkedIn URN not exposed externally
178+
3. **Parameterized queries** — SQL injection prevented
179+
4. **JWT validation** — User identity verified on every request
122180

123181
### In-Transit
124182

backend/app.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@
6464
get_user_posts = None
6565
get_user_stats = None
6666

67+
# Import Rate Limiter
68+
try:
69+
from services.rate_limiter import check_rate_limit, get_rate_limit_status
70+
except ImportError:
71+
check_rate_limit = None
72+
get_rate_limit_status = None
73+
6774
try:
6875
# Import core functions from the refactored services
6976
# from services.ai_service import generate_post_with_ai # Already imported above

services/rate_limiter.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""
2+
Rate Limiter Service - Per-User Request Throttling
3+
4+
Implements simple in-memory rate limiting to prevent abuse.
5+
6+
CONFIGURATION:
7+
- Default: 60 requests per minute per user
8+
- Configurable via environment variables
9+
10+
DESIGN:
11+
- In-memory storage (resets on server restart)
12+
- Keyed by user_id for multi-tenant isolation
13+
- Sliding window algorithm
14+
"""
15+
import time
16+
import os
17+
from collections import defaultdict
18+
from threading import Lock
19+
import logging
20+
21+
logger = logging.getLogger(__name__)
22+
23+
# Configuration
24+
RATE_LIMIT_REQUESTS = int(os.getenv('RATE_LIMIT_REQUESTS', '60'))
25+
RATE_LIMIT_WINDOW_SECONDS = int(os.getenv('RATE_LIMIT_WINDOW', '60'))
26+
27+
28+
class RateLimiter:
29+
"""
30+
Thread-safe per-user rate limiter using sliding window.
31+
32+
MULTI-TENANT ISOLATION:
33+
- Each user has their own request counter
34+
- No cross-user rate limit sharing
35+
"""
36+
37+
def __init__(self, max_requests: int = RATE_LIMIT_REQUESTS,
38+
window_seconds: int = RATE_LIMIT_WINDOW_SECONDS):
39+
self.max_requests = max_requests
40+
self.window_seconds = window_seconds
41+
self.requests = defaultdict(list) # user_id -> [timestamps]
42+
self.lock = Lock()
43+
44+
def is_allowed(self, user_id: str) -> tuple[bool, dict]:
45+
"""
46+
Check if a request is allowed for a user.
47+
48+
Args:
49+
user_id: Clerk user ID (tenant isolation key)
50+
51+
Returns:
52+
(allowed: bool, info: dict with remaining, reset_at, etc.)
53+
54+
SECURITY: Uses user_id to ensure rate limits are per-tenant.
55+
"""
56+
if not user_id:
57+
# Anonymous requests - use stricter limit
58+
user_id = "anonymous"
59+
60+
current_time = time.time()
61+
window_start = current_time - self.window_seconds
62+
63+
with self.lock:
64+
# Clean old requests
65+
self.requests[user_id] = [
66+
ts for ts in self.requests[user_id]
67+
if ts > window_start
68+
]
69+
70+
request_count = len(self.requests[user_id])
71+
remaining = max(0, self.max_requests - request_count)
72+
73+
if request_count >= self.max_requests:
74+
# Rate limited
75+
oldest = self.requests[user_id][0] if self.requests[user_id] else current_time
76+
reset_at = oldest + self.window_seconds
77+
return False, {
78+
"allowed": False,
79+
"remaining": 0,
80+
"limit": self.max_requests,
81+
"reset_at": int(reset_at),
82+
"retry_after": int(reset_at - current_time)
83+
}
84+
85+
# Allow and record
86+
self.requests[user_id].append(current_time)
87+
88+
return True, {
89+
"allowed": True,
90+
"remaining": remaining - 1,
91+
"limit": self.max_requests,
92+
"reset_at": int(current_time + self.window_seconds)
93+
}
94+
95+
def get_status(self, user_id: str) -> dict:
96+
"""Get current rate limit status for a user without consuming quota."""
97+
current_time = time.time()
98+
window_start = current_time - self.window_seconds
99+
100+
with self.lock:
101+
requests = [ts for ts in self.requests.get(user_id, []) if ts > window_start]
102+
remaining = max(0, self.max_requests - len(requests))
103+
104+
return {
105+
"remaining": remaining,
106+
"limit": self.max_requests,
107+
"window_seconds": self.window_seconds,
108+
"used": len(requests)
109+
}
110+
111+
112+
# Global rate limiter instance
113+
rate_limiter = RateLimiter()
114+
115+
116+
def check_rate_limit(user_id: str) -> tuple[bool, dict]:
117+
"""
118+
Convenience function to check rate limit.
119+
120+
Returns:
121+
(allowed: bool, info: dict)
122+
"""
123+
return rate_limiter.is_allowed(user_id)
124+
125+
126+
def get_rate_limit_status(user_id: str) -> dict:
127+
"""Get rate limit status without consuming quota."""
128+
return rate_limiter.get_status(user_id)

0 commit comments

Comments
 (0)