Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
233 changes: 106 additions & 127 deletions backend/apps/northbound_app.py

Large diffs are not rendered by default.

109 changes: 107 additions & 2 deletions backend/apps/user_management_app.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import logging

from dotenv import load_dotenv
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, Header, Query, Request, HTTPException
from fastapi.responses import JSONResponse
from http import HTTPStatus
from typing import Optional

from supabase_auth.errors import AuthApiError, AuthWeakPasswordError

from consts.model import UserSignInRequest, UserSignUpRequest
from consts.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException
from services.user_management_service import get_authorized_client, validate_token, \
check_auth_service_health, signup_user_with_invitation, signin_user, refresh_user_token, \
get_session_by_authorization, get_user_info
get_session_by_authorization, get_user_info, create_token, list_tokens_by_user, delete_token
from services.user_service import delete_user_and_cleanup
from consts.exceptions import UnauthorizedError
from utils.auth_utils import get_current_user_id
Expand Down Expand Up @@ -273,3 +274,107 @@ async def revoke_user_account(request: Request):
logging.error(f"User revoke failed: {str(e)}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="User revoke failed")

@router.post("/tokens")
async def create_token_endpoint(
authorization: Optional[str] = Header(None)
):
"""Create a new token for the authenticated user.

The user_id is extracted from the Authorization header (JWT token).
Returns the complete token including the secret key.
"""
try:
if not authorization:
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED,
detail="Unauthorized: No authorization header found")

user_id, _ = get_current_user_id(authorization)
if not user_id:
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED,
detail="Unauthorized: missing user_id in JWT token")

result = create_token(str(user_id))
return JSONResponse(
status_code=HTTPStatus.OK,
content={"message": "success", "data": result}
)
except HTTPException as e:
raise e
except Exception as e:
logging.error(f"Failed to create token: {str(e)}", exc_info=e)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Internal Server Error")


@router.get("/tokens")
async def list_tokens_endpoint(
user_id: str = Query(..., description="User ID to query tokens for"),
authorization: Optional[str] = Header(None)
):
"""List all tokens for the specified user.

Returns token information with masked access keys (middle part replaced with *).
"""
try:
if not authorization:
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED,
detail="Unauthorized: No authorization header found")

request_user_id, _ = get_current_user_id(authorization)
if not request_user_id:
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED,
detail="Unauthorized: missing user_id in JWT token")

# Only allow users to list their own tokens
if str(request_user_id) != user_id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN,
detail="Forbidden: cannot list tokens for other users")

tokens = list_tokens_by_user(user_id)
return JSONResponse(
status_code=HTTPStatus.OK,
content={"message": "success", "data": tokens}
)
except HTTPException as e:
raise e
except Exception as e:
logging.error(f"Failed to list tokens: {str(e)}", exc_info=e)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Internal Server Error")


@router.delete("/tokens/{token_id}")
async def delete_token_endpoint(
token_id: int,
authorization: Optional[str] = Header(None)
):
"""Soft delete a token.

Only the owner of the token can delete it.
"""
try:
if not authorization:
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED,
detail="Unauthorized: No authorization header found")

user_id, _ = get_current_user_id(authorization)
if not user_id:
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED,
detail="Unauthorized: missing user_id in JWT token")

success = delete_token(token_id, str(user_id))
if not success:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND,
detail="Token not found or not owned by user")

return JSONResponse(
status_code=HTTPStatus.OK,
content={"message": "success", "data": {"token_id": token_id}}
)
except HTTPException as e:
raise e
except Exception as e:
logging.error(f"Failed to delete token: {str(e)}", exc_info=e)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Internal Server Error")
5 changes: 3 additions & 2 deletions backend/database/conversation_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from sqlalchemy import asc, desc, func, insert, select, update

from .client import as_dict, get_db_session
from .client import as_dict, db_client, get_db_session
from .db_models import (
ConversationMessage,
ConversationMessageUnit,
Expand Down Expand Up @@ -328,11 +328,12 @@ def rename_conversation(conversation_id: int, new_title: str, user_id: Optional[
# Ensure conversation_id is of integer type
conversation_id = int(conversation_id)

# Prepare update data
# Prepare update data with UTF-8 encoding for title
update_data = {
"conversation_title": new_title,
"update_time": func.current_timestamp()
}
update_data = db_client.clean_string_values(update_data)
if user_id:
update_data = add_update_tracking(update_data, user_id)

Expand Down
29 changes: 29 additions & 0 deletions backend/database/db_models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from sqlalchemy import BigInteger, Boolean, Column, Integer, JSON, Numeric, PrimaryKeyConstraint, Sequence, String, Text, TIMESTAMP
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.sql import func

Expand Down Expand Up @@ -483,3 +484,31 @@ class AgentVersion(TableBase):
source_version_no = Column(Integer, doc="Source version number. If this version is a rollback, record the source version")
source_type = Column(String(30), doc="Source type: NORMAL (normal publish) / ROLLBACK (rollback and republish)")
status = Column(String(30), default="RELEASED", doc="Version status: RELEASED / DISABLED / ARCHIVED")


class UserTokenInfo(TableBase):
"""
User token (AK/SK) information table
"""
__tablename__ = "user_token_info_t"
__table_args__ = {"schema": SCHEMA}

token_id = Column(Integer, Sequence("user_token_info_t_token_id_seq", schema=SCHEMA),
primary_key=True, nullable=False, doc="Token ID, unique primary key")
access_key = Column(String(100), nullable=False, doc="Access Key (AK)")
user_id = Column(String(100), nullable=False, doc="User ID who owns this token")


class UserTokenUsageLog(TableBase):
"""
User token usage log table
"""
__tablename__ = "user_token_usage_log_t"
__table_args__ = {"schema": SCHEMA}

token_usage_id = Column(Integer, Sequence("user_token_usage_log_t_token_usage_id_seq", schema=SCHEMA),
primary_key=True, nullable=False, doc="Token usage log ID, unique primary key")
token_id = Column(Integer, nullable=False, doc="Foreign key to user_token_info_t.token_id")
call_function_name = Column(String(100), doc="API function name being called")
related_id = Column(Integer, doc="Related resource ID (e.g., conversation_id)")
meta_data = Column(JSONB, doc="Additional metadata for this usage log entry, stored as JSON")
189 changes: 189 additions & 0 deletions backend/database/token_db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"""
Database operations for user API token (API Key) management.
"""
import secrets
from typing import Any, Dict, List, Optional

from database.client import get_db_session
from database.db_models import UserTokenInfo, UserTokenUsageLog


def generate_access_key() -> str:
"""Generate a random access key with format nexent-xxxxx..."""
random_part = secrets.token_hex(12) # 24 hex characters for more entropy
return f"nexent-{random_part}"


def create_token(access_key: str, user_id: str) -> Dict[str, Any]:
"""Create a new token record in the database.

Args:
access_key: The access key (API Key).
user_id: The user ID who owns this token.

Returns:
Dictionary containing the created token information.
"""
with get_db_session() as session:
token = UserTokenInfo(
access_key=access_key,
user_id=user_id,
created_by=user_id,
updated_by=user_id,
delete_flag='N'
)
session.add(token)
session.flush()

return {
"token_id": token.token_id,
"access_key": token.access_key,
"user_id": token.user_id
}


def list_tokens_by_user(user_id: str) -> List[Dict[str, Any]]:
"""List all active tokens for the specified user.

Args:
user_id: The user ID to query tokens for.

Returns:
List of token information with masked access keys.
"""
with get_db_session() as session:
tokens = session.query(UserTokenInfo).filter(
UserTokenInfo.user_id == user_id,
UserTokenInfo.delete_flag == 'N'
).order_by(UserTokenInfo.create_time.desc()).all()

return [
{
"token_id": token.token_id,
"access_key": token.access_key,
"user_id": token.user_id,
"create_time": token.create_time.isoformat() if token.create_time else None
}
for token in tokens
]


def get_token_by_id(token_id: int) -> UserTokenInfo:
"""Get a token by its ID.

Args:
token_id: The token ID to query.

Returns:
UserTokenInfo object if found and active, None otherwise.
"""
with get_db_session() as session:
return session.query(UserTokenInfo).filter(
UserTokenInfo.token_id == token_id,
UserTokenInfo.delete_flag == 'N'
).first()


def get_token_by_access_key(access_key: str) -> Optional[Dict[str, Any]]:
"""Get a token by its access key.

Args:
access_key: The access key to query.

Returns:
Token information dict if found and active, None otherwise.
"""
with get_db_session() as session:
token = session.query(UserTokenInfo).filter(
UserTokenInfo.access_key == access_key,
UserTokenInfo.delete_flag == 'N'
).first()

if token:
return {
"token_id": token.token_id,
"access_key": token.access_key,
"user_id": token.user_id,
"delete_flag": token.delete_flag
}
return None


def delete_token(token_id: int, user_id: str) -> bool:
"""Soft delete a token by setting delete_flag to 'Y'.

Args:
token_id: The token ID to delete.
user_id: The user ID who owns this token (for authorization).

Returns:
True if the token was deleted, False if not found or not owned by user.
"""
with get_db_session() as session:
token = session.query(UserTokenInfo).filter(
UserTokenInfo.token_id == token_id,
UserTokenInfo.user_id == user_id,
UserTokenInfo.delete_flag == 'N'
).first()

if not token:
return False

token.delete_flag = 'Y'
token.updated_by = user_id
return True


def log_token_usage(
token_id: int,
call_function_name: str,
related_id: Optional[int],
created_by: str,
metadata: Optional[Dict[str, Any]] = None
) -> int:
"""Log token usage to the database.

Args:
token_id: The token ID used.
call_function_name: The API function name being called.
related_id: Related resource ID (e.g., conversation_id).
created_by: User ID who initiated the call.
metadata: Optional additional metadata for this usage log entry.

Returns:
The created token_usage_id.
"""
with get_db_session() as session:
usage_log = UserTokenUsageLog(
token_id=token_id,
call_function_name=call_function_name,
related_id=related_id,
created_by=created_by,
meta_data=metadata
)
session.add(usage_log)
session.flush()
return usage_log.token_usage_id


def get_latest_usage_metadata(token_id: int, related_id: int, call_function_name: str) -> Optional[Dict[str, Any]]:
"""Get the latest metadata for a given token, related_id and function name.

Args:
token_id: The token ID used.
related_id: Related resource ID (e.g., conversation_id).
call_function_name: The API function name.

Returns:
The metadata dict if found, None otherwise.
"""
with get_db_session() as session:
usage_log = session.query(UserTokenUsageLog).filter(
UserTokenUsageLog.token_id == token_id,
UserTokenUsageLog.related_id == related_id,
UserTokenUsageLog.call_function_name == call_function_name
).order_by(UserTokenUsageLog.create_time.desc()).first()

if usage_log and usage_log.meta_data:
return usage_log.meta_data
return None
Loading
Loading