Skip to content

Commit efcaff0

Browse files
authored
✨ Now supports regular user account deletion
2 parents 0e73955 + c194f47 commit efcaff0

27 files changed

+1436
-115
lines changed

backend/apps/user_management_app.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from consts.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException
1212
from services.user_management_service import get_authorized_client, validate_token, \
1313
check_auth_service_health, signup_user, signin_user, refresh_user_token, \
14-
get_session_by_authorization
14+
get_session_by_authorization, revoke_regular_user
1515
from consts.exceptions import UnauthorizedError
1616
from utils.auth_utils import get_current_user_id
1717

@@ -69,7 +69,7 @@ async def signup(request: UserSignUpRequest):
6969
detail="EMAIL_ALREADY_EXISTS")
7070
except AuthWeakPasswordError as e:
7171
logging.error(f"User registration failed by weak password: {str(e)}")
72-
raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
72+
raise HTTPException(status_code=HTTPStatus.NOT_ACCEPTABLE,
7373
detail="WEAK_PASSWORD")
7474
except Exception as e:
7575
logging.error(f"User registration failed, unknown error: {str(e)}")
@@ -87,7 +87,7 @@ async def signin(request: UserSignInRequest):
8787
content=signin_content)
8888
except AuthApiError as e:
8989
logging.error(f"User login failed: {str(e)}")
90-
raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
90+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED,
9191
detail="Email or password error")
9292
except Exception as e:
9393
logging.error(f"User login failed, unknown error: {str(e)}")
@@ -200,3 +200,48 @@ async def get_user_id(request: Request):
200200
logging.error(f"Get user ID failed: {str(e)}")
201201
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
202202
detail="Get user ID failed")
203+
204+
205+
@router.post("/revoke")
206+
async def revoke_user_account(request: Request):
207+
"""Delete current regular user's account and purge related data.
208+
209+
Notes:
210+
- Tenant admin (role=admin) is not allowed to be revoked via this endpoint.
211+
- Idempotent: local deletions are soft deletes; Supabase deletion may already have occurred.
212+
"""
213+
authorization = request.headers.get("Authorization")
214+
if not authorization:
215+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED,
216+
detail="No authorization token provided")
217+
try:
218+
# Identify current user and tenant
219+
user_id, tenant_id = get_current_user_id(authorization)
220+
221+
# Determine role via token validation
222+
is_valid, user = validate_token(authorization.replace("Bearer ", ""))
223+
if not is_valid or not user:
224+
raise UnauthorizedError("User not logged in or session invalid")
225+
226+
# Extract role from user metadata
227+
user_role = "user"
228+
if getattr(user, "user_metadata", None) and 'role' in user.user_metadata:
229+
user_role = user.user_metadata['role']
230+
231+
# Disallow admin revocation by this endpoint
232+
if user_role == "admin":
233+
raise HTTPException(status_code=HTTPStatus.FORBIDDEN,
234+
detail="Admin account cannot be deleted via this endpoint")
235+
236+
# Orchestrate revoke for regular user
237+
await revoke_regular_user(user_id=user_id, tenant_id=tenant_id)
238+
239+
return JSONResponse(status_code=HTTPStatus.OK, content={"message": "User account revoked"})
240+
except UnauthorizedError as e:
241+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e))
242+
except HTTPException:
243+
raise
244+
except Exception as e:
245+
logging.error(f"User revoke failed: {str(e)}")
246+
raise HTTPException(
247+
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="User revoke failed")

backend/consts/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
# Supabase Configuration
3838
SUPABASE_URL = os.getenv('SUPABASE_URL')
3939
SUPABASE_KEY = os.getenv('SUPABASE_KEY')
40+
SERVICE_ROLE_KEY = os.getenv('SERVICE_ROLE_KEY', SUPABASE_KEY)
4041

4142

4243
# ===== To be migrated to frontend configuration =====

backend/database/conversation_db.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,67 @@ def delete_conversation(conversation_id: int, user_id: Optional[str] = None) ->
411411
return conversation_result.rowcount > 0
412412

413413

414+
def soft_delete_all_conversations_by_user(user_id: str) -> int:
415+
"""
416+
Soft-delete all conversations and related records created by a user.
417+
418+
Returns the number of conversations marked as deleted.
419+
"""
420+
with get_db_session() as session:
421+
update_data = {
422+
"delete_flag": 'Y',
423+
"update_time": func.current_timestamp()
424+
}
425+
426+
# 1) Find all conversation ids created by the user
427+
conv_ids = session.scalars(
428+
select(ConversationRecord.conversation_id).where(
429+
ConversationRecord.delete_flag == 'N',
430+
ConversationRecord.created_by == user_id,
431+
)
432+
).all()
433+
434+
if not conv_ids:
435+
return 0
436+
437+
# 2) Mark conversations as deleted
438+
session.execute(
439+
update(ConversationRecord)
440+
.where(ConversationRecord.conversation_id.in_(conv_ids), ConversationRecord.delete_flag == 'N')
441+
.values(update_data)
442+
)
443+
444+
# 3) Mark messages as deleted
445+
session.execute(
446+
update(ConversationMessage)
447+
.where(ConversationMessage.conversation_id.in_(conv_ids), ConversationMessage.delete_flag == 'N')
448+
.values(update_data)
449+
)
450+
451+
# 4) Mark message units as deleted
452+
session.execute(
453+
update(ConversationMessageUnit)
454+
.where(ConversationMessageUnit.conversation_id.in_(conv_ids), ConversationMessageUnit.delete_flag == 'N')
455+
.values(update_data)
456+
)
457+
458+
# 5) Mark search sources as deleted
459+
session.execute(
460+
update(ConversationSourceSearch)
461+
.where(ConversationSourceSearch.conversation_id.in_(conv_ids), ConversationSourceSearch.delete_flag == 'N')
462+
.values(update_data)
463+
)
464+
465+
# 6) Mark image sources as deleted
466+
session.execute(
467+
update(ConversationSourceImage)
468+
.where(ConversationSourceImage.conversation_id.in_(conv_ids), ConversationSourceImage.delete_flag == 'N')
469+
.values(update_data)
470+
)
471+
472+
return len(conv_ids)
473+
474+
414475
def update_message_opinion(message_id: int, opinion: str, user_id: Optional[str] = None) -> bool:
415476
"""
416477
Update message like/dislike status

backend/database/memory_config_db.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,21 @@ def update_config_by_id(config_id: int, update_data: Dict[str, Any]) -> bool:
8888
except Exception:
8989
session.rollback()
9090
return False
91+
92+
93+
def soft_delete_all_configs_by_user_id(user_id: str, actor: str) -> bool:
94+
"""Soft-delete all memory user config records for a user."""
95+
with get_db_session() as session:
96+
try:
97+
session.query(MemoryUserConfig).filter(
98+
MemoryUserConfig.user_id == user_id,
99+
MemoryUserConfig.delete_flag == "N",
100+
).update({
101+
"delete_flag": "Y",
102+
"updated_by": actor,
103+
})
104+
session.commit()
105+
return True
106+
except Exception:
107+
session.rollback()
108+
return False

backend/database/user_tenant_db.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,29 @@ def insert_user_tenant(user_id: str, tenant_id: str):
6666
updated_by=user_id
6767
)
6868
session.add(user_tenant)
69+
70+
71+
def soft_delete_user_tenant_by_user_id(user_id: str, actor: Optional[str] = None) -> bool:
72+
"""
73+
Soft delete user-tenant relationship(s) for the specified user.
74+
75+
Args:
76+
user_id: User ID
77+
actor: Updated_by field value
78+
79+
Returns:
80+
bool: Whether any rows were affected
81+
"""
82+
with get_db_session() as session:
83+
# Build soft-delete update
84+
update_data: Dict[str, Any] = {"delete_flag": "Y"}
85+
if actor:
86+
update_data["updated_by"] = actor
87+
88+
result = (
89+
session.query(UserTenant)
90+
.filter(UserTenant.user_id == user_id, UserTenant.delete_flag == "N")
91+
.update(update_data, synchronize_session=False)
92+
)
93+
94+
return result > 0

backend/services/model_management_service.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
split_repo_name,
2626
sort_models_by_id,
2727
)
28+
from utils.memory_utils import build_memory_config as build_memory_config_for_tenant
29+
from services.elasticsearch_service import get_es_core
30+
from nexent.memory.memory_service import clear_model_memories
2831

2932
logger = logging.getLogger("model_management_service")
3033

@@ -227,11 +230,42 @@ async def delete_model_for_tenant(user_id: str, tenant_id: str, display_name: st
227230

228231
deleted_types: List[str] = []
229232
if model.get("model_type") in ["embedding", "multi_embedding"]:
233+
# Fetch both variants once to avoid repeated lookups
234+
models_by_type: Dict[str, Dict[str, Any]] = {}
230235
for t in ["embedding", "multi_embedding"]:
231236
m = get_model_by_display_name(display_name, tenant_id)
232237
if m and m.get("model_type") == t:
233-
delete_model_record(m["model_id"], user_id, tenant_id)
234-
deleted_types.append(t)
238+
models_by_type[t] = m
239+
240+
# Best-effort memory cleanup using the fetched variants
241+
try:
242+
es_core = get_es_core()
243+
base_memory_config = build_memory_config_for_tenant(tenant_id)
244+
for t, m in models_by_type.items():
245+
try:
246+
await clear_model_memories(
247+
es_core=es_core,
248+
model_repo=m.get("model_repo", ""),
249+
model_name=m.get("model_name", ""),
250+
embedding_dims=int(m.get("max_tokens") or 0),
251+
base_memory_config=base_memory_config,
252+
)
253+
except Exception as cleanup_exc:
254+
logger.warning(
255+
"Best-effort clear_model_memories failed for %s/%s dims=%s: %s",
256+
m.get("model_repo", ""),
257+
m.get("model_name", ""),
258+
m.get("max_tokens"),
259+
cleanup_exc,
260+
)
261+
except Exception as outer_cleanup_exc:
262+
logger.warning(
263+
"Memory cleanup preparation failed: %s", outer_cleanup_exc)
264+
265+
# Delete the fetched variants
266+
for t, m in models_by_type.items():
267+
delete_model_record(m["model_id"], user_id, tenant_id)
268+
deleted_types.append(t)
235269
else:
236270
delete_model_record(model["model_id"], user_id, tenant_id)
237271
deleted_types.append(model.get("model_type", "unknown"))

backend/services/user_management_service.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,24 @@
66
from supabase import Client
77
from pydantic import EmailStr
88

9-
from utils.auth_utils import get_supabase_client, calculate_expires_at, get_jwt_expiry_seconds
9+
from utils.auth_utils import (
10+
get_supabase_client,
11+
get_supabase_admin_client,
12+
calculate_expires_at,
13+
get_jwt_expiry_seconds,
14+
)
1015
from consts.const import INVITE_CODE, SUPABASE_URL, SUPABASE_KEY
1116
from consts.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException, UnauthorizedError
1217

1318
from database.model_management_db import create_model_record
14-
from database.user_tenant_db import insert_user_tenant
19+
from database.user_tenant_db import insert_user_tenant, soft_delete_user_tenant_by_user_id
20+
from database.memory_config_db import soft_delete_all_configs_by_user_id
21+
from database.conversation_db import soft_delete_all_conversations_by_user
22+
from utils.memory_utils import build_memory_config
23+
from nexent.memory.memory_service import clear_memory
24+
25+
26+
logging.getLogger("user_management_service").setLevel(logging.DEBUG)
1527

1628

1729
def set_auth_token_to_client(client: Client, token: str) -> None:
@@ -295,3 +307,75 @@ async def get_session_by_authorization(authorization):
295307
else:
296308
# Use domain-specific exception for invalid/expired token
297309
raise UnauthorizedError("Session is invalid or expired")
310+
311+
312+
async def revoke_regular_user(user_id: str, tenant_id: str) -> None:
313+
"""Revoke a regular user's account and purge related data.
314+
315+
Steps:
316+
1) Soft-delete user-tenant relation rows and memory user configs, and all conversations for the user in PostgreSQL.
317+
2) Clear user-level memories in memory store (levels: "user" and "user_agent").
318+
3) Permanently delete the user from Supabase using service role key (admin API).
319+
"""
320+
try:
321+
logging.debug(f"Start deleting user {user_id} related data...")
322+
# 1) PostgreSQL soft-deletes
323+
try:
324+
soft_delete_user_tenant_by_user_id(user_id, actor=user_id)
325+
logging.debug("\tTenant relationship deleted.")
326+
except Exception as e:
327+
logging.error(
328+
f"Failed soft-deleting user-tenant for user {user_id}: {e}")
329+
330+
try:
331+
soft_delete_all_configs_by_user_id(user_id, actor=user_id)
332+
logging.debug("\tMemory user configs deleted.")
333+
except Exception as e:
334+
logging.error(
335+
f"Failed soft-deleting memory user configs for user {user_id}: {e}")
336+
337+
try:
338+
deleted_convs = soft_delete_all_conversations_by_user(user_id)
339+
logging.debug(f"\t{deleted_convs} conversations deleted")
340+
except Exception as e:
341+
logging.error(
342+
f"Failed soft-deleting conversations for user {user_id}: {e}")
343+
344+
# 2) Clear memory records
345+
try:
346+
memory_config = build_memory_config(tenant_id)
347+
# Clear user-level memory
348+
await clear_memory(
349+
memory_level="user",
350+
memory_config=memory_config,
351+
tenant_id=tenant_id,
352+
user_id=user_id,
353+
)
354+
# Also clear user_agent-level memory for all agents (API clears by user + any agent)
355+
await clear_memory(
356+
memory_level="user_agent",
357+
memory_config=memory_config,
358+
tenant_id=tenant_id,
359+
user_id=user_id,
360+
)
361+
logging.debug(
362+
"\tMemories under current embedding configuration deleted")
363+
except Exception as e:
364+
logging.error(f"Failed clearing memory for user {user_id}: {e}")
365+
366+
# 3) Delete Supabase user using admin API
367+
try:
368+
admin_client = get_supabase_admin_client()
369+
if admin_client and hasattr(admin_client.auth, "admin"):
370+
admin_client.auth.admin.delete_user(user_id)
371+
else:
372+
raise RuntimeError("Supabase admin client not available")
373+
logging.debug("\tUser account deleted.")
374+
except Exception as e:
375+
logging.error(f"Failed deleting supabase user {user_id}: {e}")
376+
# prior steps already purged local data
377+
logging.info(f"Account {user_id} has been successfully deleted")
378+
except Exception as e:
379+
logging.error(
380+
f"Unexpected error in revoke_regular_user for {user_id}: {e}")
381+
# swallow to keep idempotent behavior

backend/utils/auth_utils.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from fastapi import Request
1010
from supabase import create_client
1111

12-
from consts.const import DEFAULT_TENANT_ID, DEFAULT_USER_ID, IS_SPEED_MODE, SUPABASE_URL, SUPABASE_KEY, DEBUG_JWT_EXPIRE_SECONDS, LANGUAGE
12+
from consts.const import DEFAULT_TENANT_ID, DEFAULT_USER_ID, IS_SPEED_MODE, SUPABASE_URL, SUPABASE_KEY, SERVICE_ROLE_KEY, DEBUG_JWT_EXPIRE_SECONDS, LANGUAGE
1313
from consts.exceptions import LimitExceededError, SignatureValidationError, UnauthorizedError
1414
from database.user_tenant_db import get_user_tenant_by_user_id
1515

@@ -196,14 +196,23 @@ def validate_aksk_authentication(headers: dict, request_body: str = "") -> bool:
196196

197197

198198
def get_supabase_client():
199-
"""Get Supabase client instance with service key for admin operations"""
199+
"""Get Supabase client instance with regular key (user-context operations)."""
200200
try:
201201
return create_client(SUPABASE_URL, SUPABASE_KEY)
202202
except Exception as e:
203203
logging.error(f"Failed to create Supabase client: {str(e)}")
204204
return None
205205

206206

207+
def get_supabase_admin_client():
208+
"""Get Supabase client instance with service role key for admin operations."""
209+
try:
210+
return create_client(SUPABASE_URL, SERVICE_ROLE_KEY)
211+
except Exception as e:
212+
logging.error(f"Failed to create Supabase admin client: {str(e)}")
213+
return None
214+
215+
207216
def get_jwt_expiry_seconds(token: str) -> int:
208217
"""
209218
Get expiration time from JWT token (seconds)

0 commit comments

Comments
 (0)