Codex commited on
Commit
34bbf1b
·
1 Parent(s): a2463e6

Fix Clerk account recovery and tier safety

Browse files
backend/app/clerk_billing.py CHANGED
@@ -48,19 +48,20 @@ def plan_candidates(data: dict[str, Any]) -> set[str]:
48
  return candidates
49
 
50
 
51
- def tier_from_clerk_billing(data: dict[str, Any]) -> str:
52
- status = text(data.get("status"))
53
- if status and status.lower() in FREE_STATUSES:
54
- return "free"
 
 
 
 
 
55
  candidates = plan_candidates(data)
56
  enterprise = csv_set(settings.clerk_enterprise_plan_ids) | csv_set(settings.clerk_enterprise_plan_slugs)
57
  paid = csv_set(settings.clerk_paid_plan_ids) | csv_set(settings.clerk_paid_plan_slugs)
58
  if candidates & enterprise:
59
  return "enterprise"
60
- if candidates & paid:
61
- return "paid"
62
- if any("enterprise" in item for item in candidates):
63
- return "enterprise"
64
- if any(item in {"paid", "pro"} or "paid" in item or "pro" in item for item in candidates):
65
- return "paid"
66
- return "free"
 
48
  return candidates
49
 
50
 
51
+ def tier_from_clerk_billing(data: dict[str, Any]) -> str | None:
52
+ """Return a tier only when Clerk Billing gives an unambiguous answer.
53
+
54
+ A missing or unfamiliar plan must never downgrade an existing user. This
55
+ avoids assigning tiers based on partial checkout payloads or renamed plans.
56
+ """
57
+ status = text(data.get("status"))
58
+ if status and status.lower() in FREE_STATUSES:
59
+ return "free"
60
  candidates = plan_candidates(data)
61
  enterprise = csv_set(settings.clerk_enterprise_plan_ids) | csv_set(settings.clerk_enterprise_plan_slugs)
62
  paid = csv_set(settings.clerk_paid_plan_ids) | csv_set(settings.clerk_paid_plan_slugs)
63
  if candidates & enterprise:
64
  return "enterprise"
65
+ if candidates & paid:
66
+ return "paid"
67
+ return None
 
 
 
 
backend/app/config.py CHANGED
@@ -47,7 +47,9 @@ class Settings(BaseSettings):
47
  environment: str = "development"
48
 
49
  # Auth / Database
50
- database_url: str = "sqlite:///./malwareguard.db"
 
 
51
  jwt_secret_key: str = DEFAULT_JWT_SECRET
52
  jwt_expire_minutes: int = 60 * 24
53
  admin_emails: str = ""
@@ -57,7 +59,8 @@ class Settings(BaseSettings):
57
  clerk_secret_key: str = ""
58
  clerk_issuer: str = ""
59
  clerk_jwks_url: str = ""
60
- clerk_api_url: str = "https://api.clerk.com"
 
61
  clerk_webhook_secret: str = ""
62
  clerk_paid_plan_ids: str = "cplan_3E9Yp1Qf0qVbvx4x15QndPQyLfA"
63
  clerk_paid_plan_slugs: str = "paid,pro,pro_plan"
 
47
  environment: str = "development"
48
 
49
  # Auth / Database
50
+ # Set DATABASE_URL for managed Postgres. When omitted, production Spaces
51
+ # use /data automatically when Hugging Face persistent storage is attached.
52
+ database_url: str = ""
53
  jwt_secret_key: str = DEFAULT_JWT_SECRET
54
  jwt_expire_minutes: int = 60 * 24
55
  admin_emails: str = ""
 
59
  clerk_secret_key: str = ""
60
  clerk_issuer: str = ""
61
  clerk_jwks_url: str = ""
62
+ clerk_api_url: str = "https://api.clerk.com"
63
+ clerk_startup_user_sync: bool = True
64
  clerk_webhook_secret: str = ""
65
  clerk_paid_plan_ids: str = "cplan_3E9Yp1Qf0qVbvx4x15QndPQyLfA"
66
  clerk_paid_plan_slugs: str = "paid,pro,pro_plan"
backend/app/database.py CHANGED
@@ -1,16 +1,34 @@
1
- """Database session and schema initialization."""
2
-
3
- from sqlalchemy import create_engine, inspect, text
 
 
4
  from sqlalchemy.orm import declarative_base, sessionmaker
5
 
6
  from .config import settings
7
 
8
 
9
- def _database_url() -> str:
10
- url = settings.database_url
11
- if url.startswith("postgres://"):
12
- return url.replace("postgres://", "postgresql://", 1)
13
- return url
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
 
16
  connect_args = {"check_same_thread": False} if _database_url().startswith("sqlite") else {}
 
1
+ """Database session and schema initialization."""
2
+
3
+ from pathlib import Path
4
+
5
+ from sqlalchemy import create_engine, inspect, text
6
  from sqlalchemy.orm import declarative_base, sessionmaker
7
 
8
  from .config import settings
9
 
10
 
11
+ def _database_url() -> str:
12
+ url = settings.database_url.strip()
13
+ if not url:
14
+ # Hugging Face mounts persistent Space storage at /data. Falling back
15
+ # to the container working directory is safe for local development,
16
+ # but intentionally visible in production logs because it is ephemeral.
17
+ if settings.is_production and Path("/data").is_dir():
18
+ url = "sqlite:////data/malwareguard.db"
19
+ else:
20
+ url = "sqlite:///./malwareguard.db"
21
+ if url.startswith("postgres://"):
22
+ return url.replace("postgres://", "postgresql://", 1)
23
+ return url
24
+
25
+
26
+ def database_is_ephemeral() -> bool:
27
+ """Return whether the configured SQLite path will disappear on Space rebuilds."""
28
+ url = _database_url()
29
+ if not url.startswith("sqlite"):
30
+ return False
31
+ return "/data/" not in url.replace("\\", "/")
32
 
33
 
34
  connect_args = {"check_same_thread": False} if _database_url().startswith("sqlite") else {}
backend/app/dependencies.py CHANGED
@@ -1,8 +1,9 @@
1
  """Auth, role, and quota dependencies shared by routers."""
2
 
3
- from datetime import datetime, timedelta
4
- import hashlib
5
- import time
 
6
 
7
  from fastapi import Cookie, Depends, HTTPException, Request, status
8
  from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -12,16 +13,17 @@ from sqlalchemy.orm import Session
12
 
13
  from .clerk_billing import tier_from_clerk_billing
14
  from .config import settings
15
- from .database import get_db
16
  from .db_models import AuditLog, ScanEvent, User
17
  from .security import decode_access_token
18
 
19
  bearer_scheme = HTTPBearer(auto_error=False)
20
  ANALYST_TIERS = {"enterprise"}
21
  ADMIN_ROLES = {"admin"}
22
- _clerk_jwks_cache: dict[str, object] = {"expires_at": 0, "keys": []}
23
- _clerk_user_cache: dict[str, object] = {} # clerk_user_id -> {"data": ..., "expires_at": float}
24
- _CLERK_USER_CACHE_TTL = 300 # 5 minutes
 
25
 
26
 
27
  def is_analyst(user: User | None) -> bool:
@@ -97,7 +99,7 @@ async def _fetch_clerk_user(clerk_user_id: str) -> dict | None:
97
  return None
98
 
99
 
100
- async def _fetch_clerk_subscription(clerk_user_id: str) -> dict | None:
101
  if not settings.clerk_secret_key:
102
  return None
103
  now = time.time()
@@ -113,8 +115,33 @@ async def _fetch_clerk_subscription(clerk_user_id: str) -> dict | None:
113
  result = data if isinstance(data, dict) else None
114
  _clerk_user_cache[f"sub:{clerk_user_id}"] = {"data": result, "expires_at": now + _CLERK_USER_CACHE_TTL}
115
  return result
116
- except httpx.HTTPError:
117
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
 
120
  def _email_from_clerk_payload(payload: dict, clerk_user_id: str) -> str:
@@ -125,12 +152,16 @@ def _email_from_clerk_payload(payload: dict, clerk_user_id: str) -> str:
125
  return f"{clerk_user_id}@clerk.local"
126
 
127
 
128
- async def _upsert_clerk_user(db: Session, payload: dict) -> User | None:
 
 
 
 
129
  clerk_user_id = payload.get("sub")
130
  if not isinstance(clerk_user_id, str) or not clerk_user_id:
131
  return None
132
  user = db.query(User).filter(User.clerk_user_id == clerk_user_id).first()
133
- clerk_user = await _fetch_clerk_user(clerk_user_id)
134
  email = _email_from_clerk_payload(payload, clerk_user_id)
135
  display_name = payload.get("name")
136
  if clerk_user:
@@ -148,8 +179,12 @@ async def _upsert_clerk_user(db: Session, payload: dict) -> User | None:
148
  )
149
  or display_name
150
  )
151
- if not user:
152
- user = db.query(User).filter(User.email == email).first()
 
 
 
 
153
  if not user:
154
  user = User(
155
  clerk_user_id=clerk_user_id,
@@ -160,17 +195,54 @@ async def _upsert_clerk_user(db: Session, payload: dict) -> User | None:
160
  tier="free",
161
  )
162
  db.add(user)
163
- else:
164
- user.clerk_user_id = user.clerk_user_id or clerk_user_id
165
- user.email = email
 
 
 
 
166
  if display_name:
167
  user.display_name = display_name
168
- subscription = await _fetch_clerk_subscription(clerk_user_id)
169
- if subscription:
170
- user.tier = tier_from_clerk_billing(subscription)
 
 
171
  db.commit()
172
  db.refresh(user)
173
- return user
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
 
176
  async def get_current_user_optional(
 
1
  """Auth, role, and quota dependencies shared by routers."""
2
 
3
+ from datetime import datetime, timedelta
4
+ import hashlib
5
+ import logging
6
+ import time
7
 
8
  from fastapi import Cookie, Depends, HTTPException, Request, status
9
  from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
 
13
 
14
  from .clerk_billing import tier_from_clerk_billing
15
  from .config import settings
16
+ from .database import SessionLocal, get_db
17
  from .db_models import AuditLog, ScanEvent, User
18
  from .security import decode_access_token
19
 
20
  bearer_scheme = HTTPBearer(auto_error=False)
21
  ANALYST_TIERS = {"enterprise"}
22
  ADMIN_ROLES = {"admin"}
23
+ _clerk_jwks_cache: dict[str, object] = {"expires_at": 0, "keys": []}
24
+ _clerk_user_cache: dict[str, object] = {} # clerk_user_id -> {"data": ..., "expires_at": float}
25
+ _CLERK_USER_CACHE_TTL = 300 # 5 minutes
26
+ logger = logging.getLogger(__name__)
27
 
28
 
29
  def is_analyst(user: User | None) -> bool:
 
99
  return None
100
 
101
 
102
+ async def _fetch_clerk_subscription(clerk_user_id: str) -> dict | None:
103
  if not settings.clerk_secret_key:
104
  return None
105
  now = time.time()
 
115
  result = data if isinstance(data, dict) else None
116
  _clerk_user_cache[f"sub:{clerk_user_id}"] = {"data": result, "expires_at": now + _CLERK_USER_CACHE_TTL}
117
  return result
118
+ except httpx.HTTPError:
119
+ return None
120
+
121
+
122
+ async def _list_clerk_users(offset: int, limit: int) -> tuple[list[dict], int | None]:
123
+ if not settings.clerk_secret_key:
124
+ return [], None
125
+ url = f"{settings.clerk_api_url.rstrip('/')}/v1/users"
126
+ try:
127
+ async with httpx.AsyncClient(timeout=10.0) as client:
128
+ response = await client.get(
129
+ url,
130
+ headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
131
+ params={"limit": limit, "offset": offset},
132
+ )
133
+ response.raise_for_status()
134
+ payload = response.json()
135
+ except httpx.HTTPError:
136
+ logger.warning("Could not synchronize Clerk users during startup", exc_info=True)
137
+ return [], None
138
+ if isinstance(payload, list):
139
+ return [item for item in payload if isinstance(item, dict)], None
140
+ if not isinstance(payload, dict):
141
+ return [], None
142
+ users = payload.get("data", [])
143
+ total_count = payload.get("total_count")
144
+ return [item for item in users if isinstance(item, dict)], total_count if isinstance(total_count, int) else None
145
 
146
 
147
  def _email_from_clerk_payload(payload: dict, clerk_user_id: str) -> str:
 
152
  return f"{clerk_user_id}@clerk.local"
153
 
154
 
155
+ async def _upsert_clerk_user(
156
+ db: Session,
157
+ payload: dict,
158
+ clerk_user: dict | None = None,
159
+ ) -> User | None:
160
  clerk_user_id = payload.get("sub")
161
  if not isinstance(clerk_user_id, str) or not clerk_user_id:
162
  return None
163
  user = db.query(User).filter(User.clerk_user_id == clerk_user_id).first()
164
+ clerk_user = clerk_user or await _fetch_clerk_user(clerk_user_id)
165
  email = _email_from_clerk_payload(payload, clerk_user_id)
166
  display_name = payload.get("name")
167
  if clerk_user:
 
179
  )
180
  or display_name
181
  )
182
+ if not user:
183
+ email_user = db.query(User).filter(User.email == email).first()
184
+ if email_user and email_user.clerk_user_id not in {None, clerk_user_id}:
185
+ logger.error("Refusing Clerk identity collision for email %s", email)
186
+ return None
187
+ user = email_user
188
  if not user:
189
  user = User(
190
  clerk_user_id=clerk_user_id,
 
195
  tier="free",
196
  )
197
  db.add(user)
198
+ else:
199
+ user.clerk_user_id = user.clerk_user_id or clerk_user_id
200
+ email_owner = db.query(User).filter(User.email == email).first()
201
+ if email_owner and email_owner.id != user.id:
202
+ logger.error("Refusing Clerk email reassignment for user %s", clerk_user_id)
203
+ return None
204
+ user.email = email
205
  if display_name:
206
  user.display_name = display_name
207
+ subscription = await _fetch_clerk_subscription(clerk_user_id)
208
+ if subscription:
209
+ tier = tier_from_clerk_billing(subscription)
210
+ if tier is not None:
211
+ user.tier = tier
212
  db.commit()
213
  db.refresh(user)
214
+ return user
215
+
216
+
217
+ async def sync_clerk_users_from_clerk() -> int:
218
+ """Rebuild the local Clerk user projection after an ephemeral Space restart."""
219
+ if not settings.clerk_startup_user_sync or not settings.clerk_secret_key:
220
+ return 0
221
+
222
+ synced = 0
223
+ offset = 0
224
+ page_size = 100
225
+ db = SessionLocal()
226
+ try:
227
+ while True:
228
+ clerk_users, total_count = await _list_clerk_users(offset, page_size)
229
+ if not clerk_users:
230
+ break
231
+ for clerk_user in clerk_users:
232
+ clerk_user_id = clerk_user.get("id")
233
+ if not isinstance(clerk_user_id, str):
234
+ continue
235
+ user = await _upsert_clerk_user(db, {"sub": clerk_user_id}, clerk_user=clerk_user)
236
+ if user:
237
+ synced += 1
238
+ offset += len(clerk_users)
239
+ if total_count is not None and offset >= total_count:
240
+ break
241
+ if len(clerk_users) < page_size:
242
+ break
243
+ finally:
244
+ db.close()
245
+ return synced
246
 
247
 
248
  async def get_current_user_optional(
backend/app/main.py CHANGED
@@ -20,7 +20,7 @@ from slowapi.errors import RateLimitExceeded
20
  from .config import settings
21
  from .rate_limit import limiter
22
  from .routers import admin, auth, partner, predict, virustotal, chat, upload, url, feedback, generic_upload, webhooks
23
- from .database import init_db
24
  from .services.ml_service import get_ml_service
25
  from .services.shap_service import get_shap_service
26
  from .services.gemini_service import get_gemini_service
@@ -29,7 +29,8 @@ from .services.url_service import get_url_service
29
  from .services.feedback_service import get_feedback_service
30
  from .services.csv_analyzer import get_csv_analyzer
31
  from .services.mp4_analyzer import get_mp4_analyzer
32
- from .services.bootstrap_service import bootstrap_admin_account
 
33
 
34
  # Configure logging.
35
  logging.basicConfig(
@@ -51,9 +52,16 @@ async def lifespan(app: FastAPI):
51
  settings.validate_security()
52
 
53
  try:
54
- init_db()
55
- logger.info("Database schema ready")
56
- bootstrap_admin_account()
 
 
 
 
 
 
 
57
  except Exception as e:
58
  logger.error(f"Database initialization failed: {e}")
59
  raise
 
20
  from .config import settings
21
  from .rate_limit import limiter
22
  from .routers import admin, auth, partner, predict, virustotal, chat, upload, url, feedback, generic_upload, webhooks
23
+ from .database import database_is_ephemeral, init_db
24
  from .services.ml_service import get_ml_service
25
  from .services.shap_service import get_shap_service
26
  from .services.gemini_service import get_gemini_service
 
29
  from .services.feedback_service import get_feedback_service
30
  from .services.csv_analyzer import get_csv_analyzer
31
  from .services.mp4_analyzer import get_mp4_analyzer
32
+ from .services.bootstrap_service import bootstrap_admin_account
33
+ from .dependencies import sync_clerk_users_from_clerk
34
 
35
  # Configure logging.
36
  logging.basicConfig(
 
52
  settings.validate_security()
53
 
54
  try:
55
+ init_db()
56
+ logger.info("Database schema ready")
57
+ if settings.is_production and database_is_ephemeral():
58
+ logger.warning(
59
+ "Using ephemeral SQLite storage. Configure DATABASE_URL or attach Hugging Face persistent storage at /data."
60
+ )
61
+ synced_users = await sync_clerk_users_from_clerk()
62
+ if synced_users:
63
+ logger.info("Synchronized %s Clerk users into the local account projection", synced_users)
64
+ bootstrap_admin_account()
65
  except Exception as e:
66
  logger.error(f"Database initialization failed: {e}")
67
  raise
backend/app/routers/webhooks.py CHANGED
@@ -68,10 +68,13 @@ def _upsert_user_from_clerk(data: dict[str, Any], event_type: str, db: Session)
68
  clerk_user_id = _find_user_id(data, event_type)
69
  if not clerk_user_id:
70
  return None
71
- user = db.query(User).filter(User.clerk_user_id == clerk_user_id).first()
72
- email = _find_email(data)
73
- if not user and email:
74
- user = db.query(User).filter(User.email == email).first()
 
 
 
75
  if not user:
76
  user = User(
77
  clerk_user_id=clerk_user_id,
@@ -81,10 +84,13 @@ def _upsert_user_from_clerk(data: dict[str, Any], event_type: str, db: Session)
81
  tier="free",
82
  )
83
  db.add(user)
84
- else:
85
- user.clerk_user_id = user.clerk_user_id or clerk_user_id
86
- if email:
87
- user.email = email
 
 
 
88
  display_name = _find_display_name(data)
89
  if display_name:
90
  user.display_name = display_name
@@ -117,10 +123,12 @@ async def clerk_webhook(request: Request, db: Session = Depends(get_db)):
117
 
118
  if event_type.startswith("user.deleted"):
119
  user.is_active = False
120
- elif event_type.startswith("user."):
121
- user.is_active = True
122
- else:
123
- user.tier = tier_from_clerk_billing(data)
 
 
124
 
125
  db.commit()
126
  db.refresh(user)
 
68
  clerk_user_id = _find_user_id(data, event_type)
69
  if not clerk_user_id:
70
  return None
71
+ user = db.query(User).filter(User.clerk_user_id == clerk_user_id).first()
72
+ email = _find_email(data)
73
+ if not user and email:
74
+ email_user = db.query(User).filter(User.email == email).first()
75
+ if email_user and email_user.clerk_user_id not in {None, clerk_user_id}:
76
+ return None
77
+ user = email_user
78
  if not user:
79
  user = User(
80
  clerk_user_id=clerk_user_id,
 
84
  tier="free",
85
  )
86
  db.add(user)
87
+ else:
88
+ user.clerk_user_id = user.clerk_user_id or clerk_user_id
89
+ if email:
90
+ email_owner = db.query(User).filter(User.email == email).first()
91
+ if email_owner and email_owner.id != user.id:
92
+ return None
93
+ user.email = email
94
  display_name = _find_display_name(data)
95
  if display_name:
96
  user.display_name = display_name
 
123
 
124
  if event_type.startswith("user.deleted"):
125
  user.is_active = False
126
+ elif event_type.startswith("user."):
127
+ user.is_active = True
128
+ else:
129
+ tier = tier_from_clerk_billing(data)
130
+ if tier is not None:
131
+ user.tier = tier
132
 
133
  db.commit()
134
  db.refresh(user)