commit
41d2b463db
14 changed files with 621 additions and 155 deletions
7
app.py
7
app.py
|
|
@ -57,7 +57,8 @@ def initLogging():
|
||||||
'_send_single_request',
|
'_send_single_request',
|
||||||
'httpcore.http11',
|
'httpcore.http11',
|
||||||
'httpx._client',
|
'httpx._client',
|
||||||
'HTTP Request'
|
'HTTP Request',
|
||||||
|
'multipart.multipart'
|
||||||
]
|
]
|
||||||
return not any(pattern in record.msg for pattern in http_debug_patterns)
|
return not any(pattern in record.msg for pattern in http_debug_patterns)
|
||||||
return True
|
return True
|
||||||
|
|
@ -254,3 +255,7 @@ app.include_router(voiceGoogleRouter)
|
||||||
|
|
||||||
from modules.routes.routeVoiceStreaming import router as voiceStreamingRouter
|
from modules.routes.routeVoiceStreaming import router as voiceStreamingRouter
|
||||||
app.include_router(voiceStreamingRouter)
|
app.include_router(voiceStreamingRouter)
|
||||||
|
|
||||||
|
# Admin security routes (token listing and revocation, logs, db tools)
|
||||||
|
from modules.routes.routeSecurityAdmin import router as adminSecurityRouter
|
||||||
|
app.include_router(adminSecurityRouter)
|
||||||
BIN
debug_audio/audio_google_interpreter_recording.webm
Normal file
BIN
debug_audio/audio_google_interpreter_recording.webm
Normal file
Binary file not shown.
|
|
@ -48,7 +48,7 @@ DB_MANAGEMENT_PASSWORD_SECRET=dev_password
|
||||||
DB_MANAGEMENT_PORT=5432
|
DB_MANAGEMENT_PORT=5432
|
||||||
|
|
||||||
# Security Configuration
|
# Security Configuration
|
||||||
APP_JWT_SECRET_SECRET=dev_jwt_secret_token
|
APP_JWT_SECRET_SECRET=rotated_jwt_secret_2025_09_17_f8a3b6c2-7d4e-45b6-9a1f-3c0b9a1d2e7f
|
||||||
APP_TOKEN_EXPIRY=300
|
APP_TOKEN_EXPIRY=300
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ DB_MANAGEMENT_PASSWORD_SECRET=VkAjgECESbEVQ$Tu
|
||||||
DB_MANAGEMENT_PORT=5432
|
DB_MANAGEMENT_PORT=5432
|
||||||
|
|
||||||
# Security Configuration
|
# Security Configuration
|
||||||
APP_JWT_SECRET_SECRET=dev_jwt_secret_token
|
APP_JWT_SECRET_SECRET=rotated_jwt_secret_2025_09_17_2c5f8e7a-1b3d-49c7-ae5d-9f0a2c3d4b5e
|
||||||
APP_TOKEN_EXPIRY=300
|
APP_TOKEN_EXPIRY=300
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ DB_MANAGEMENT_PASSWORD_SECRET=prod_password_very_secure.2025
|
||||||
DB_MANAGEMENT_PORT=5432
|
DB_MANAGEMENT_PORT=5432
|
||||||
|
|
||||||
# Security Configuration
|
# Security Configuration
|
||||||
APP_JWT_SECRET_SECRET=dev_jwt_secret_token
|
APP_JWT_SECRET_SECRET=rotated_jwt_secret_2025_09_17_prod_e1a9c4d7-6b8f-4f2e-9c1a-7e3d2a1b9c5f
|
||||||
APP_TOKEN_EXPIRY=300
|
APP_TOKEN_EXPIRY=300
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
|
|
|
||||||
|
|
@ -57,14 +57,6 @@ class DatabaseConnector:
|
||||||
Uses PostgreSQL with JSONB columns for flexible data storage.
|
Uses PostgreSQL with JSONB columns for flexible data storage.
|
||||||
"""
|
"""
|
||||||
def __init__(self, dbHost: str, dbDatabase: str, dbUser: str = None, dbPassword: str = None, dbPort: int = None, userId: str = None):
|
def __init__(self, dbHost: str, dbDatabase: str, dbUser: str = None, dbPassword: str = None, dbPort: int = None, userId: str = None):
|
||||||
# DEBUG: Log constructor parameters
|
|
||||||
logger.info(f"DEBUG: DatabaseConnector constructor called with:")
|
|
||||||
logger.info(f" dbHost: '{dbHost}' (type: {type(dbHost)})")
|
|
||||||
logger.info(f" dbDatabase: '{dbDatabase}' (type: {type(dbDatabase)})")
|
|
||||||
logger.info(f" dbUser: '{dbUser}' (type: {type(dbUser)})")
|
|
||||||
logger.info(f" dbPort: {dbPort} (type: {type(dbPort)})")
|
|
||||||
logger.info(f" userId: '{userId}' (type: {type(userId)})")
|
|
||||||
|
|
||||||
# Store the input parameters
|
# Store the input parameters
|
||||||
self.dbHost = dbHost
|
self.dbHost = dbHost
|
||||||
self.dbDatabase = dbDatabase
|
self.dbDatabase = dbDatabase
|
||||||
|
|
@ -109,10 +101,6 @@ class DatabaseConnector:
|
||||||
def _create_database_if_not_exists(self):
|
def _create_database_if_not_exists(self):
|
||||||
"""Create the database if it doesn't exist."""
|
"""Create the database if it doesn't exist."""
|
||||||
try:
|
try:
|
||||||
# DEBUG: Log all database configuration values
|
|
||||||
logger.info(f"DEBUG: Database configuration - Host: {self.dbHost}, Database: {self.dbDatabase}, User: {self.dbUser}, Port: {self.dbPort}")
|
|
||||||
logger.info(f"DEBUG: Database name type: {type(self.dbDatabase)}, value: '{self.dbDatabase}'")
|
|
||||||
|
|
||||||
# Use the configured user for database creation
|
# Use the configured user for database creation
|
||||||
conn = psycopg2.connect(
|
conn = psycopg2.connect(
|
||||||
host=self.dbHost,
|
host=self.dbHost,
|
||||||
|
|
@ -132,11 +120,8 @@ class DatabaseConnector:
|
||||||
if not exists:
|
if not exists:
|
||||||
# Create database with proper quoting for names with hyphens
|
# Create database with proper quoting for names with hyphens
|
||||||
quoted_db_name = f'"{self.dbDatabase}"'
|
quoted_db_name = f'"{self.dbDatabase}"'
|
||||||
logger.info(f"DEBUG: Creating database with quoted name: {quoted_db_name}")
|
|
||||||
cursor.execute(f"CREATE DATABASE {quoted_db_name}")
|
cursor.execute(f"CREATE DATABASE {quoted_db_name}")
|
||||||
logger.info(f"Created database: {self.dbDatabase}")
|
logger.info(f"Created database: {self.dbDatabase}")
|
||||||
else:
|
|
||||||
logger.info(f"Database {self.dbDatabase} already exists")
|
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
@ -171,9 +156,6 @@ class DatabaseConnector:
|
||||||
_modifiedAt DOUBLE PRECISION
|
_modifiedAt DOUBLE PRECISION
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
logger.info("System table created successfully")
|
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -195,7 +177,6 @@ class DatabaseConnector:
|
||||||
cursor_factory=psycopg2.extras.RealDictCursor
|
cursor_factory=psycopg2.extras.RealDictCursor
|
||||||
)
|
)
|
||||||
self.connection.autocommit = False # Use transactions
|
self.connection.autocommit = False # Use transactions
|
||||||
logger.info(f"Connected to PostgreSQL database: {self.dbDatabase}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to connect to PostgreSQL: {e}")
|
logger.error(f"Failed to connect to PostgreSQL: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
@ -299,8 +280,6 @@ class DatabaseConnector:
|
||||||
|
|
||||||
if '_modifiedAt' not in existing_columns:
|
if '_modifiedAt' not in existing_columns:
|
||||||
cursor.execute(f'ALTER TABLE "{self._systemTableName}" ADD COLUMN "_modifiedAt" DOUBLE PRECISION')
|
cursor.execute(f'ALTER TABLE "{self._systemTableName}" ADD COLUMN "_modifiedAt" DOUBLE PRECISION')
|
||||||
logger.info("Added _modifiedAt column to existing system table")
|
|
||||||
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -610,15 +589,8 @@ class DatabaseConnector:
|
||||||
raise ValueError("userId must be provided")
|
raise ValueError("userId must be provided")
|
||||||
|
|
||||||
self.userId = userId
|
self.userId = userId
|
||||||
logger.info(f"Updated database context: userId={self.userId}")
|
|
||||||
|
|
||||||
# No cache to clear - database handles data consistency
|
# No cache to clear - database handles data consistency
|
||||||
|
|
||||||
def clearTableCache(self, model_class: type) -> None:
|
|
||||||
"""No-op: Database handles data consistency automatically."""
|
|
||||||
# No caching with proper database - PostgreSQL handles consistency
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Public API
|
# Public API
|
||||||
|
|
||||||
def getTables(self) -> List[str]:
|
def getTables(self) -> List[str]:
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@ class ConnectionStatus(str, Enum):
|
||||||
REVOKED = "revoked"
|
REVOKED = "revoked"
|
||||||
PENDING = "pending"
|
PENDING = "pending"
|
||||||
|
|
||||||
|
class TokenStatus(str, Enum):
|
||||||
|
"""Status of an issued gateway JWT access token"""
|
||||||
|
ACTIVE = "active"
|
||||||
|
REVOKED = "revoked"
|
||||||
|
|
||||||
class Mandate(BaseModel, ModelMixin):
|
class Mandate(BaseModel, ModelMixin):
|
||||||
"""Data model for a mandate"""
|
"""Data model for a mandate"""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
|
|
@ -321,6 +326,13 @@ class Token(BaseModel, ModelMixin):
|
||||||
expiresAt: float = Field(description="When the token expires (UTC timestamp in seconds)")
|
expiresAt: float = Field(description="When the token expires (UTC timestamp in seconds)")
|
||||||
tokenRefresh: Optional[str] = None
|
tokenRefresh: Optional[str] = None
|
||||||
createdAt: Optional[float] = Field(None, description="When the token was created (UTC timestamp in seconds)")
|
createdAt: Optional[float] = Field(None, description="When the token was created (UTC timestamp in seconds)")
|
||||||
|
# Revocation and session tracking (for LOCAL gateway JWTs)
|
||||||
|
status: TokenStatus = Field(default=TokenStatus.ACTIVE, description="Token status: active/revoked")
|
||||||
|
revokedAt: Optional[float] = Field(None, description="When the token was revoked (UTC timestamp in seconds)")
|
||||||
|
revokedBy: Optional[str] = Field(None, description="User ID who revoked the token (admin/self)")
|
||||||
|
reason: Optional[str] = Field(None, description="Optional revocation reason")
|
||||||
|
sessionId: Optional[str] = Field(None, description="Logical session grouping for logout revocation")
|
||||||
|
mandateId: Optional[str] = Field(None, description="Mandate ID for tenant scoping of the token")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
useEnumValues = True
|
useEnumValues = True
|
||||||
|
|
@ -338,7 +350,13 @@ register_model_labels(
|
||||||
"tokenType": {"en": "Token Type", "fr": "Type de jeton"},
|
"tokenType": {"en": "Token Type", "fr": "Type de jeton"},
|
||||||
"expiresAt": {"en": "Expires At", "fr": "Expire le"},
|
"expiresAt": {"en": "Expires At", "fr": "Expire le"},
|
||||||
"tokenRefresh": {"en": "Refresh Token", "fr": "Jeton de rafraîchissement"},
|
"tokenRefresh": {"en": "Refresh Token", "fr": "Jeton de rafraîchissement"},
|
||||||
"createdAt": {"en": "Created At", "fr": "Créé le"}
|
"createdAt": {"en": "Created At", "fr": "Créé le"},
|
||||||
|
"status": {"en": "Status", "fr": "Statut"},
|
||||||
|
"revokedAt": {"en": "Revoked At", "fr": "Révoqué le"},
|
||||||
|
"revokedBy": {"en": "Revoked By", "fr": "Révoqué par"},
|
||||||
|
"reason": {"en": "Reason", "fr": "Raison"},
|
||||||
|
"sessionId": {"en": "Session ID", "fr": "ID de session"},
|
||||||
|
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from modules.interfaces.interfaceAppAccess import AppAccess
|
||||||
from modules.interfaces.interfaceAppModel import (
|
from modules.interfaces.interfaceAppModel import (
|
||||||
User, Mandate, UserInDB, UserConnection,
|
User, Mandate, UserInDB, UserConnection,
|
||||||
AuthAuthority, UserPrivilege,
|
AuthAuthority, UserPrivilege,
|
||||||
ConnectionStatus, Token, AuthEvent,
|
ConnectionStatus, Token, AuthEvent, TokenStatus,
|
||||||
DataNeutraliserConfig, DataNeutralizerAttributes
|
DataNeutraliserConfig, DataNeutralizerAttributes
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -949,6 +949,99 @@ class AppObjects:
|
||||||
logger.error(f"Error deleting connection token for connectionId {connectionId}: {str(e)}")
|
logger.error(f"Error deleting connection token for connectionId {connectionId}: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Token revocation (LOCAL gateway JWTs)
|
||||||
|
# =====================
|
||||||
|
def findActiveTokenById(self, tokenId: str, userId: str, authority: AuthAuthority, sessionId: str = None, mandateId: str = None) -> Optional[Token]:
|
||||||
|
"""Find an active access token by its id (jti) with optional session/tenant scoping."""
|
||||||
|
try:
|
||||||
|
recordFilter = {
|
||||||
|
"id": tokenId,
|
||||||
|
"userId": userId,
|
||||||
|
"authority": authority.value if hasattr(authority, 'value') else str(authority),
|
||||||
|
"status": TokenStatus.ACTIVE,
|
||||||
|
}
|
||||||
|
if sessionId is not None:
|
||||||
|
recordFilter["sessionId"] = sessionId
|
||||||
|
if mandateId is not None:
|
||||||
|
recordFilter["mandateId"] = mandateId
|
||||||
|
tokens = self.db.getRecordset(Token, recordFilter=recordFilter)
|
||||||
|
if not tokens:
|
||||||
|
return None
|
||||||
|
return Token(**tokens[0])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error finding active token by id {tokenId}: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def revokeTokenById(self, tokenId: str, revokedBy: str, reason: str = None) -> bool:
|
||||||
|
"""Revoke a single token by id by setting status fields (no delete)."""
|
||||||
|
try:
|
||||||
|
existing = self.db.getRecordset(Token, recordFilter={"id": tokenId})
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
token = existing[0]
|
||||||
|
if token.get("status") == TokenStatus.REVOKED:
|
||||||
|
return True
|
||||||
|
tokenUpdate = {
|
||||||
|
"status": TokenStatus.REVOKED,
|
||||||
|
"revokedAt": get_utc_timestamp(),
|
||||||
|
"revokedBy": revokedBy,
|
||||||
|
"reason": reason or "revoked"
|
||||||
|
}
|
||||||
|
self.db.recordModify(Token, tokenId, tokenUpdate)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error revoking token {tokenId}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def revokeTokensBySessionId(self, sessionId: str, userId: str, authority: AuthAuthority, revokedBy: str, reason: str = None) -> int:
|
||||||
|
"""Revoke all tokens of a session for a user/authority."""
|
||||||
|
try:
|
||||||
|
tokens = self.db.getRecordset(Token, recordFilter={
|
||||||
|
"userId": userId,
|
||||||
|
"authority": authority.value if hasattr(authority, 'value') else str(authority),
|
||||||
|
"sessionId": sessionId,
|
||||||
|
"status": TokenStatus.ACTIVE
|
||||||
|
})
|
||||||
|
count = 0
|
||||||
|
for t in tokens:
|
||||||
|
self.db.recordModify(Token, t["id"], {
|
||||||
|
"status": TokenStatus.REVOKED,
|
||||||
|
"revokedAt": get_utc_timestamp(),
|
||||||
|
"revokedBy": revokedBy,
|
||||||
|
"reason": reason or "session logout"
|
||||||
|
})
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error revoking tokens for session {sessionId}: {str(e)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def revokeTokensByUser(self, userId: str, authority: AuthAuthority = None, mandateId: str = None, revokedBy: str = None, reason: str = None) -> int:
|
||||||
|
"""Revoke all active tokens for a user, optionally filtered by authority/mandate."""
|
||||||
|
try:
|
||||||
|
# Fetch all active tokens for user (optionally filtered by authority)
|
||||||
|
recordFilter = {
|
||||||
|
"userId": userId,
|
||||||
|
"status": TokenStatus.ACTIVE,
|
||||||
|
}
|
||||||
|
if authority is not None:
|
||||||
|
recordFilter["authority"] = authority.value if hasattr(authority, 'value') else str(authority)
|
||||||
|
tokens = self.db.getRecordset(Token, recordFilter=recordFilter)
|
||||||
|
count = 0
|
||||||
|
for t in tokens:
|
||||||
|
self.db.recordModify(Token, t["id"], {
|
||||||
|
"status": TokenStatus.REVOKED,
|
||||||
|
"revokedAt": get_utc_timestamp(),
|
||||||
|
"revokedBy": revokedBy,
|
||||||
|
"reason": reason or "admin revoke"
|
||||||
|
})
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error revoking tokens for user {userId}: {str(e)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
def cleanupExpiredTokens(self) -> int:
|
def cleanupExpiredTokens(self) -> int:
|
||||||
"""Clean up expired tokens for all connections, returns count of cleaned tokens"""
|
"""Clean up expired tokens for all connections, returns count of cleaned tokens"""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
292
modules/routes/routeSecurityAdmin.py
Normal file
292
modules/routes/routeSecurityAdmin.py
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, status, Request, Body
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from modules.security.auth import getCurrentUser, limiter
|
||||||
|
from modules.interfaces.interfaceAppObjects import getInterface, getRootInterface
|
||||||
|
from modules.interfaces.interfaceAppModel import User, UserInDB, AuthAuthority, Token
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/admin",
|
||||||
|
tags=["Admin"],
|
||||||
|
responses={
|
||||||
|
404: {"description": "Not found"},
|
||||||
|
400: {"description": "Bad request"},
|
||||||
|
401: {"description": "Unauthorized"},
|
||||||
|
403: {"description": "Forbidden"},
|
||||||
|
500: {"description": "Internal server error"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ensure_admin_scope(current_user: User, target_mandate_id: Optional[str] = None) -> None:
|
||||||
|
if current_user.privilege not in ("admin", "sysadmin"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
|
||||||
|
if current_user.privilege == "admin":
|
||||||
|
if target_mandate_id and str(target_mandate_id) != str(current_user.mandateId):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden for target mandate")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------
|
||||||
|
# Token listing and revocation
|
||||||
|
# ----------------------
|
||||||
|
|
||||||
|
@router.get("/tokens")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def list_tokens(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
userId: Optional[str] = None,
|
||||||
|
authority: Optional[str] = None,
|
||||||
|
sessionId: Optional[str] = None,
|
||||||
|
statusFilter: Optional[str] = None,
|
||||||
|
connectionId: Optional[str] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
appInterface = getRootInterface()
|
||||||
|
target_mandate = currentUser.mandateId
|
||||||
|
_ensure_admin_scope(currentUser, target_mandate)
|
||||||
|
|
||||||
|
recordFilter: Dict[str, Any] = {}
|
||||||
|
if userId:
|
||||||
|
recordFilter["userId"] = userId
|
||||||
|
if authority:
|
||||||
|
recordFilter["authority"] = authority
|
||||||
|
if sessionId:
|
||||||
|
recordFilter["sessionId"] = sessionId
|
||||||
|
if connectionId:
|
||||||
|
recordFilter["connectionId"] = connectionId
|
||||||
|
if statusFilter:
|
||||||
|
recordFilter["status"] = statusFilter
|
||||||
|
if currentUser.privilege == "admin":
|
||||||
|
recordFilter["mandateId"] = str(currentUser.mandateId)
|
||||||
|
|
||||||
|
tokens = appInterface.db.getRecordset(Token, recordFilter=recordFilter)
|
||||||
|
return tokens
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing tokens: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to list tokens")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tokens/revoke/user")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def revoke_tokens_by_user(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
payload: Dict[str, Any] = Body(...)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
userId = payload.get("userId")
|
||||||
|
authority = payload.get("authority")
|
||||||
|
reason = payload.get("reason", "admin revoke")
|
||||||
|
if not userId:
|
||||||
|
raise HTTPException(status_code=400, detail="userId is required")
|
||||||
|
|
||||||
|
appInterface = getRootInterface()
|
||||||
|
# Tenant scope check
|
||||||
|
target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId})
|
||||||
|
target_mandate = target_user[0].get("mandateId") if target_user else None
|
||||||
|
_ensure_admin_scope(currentUser, target_mandate)
|
||||||
|
|
||||||
|
count = appInterface.revokeTokensByUser(
|
||||||
|
userId=userId,
|
||||||
|
authority=AuthAuthority(authority) if authority else None,
|
||||||
|
mandateId=None if currentUser.privilege == "sysadmin" else str(currentUser.mandateId),
|
||||||
|
revokedBy=currentUser.id,
|
||||||
|
reason=reason
|
||||||
|
)
|
||||||
|
return {"revoked": count}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error revoking tokens by user: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to revoke tokens")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tokens/revoke/session")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def revoke_tokens_by_session(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
payload: Dict[str, Any] = Body(...)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
userId = payload.get("userId")
|
||||||
|
sessionId = payload.get("sessionId")
|
||||||
|
authority = payload.get("authority", "local")
|
||||||
|
reason = payload.get("reason", "admin session revoke")
|
||||||
|
if not userId or not sessionId:
|
||||||
|
raise HTTPException(status_code=400, detail="userId and sessionId are required")
|
||||||
|
|
||||||
|
appInterface = getRootInterface()
|
||||||
|
target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId})
|
||||||
|
target_mandate = target_user[0].get("mandateId") if target_user else None
|
||||||
|
_ensure_admin_scope(currentUser, target_mandate)
|
||||||
|
|
||||||
|
count = appInterface.revokeTokensBySessionId(
|
||||||
|
sessionId=sessionId,
|
||||||
|
userId=userId,
|
||||||
|
authority=AuthAuthority(authority),
|
||||||
|
revokedBy=currentUser.id,
|
||||||
|
reason=reason
|
||||||
|
)
|
||||||
|
return {"revoked": count}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error revoking tokens by session: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to revoke session tokens")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tokens/revoke/id")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def revoke_token_by_id(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
payload: Dict[str, Any] = Body(...)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
tokenId = payload.get("tokenId")
|
||||||
|
reason = payload.get("reason", "admin revoke")
|
||||||
|
if not tokenId:
|
||||||
|
raise HTTPException(status_code=400, detail="tokenId is required")
|
||||||
|
appInterface = getRootInterface()
|
||||||
|
# Load token to check tenant scope for admins
|
||||||
|
tokens = appInterface.db.getRecordset(Token, recordFilter={"id": tokenId})
|
||||||
|
if not tokens:
|
||||||
|
return {"revoked": 0}
|
||||||
|
target_mandate = tokens[0].get("mandateId")
|
||||||
|
_ensure_admin_scope(currentUser, target_mandate)
|
||||||
|
|
||||||
|
ok = appInterface.revokeTokenById(tokenId, revokedBy=currentUser.id, reason=reason)
|
||||||
|
return {"revoked": 1 if ok else 0}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error revoking token by id: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to revoke token")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tokens/revoke/mandate")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def revoke_tokens_by_mandate(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
payload: Dict[str, Any] = Body(...)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
mandateId = payload.get("mandateId")
|
||||||
|
authority = payload.get("authority", "local")
|
||||||
|
reason = payload.get("reason", "admin mandate revoke")
|
||||||
|
if not mandateId:
|
||||||
|
raise HTTPException(status_code=400, detail="mandateId is required")
|
||||||
|
|
||||||
|
_ensure_admin_scope(currentUser, mandateId)
|
||||||
|
|
||||||
|
# Revoke for all users in mandate
|
||||||
|
appInterface = getRootInterface()
|
||||||
|
# IMPORTANT: user rows are stored as UserInDB in the database
|
||||||
|
users = appInterface.db.getRecordset(UserInDB, recordFilter={"mandateId": mandateId})
|
||||||
|
total = 0
|
||||||
|
for u in users:
|
||||||
|
# Revoke regardless of token.mandateId to also catch legacy tokens without mandateId
|
||||||
|
total += appInterface.revokeTokensByUser(
|
||||||
|
userId=u["id"],
|
||||||
|
authority=AuthAuthority(authority),
|
||||||
|
mandateId=None,
|
||||||
|
revokedBy=currentUser.id,
|
||||||
|
reason=reason
|
||||||
|
)
|
||||||
|
return {"revoked": total}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error revoking tokens by mandate: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to revoke mandate tokens")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------
|
||||||
|
# Logs download
|
||||||
|
# ----------------------
|
||||||
|
|
||||||
|
@router.get("/logs/{log_name}")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def download_log(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
log_name: str = "poweron"
|
||||||
|
):
|
||||||
|
_ensure_admin_scope(currentUser)
|
||||||
|
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
# base_dir -> gateway
|
||||||
|
if log_name == "poweron":
|
||||||
|
file_path = os.path.join(base_dir, "poweron.log")
|
||||||
|
elif log_name == "audit":
|
||||||
|
file_path = os.path.join(base_dir, "audit.log")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="Unsupported log name")
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
raise HTTPException(status_code=404, detail=f"{log_name}.log not found")
|
||||||
|
return FileResponse(path=file_path, filename=f"{log_name}.log")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------
|
||||||
|
# Database admin
|
||||||
|
# ----------------------
|
||||||
|
|
||||||
|
@router.get("/databases")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def list_databases(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
_ensure_admin_scope(currentUser)
|
||||||
|
# For safety, expose only configured database name
|
||||||
|
db_name = APP_CONFIG.get("DB_DATABASE") or APP_CONFIG.get("DB_NAME") or "poweron"
|
||||||
|
return {"databases": [db_name]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/databases/drop")
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def drop_database(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
payload: Dict[str, Any] = Body(...)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
_ensure_admin_scope(currentUser)
|
||||||
|
db_name = payload.get("database")
|
||||||
|
configured_db = APP_CONFIG.get("DB_DATABASE") or APP_CONFIG.get("DB_NAME") or "poweron"
|
||||||
|
if not db_name or db_name != configured_db:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid database name")
|
||||||
|
|
||||||
|
try:
|
||||||
|
appInterface = getRootInterface()
|
||||||
|
conn = appInterface.db.connection
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# Drop all user tables (public schema) except system table
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
||||||
|
""")
|
||||||
|
tables = [row['table_name'] for row in cursor.fetchall()]
|
||||||
|
dropped = []
|
||||||
|
for tbl in tables:
|
||||||
|
cursor.execute(f'DROP TABLE IF EXISTS "{tbl}" CASCADE')
|
||||||
|
dropped.append(tbl)
|
||||||
|
conn.commit()
|
||||||
|
logger.warning(f"Admin drop_database executed by {currentUser.id}: dropped tables: {dropped}")
|
||||||
|
return {"droppedTables": dropped}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error dropping database tables: {str(e)}")
|
||||||
|
if appInterface and appInterface.db and appInterface.db.connection:
|
||||||
|
appInterface.db.connection.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to drop database tables")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -160,12 +160,36 @@ async def login(
|
||||||
scope=SCOPES
|
scope=SCOPES
|
||||||
)
|
)
|
||||||
|
|
||||||
|
extra_params = {
|
||||||
|
"access_type": "offline",
|
||||||
|
"include_granted_scopes": "true",
|
||||||
|
"state": state_param
|
||||||
|
}
|
||||||
|
# If targeting specific connection, add login_hint and hd to preselect account
|
||||||
|
try:
|
||||||
|
if connectionId:
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
from modules.interfaces.interfaceAppModel import UserConnection
|
||||||
|
records = rootInterface.db.getRecordset(UserConnection, recordFilter={"id": connectionId})
|
||||||
|
if records:
|
||||||
|
record = records[0]
|
||||||
|
login_hint = record.get("externalEmail") or record.get("externalUsername")
|
||||||
|
if login_hint:
|
||||||
|
extra_params["login_hint"] = login_hint
|
||||||
|
if "@" in login_hint:
|
||||||
|
extra_params["hd"] = login_hint.split("@", 1)[1]
|
||||||
|
# Avoid account picker when targeting a known account
|
||||||
|
extra_params["prompt"] = "consent"
|
||||||
|
else:
|
||||||
|
extra_params["prompt"] = "consent select_account"
|
||||||
|
else:
|
||||||
|
extra_params["prompt"] = "consent select_account"
|
||||||
|
except Exception:
|
||||||
|
extra_params["prompt"] = "consent select_account"
|
||||||
|
|
||||||
auth_url, state = oauth.authorization_url(
|
auth_url, state = oauth.authorization_url(
|
||||||
"https://accounts.google.com/o/oauth2/auth",
|
"https://accounts.google.com/o/oauth2/auth",
|
||||||
access_type="offline",
|
**extra_params
|
||||||
include_granted_scopes="true",
|
|
||||||
state=state_param,
|
|
||||||
prompt="consent select_account"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Generated Google OAuth URL using OAuth2Session: {auth_url}")
|
logger.info(f"Generated Google OAuth URL using OAuth2Session: {auth_url}")
|
||||||
|
|
@ -646,16 +670,30 @@ async def refresh_token(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser)
|
currentUser: User = Depends(getCurrentUser)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Refresh Google OAuth token for current user"""
|
"""Refresh Google OAuth token for current user. Accepts optional { connectionId } to target a specific connection."""
|
||||||
try:
|
try:
|
||||||
appInterface = getInterface(currentUser)
|
appInterface = getInterface(currentUser)
|
||||||
|
|
||||||
|
# Optional: use provided connectionId to target a specific connection
|
||||||
|
payload = {}
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
requested_connection_id = payload.get("connectionId") if isinstance(payload, dict) else None
|
||||||
|
|
||||||
# Find Google connection for this user
|
# Find Google connection for this user
|
||||||
logger.debug(f"Looking for Google connection for user {currentUser.id}")
|
logger.debug(f"Looking for Google connection for user {currentUser.id}")
|
||||||
|
|
||||||
connections = appInterface.getUserConnections(currentUser.id)
|
connections = appInterface.getUserConnections(currentUser.id)
|
||||||
google_connection = None
|
google_connection = None
|
||||||
|
if requested_connection_id:
|
||||||
|
for conn in connections:
|
||||||
|
if conn.id == requested_connection_id and conn.authority == AuthAuthority.GOOGLE:
|
||||||
|
google_connection = conn
|
||||||
|
break
|
||||||
|
if not google_connection:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Requested Google connection not found for current user")
|
||||||
|
else:
|
||||||
for conn in connections:
|
for conn in connections:
|
||||||
if conn.authority == AuthAuthority.GOOGLE:
|
if conn.authority == AuthAuthority.GOOGLE:
|
||||||
google_connection = conn
|
google_connection = conn
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@ import logging
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse
|
||||||
|
import uuid
|
||||||
|
from jose import jwt
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Import auth modules
|
# Import auth modules
|
||||||
from modules.security.auth import createAccessToken, getCurrentUser, limiter
|
from modules.security.auth import createAccessToken, getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
||||||
from modules.interfaces.interfaceAppObjects import getInterface, getRootInterface
|
from modules.interfaces.interfaceAppObjects import getInterface, getRootInterface
|
||||||
from modules.interfaces.interfaceAppModel import User, UserInDB, AuthAuthority, UserPrivilege, Token
|
from modules.interfaces.interfaceAppModel import User, UserInDB, AuthAuthority, UserPrivilege, Token
|
||||||
from modules.shared.attributeUtils import ModelMixin
|
from modules.shared.attributeUtils import ModelMixin
|
||||||
|
|
@ -33,7 +35,7 @@ router = APIRouter(
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("30/minute")
|
||||||
async def login(
|
async def login(
|
||||||
request: Request,
|
request: Request,
|
||||||
formData: OAuth2PasswordRequestForm = Depends(),
|
formData: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
|
@ -84,6 +86,10 @@ async def login(
|
||||||
"authenticationAuthority": AuthAuthority.LOCAL
|
"authenticationAuthority": AuthAuthority.LOCAL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Create session id and include in token claims for session-scoped logout
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
token_data["sid"] = session_id
|
||||||
|
|
||||||
# Create access token
|
# Create access token
|
||||||
access_token, expires_at = createAccessToken(token_data)
|
access_token, expires_at = createAccessToken(token_data)
|
||||||
if not access_token:
|
if not access_token:
|
||||||
|
|
@ -95,13 +101,24 @@ async def login(
|
||||||
# Get user-specific interface for token operations
|
# Get user-specific interface for token operations
|
||||||
userInterface = getInterface(user)
|
userInterface = getInterface(user)
|
||||||
|
|
||||||
|
# Decode JWT to get jti for DB persistence
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(access_token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
jti = payload.get("jti")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to decode created JWT: {str(e)}")
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to finalize token")
|
||||||
|
|
||||||
# Create token
|
# Create token
|
||||||
token = Token(
|
token = Token(
|
||||||
|
id=jti,
|
||||||
userId=user.id,
|
userId=user.id,
|
||||||
authority=AuthAuthority.LOCAL,
|
authority=AuthAuthority.LOCAL,
|
||||||
tokenAccess=access_token,
|
tokenAccess=access_token,
|
||||||
tokenType="bearer",
|
tokenType="bearer",
|
||||||
expiresAt=expires_at.timestamp()
|
expiresAt=expires_at.timestamp(),
|
||||||
|
sessionId=session_id,
|
||||||
|
mandateId=str(user.mandateId)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save access token
|
# Save access token
|
||||||
|
|
@ -111,7 +128,8 @@ async def login(
|
||||||
response_data = {
|
response_data = {
|
||||||
"type": "local_auth_success",
|
"type": "local_auth_success",
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"token_data": token.dict()
|
"token_data": token.dict(),
|
||||||
|
"authenticationAuthority": "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
return response_data
|
return response_data
|
||||||
|
|
@ -215,12 +233,29 @@ async def logout(request: Request, currentUser: User = Depends(getCurrentUser))
|
||||||
try:
|
try:
|
||||||
# Get user interface with current user context
|
# Get user interface with current user context
|
||||||
appInterface = getInterface(currentUser)
|
appInterface = getInterface(currentUser)
|
||||||
|
# Read bearer token from Authorization header to obtain session id / jti
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
if not auth_header or not auth_header.lower().startswith("bearer "):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing Authorization header")
|
||||||
|
raw_token = auth_header.split(" ", 1)[1].strip()
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(raw_token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
session_id = payload.get("sid") or payload.get("sessionId")
|
||||||
|
jti = payload.get("jti")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to decode JWT on logout: {str(e)}")
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid token")
|
||||||
|
|
||||||
# Note: JWT tokens are stateless, so no server-side cleanup needed
|
revoked = 0
|
||||||
# The client should discard the JWT token on logout
|
if session_id:
|
||||||
|
revoked = appInterface.revokeTokensBySessionId(session_id, currentUser.id, AuthAuthority.LOCAL, revokedBy=currentUser.id, reason="logout")
|
||||||
|
elif jti:
|
||||||
|
appInterface.revokeTokenById(jti, revokedBy=currentUser.id, reason="logout")
|
||||||
|
revoked = 1
|
||||||
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"message": "Successfully logged out"
|
"message": "Successfully logged out",
|
||||||
|
"revokedTokens": revoked
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -77,12 +77,40 @@ async def login(
|
||||||
"connectionId": connectionId
|
"connectionId": connectionId
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# If a specific connection is targeted, set login_hint/domain_hint to preselect that account
|
||||||
|
login_kwargs = {}
|
||||||
|
if connectionId:
|
||||||
|
try:
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
# Fetch the connection by ID directly
|
||||||
|
records = rootInterface.db.getRecordset(UserConnection, recordFilter={"id": connectionId})
|
||||||
|
if records:
|
||||||
|
record = records[0]
|
||||||
|
login_hint = record.get("externalEmail") or record.get("externalUsername")
|
||||||
|
if login_hint:
|
||||||
|
login_kwargs["login_hint"] = login_hint
|
||||||
|
# Derive domain hint from email/UPN
|
||||||
|
if "@" in login_hint:
|
||||||
|
domain = login_hint.split("@", 1)[1]
|
||||||
|
# Use common MSAL guidance: pass domain_hint to reduce account switching
|
||||||
|
login_kwargs["domain_hint"] = domain
|
||||||
|
# When targeting a specific account, avoid account picker
|
||||||
|
login_kwargs["prompt"] = "login" # force re-auth for that account only
|
||||||
|
else:
|
||||||
|
# Fall back to default behavior if connection not found
|
||||||
|
login_kwargs["prompt"] = "select_account"
|
||||||
|
except Exception:
|
||||||
|
login_kwargs["prompt"] = "select_account"
|
||||||
|
else:
|
||||||
|
# Generic login/connect flow: allow choosing account
|
||||||
|
login_kwargs["prompt"] = "select_account"
|
||||||
|
|
||||||
# MSAL automatically adds openid, profile, offline_access - we just need to provide our business scopes
|
# MSAL automatically adds openid, profile, offline_access - we just need to provide our business scopes
|
||||||
auth_url = msal_app.get_authorization_request_url(
|
auth_url = msal_app.get_authorization_request_url(
|
||||||
scopes=SCOPES, # Only our business scopes - MSAL adds the required ones automatically
|
scopes=SCOPES, # Only our business scopes - MSAL adds the required ones automatically
|
||||||
redirect_uri=REDIRECT_URI,
|
redirect_uri=REDIRECT_URI,
|
||||||
state=state_param,
|
state=state_param,
|
||||||
prompt="select_account" # Force account selection screen
|
**login_kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
return RedirectResponse(auth_url)
|
return RedirectResponse(auth_url)
|
||||||
|
|
@ -478,12 +506,32 @@ async def refresh_token(
|
||||||
try:
|
try:
|
||||||
appInterface = getInterface(currentUser)
|
appInterface = getInterface(currentUser)
|
||||||
|
|
||||||
|
# Optional: use provided connectionId to target a specific connection
|
||||||
|
payload = {}
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
requested_connection_id = payload.get("connectionId") if isinstance(payload, dict) else None
|
||||||
|
|
||||||
# Find Microsoft connection for this user
|
# Find Microsoft connection for this user
|
||||||
logger.debug(f"Looking for Microsoft connection for user {currentUser.id}")
|
logger.debug(f"Looking for Microsoft connection for user {currentUser.id}")
|
||||||
|
|
||||||
connections = appInterface.getUserConnections(currentUser.id)
|
connections = appInterface.getUserConnections(currentUser.id)
|
||||||
msft_connection = None
|
msft_connection = None
|
||||||
|
|
||||||
|
if requested_connection_id:
|
||||||
|
# Validate the requested connection belongs to current user and is MSFT
|
||||||
|
for conn in connections:
|
||||||
|
if conn.id == requested_connection_id and conn.authority == AuthAuthority.MSFT:
|
||||||
|
msft_connection = conn
|
||||||
|
break
|
||||||
|
if not msft_connection:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Requested Microsoft connection not found for current user"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback: first MSFT connection
|
||||||
for conn in connections:
|
for conn in connections:
|
||||||
if conn.authority == AuthAuthority.MSFT:
|
if conn.authority == AuthAuthority.MSFT:
|
||||||
msft_connection = conn
|
msft_connection = conn
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ Handles JWT-based authentication, token generation, and user context.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import uuid
|
||||||
from typing import Optional, Dict, Any, Tuple
|
from typing import Optional, Dict, Any, Tuple
|
||||||
from fastapi import Depends, HTTPException, status, Request
|
from fastapi import Depends, HTTPException, status, Request
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
|
@ -15,7 +16,7 @@ from slowapi.util import get_remote_address
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.timezoneUtils import get_utc_now, get_utc_timestamp
|
from modules.shared.timezoneUtils import get_utc_now, get_utc_timestamp
|
||||||
from modules.interfaces.interfaceAppObjects import getRootInterface
|
from modules.interfaces.interfaceAppObjects import getRootInterface
|
||||||
from modules.interfaces.interfaceAppModel import User
|
from modules.interfaces.interfaceAppModel import User, AuthAuthority, Token
|
||||||
|
|
||||||
# Get Config Data
|
# Get Config Data
|
||||||
SECRET_KEY = APP_CONFIG.get("APP_JWT_SECRET_SECRET")
|
SECRET_KEY = APP_CONFIG.get("APP_JWT_SECRET_SECRET")
|
||||||
|
|
@ -44,6 +45,9 @@ def createAccessToken(data: dict, expiresDelta: Optional[timedelta] = None) -> T
|
||||||
Tuple of (JWT Token as string, expiration datetime)
|
Tuple of (JWT Token as string, expiration datetime)
|
||||||
"""
|
"""
|
||||||
toEncode = data.copy()
|
toEncode = data.copy()
|
||||||
|
# Ensure a token id (jti) exists for revocation tracking (only required for local, harmless otherwise)
|
||||||
|
if "jti" not in toEncode or not toEncode.get("jti"):
|
||||||
|
toEncode["jti"] = str(uuid.uuid4())
|
||||||
|
|
||||||
if expiresDelta:
|
if expiresDelta:
|
||||||
expire = get_utc_now() + expiresDelta
|
expire = get_utc_now() + expiresDelta
|
||||||
|
|
@ -86,6 +90,9 @@ def _getUserBase(token: str = Depends(oauth2Scheme)) -> User:
|
||||||
# Extract mandate ID and user ID from token
|
# Extract mandate ID and user ID from token
|
||||||
mandateId: str = payload.get("mandateId")
|
mandateId: str = payload.get("mandateId")
|
||||||
userId: str = payload.get("userId")
|
userId: str = payload.get("userId")
|
||||||
|
authority: str = payload.get("authenticationAuthority")
|
||||||
|
tokenId: Optional[str] = payload.get("jti")
|
||||||
|
sessionId: Optional[str] = payload.get("sid") or payload.get("sessionId")
|
||||||
|
|
||||||
if not mandateId or not userId:
|
if not mandateId or not userId:
|
||||||
logger.error(f"Missing context in token: mandateId={mandateId}, userId={userId}")
|
logger.error(f"Missing context in token: mandateId={mandateId}, userId={userId}")
|
||||||
|
|
@ -119,6 +126,50 @@ def _getUserBase(token: str = Depends(oauth2Scheme)) -> User:
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# For LOCAL gateway JWTs, enforce DB-backed token validity and revocation
|
||||||
|
try:
|
||||||
|
# Normalize authority to string for comparison
|
||||||
|
normalized_authority = (str(authority).lower() if authority is not None else None)
|
||||||
|
|
||||||
|
# If we have a token id, check if a corresponding DB token exists for local authority
|
||||||
|
db_tokens = []
|
||||||
|
if tokenId:
|
||||||
|
try:
|
||||||
|
db_tokens = appInterface.db.getRecordset(
|
||||||
|
Token, recordFilter={"id": tokenId}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
db_tokens = []
|
||||||
|
|
||||||
|
if db_tokens:
|
||||||
|
# There is a server record for this token; enforce status and context when local
|
||||||
|
db_token = db_tokens[0]
|
||||||
|
token_authority = str(db_token.get("authority", "")).lower()
|
||||||
|
if token_authority == str(AuthAuthority.LOCAL.value):
|
||||||
|
# Must be active and match user/session/mandate
|
||||||
|
active_token = appInterface.findActiveTokenById(
|
||||||
|
tokenId=tokenId,
|
||||||
|
userId=user.id,
|
||||||
|
authority=AuthAuthority.LOCAL,
|
||||||
|
sessionId=sessionId,
|
||||||
|
mandateId=str(mandateId) if mandateId else None,
|
||||||
|
)
|
||||||
|
if not active_token:
|
||||||
|
logger.info(
|
||||||
|
f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, mandateId={mandateId}, sessionId={sessionId}"
|
||||||
|
)
|
||||||
|
raise credentialsException
|
||||||
|
else:
|
||||||
|
# No DB record for this token. If the claim says local (or missing/unknown), require DB record.
|
||||||
|
if normalized_authority in (None, "", str(AuthAuthority.LOCAL.value)):
|
||||||
|
logger.info("Local JWT without server record or missing authority claim")
|
||||||
|
raise credentialsException
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during local token validation: {str(e)}")
|
||||||
|
raise credentialsException
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def getCurrentUser(currentUser: User = Depends(_getUserBase)) -> User:
|
def getCurrentUser(currentUser: User = Depends(_getUserBase)) -> User:
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,12 @@
|
||||||
# Data Platform - Multi-Agent Service
|
### Launch APP
|
||||||
|
|
||||||
Eine Full-Stack-Webapplikation für die Ausführung von Multi-Agent-Workflows zur Verarbeitung und Analyse von Daten basierend auf natürlichsprachlichen Benutzeranfragen.
|
cd .\frontend_agents\
|
||||||
|
cls; python ./server.py
|
||||||
|
|
||||||
Hier: http://localhost:8000/docs
|
conda activate C:\Users\pmots\anaconda3\envs\poweron
|
||||||
|
cd .\gateway\
|
||||||
|
cls; uvicorn app:app --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
## Übersicht
|
|
||||||
|
|
||||||
Das System ermöglicht Benutzern:
|
|
||||||
- Hochladen und Verwalten verschiedener Datendateien
|
|
||||||
- Definieren von Prompts/Anweisungen für KI-Agenten
|
|
||||||
- Auswählen und Kombinieren spezialisierter Agenten
|
|
||||||
- Ausführen von Workflows mit Echtzeit-Protokollierung
|
|
||||||
- Visualisieren und Verwalten der Ergebnisse
|
|
||||||
|
|
||||||
## Projektstruktur
|
|
||||||
|
|
||||||
Das Projekt besteht aus zwei Hauptkomponenten:
|
|
||||||
|
|
||||||
### Frontend (HTML/CSS/JavaScript)
|
|
||||||
|
|
||||||
- `index.html` - Hauptstruktur der Benutzeroberfläche
|
|
||||||
- `styles.css` - Umfangreiches CSS für das responsive Design
|
|
||||||
- `script.js` - Client-seitige Logik für Interaktionen
|
|
||||||
|
|
||||||
### Backend (Python/FastAPI)
|
|
||||||
|
|
||||||
- `app.py` - Hauptanwendung mit API-Endpunkten
|
|
||||||
- `models.py` - Datenmodelle und Validierungsschemas
|
|
||||||
- `database.py` - Datenpersistenz (JSON-basiert für Demo)
|
|
||||||
- `agent_service.py` - Multi-Agent-Orchestrierung
|
|
||||||
- `requirements.txt` - Python-Abhängigkeiten
|
|
||||||
|
|
||||||
### Backend-Installation Lokal
|
|
||||||
0. lokal dev: (anaconda environment) conda activate poweron
|
|
||||||
|
|
||||||
1. Virtuelle Umgebung erstellen und aktivieren:
|
|
||||||
```bash
|
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate # Linux/Mac
|
|
||||||
venv\Scripts\activate # Windows
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Abhängigkeiten installieren:
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Server starten:
|
|
||||||
```bash
|
|
||||||
uvicorn app:app --reload --host 0.0.0.0 --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Zugangspunkte:
|
|
||||||
- Frontend: `http://localhost:8080`
|
|
||||||
- Backend API: `http://localhost:8000`
|
|
||||||
- API-Dokumentation: `http://localhost:8000/docs`
|
|
||||||
|
|
||||||
### Installation inazure WEBAPP
|
|
||||||
Configuration:
|
|
||||||
- Python version <= 3.11
|
|
||||||
- Startup command: cd gwserver && uvicorn app:app --host 0.0.0.0 --port 8000
|
|
||||||
Environment varibales:
|
|
||||||
- Neue Variable PORT=8000
|
|
||||||
|
|
||||||
### DEV TOOLS
|
|
||||||
Kill all processes on port 8000
|
|
||||||
netsh advfirewall firewall add rule name="Close_Port_8000" dir=in action=block protocol=TCP localport=8000
|
|
||||||
netsh advfirewall firewall delete rule name="Close_Port_8000"
|
|
||||||
|
|
||||||
|
|
||||||
### Datenbank-Migration
|
|
||||||
Für größere Installationen die JSON-basierte Datenbank ersetzen durch:
|
|
||||||
- PostgreSQL für relationale Daten
|
|
||||||
- MongoDB für Dokumente und unstrukturierte Daten
|
|
||||||
- Redis für Caching und Workflow-Status
|
|
||||||
|
|
||||||
## Technische Details
|
|
||||||
|
|
||||||
### Frontend-Architektur
|
|
||||||
- Vanilla JavaScript ohne Framework-Abhängigkeiten
|
|
||||||
- Modularer CSS-Ansatz für einfache Anpassungen
|
|
||||||
- Responsive Design für Desktop und mobile Nutzung
|
|
||||||
|
|
||||||
### Backend-Architektur
|
|
||||||
- FastAPI für hohe Performance und automatische API-Dokumentation
|
|
||||||
- Asynchrone Verarbeitung für parallele Agent-Ausführung
|
|
||||||
- Erweiterbare Service-Struktur für einfache Integration neuer Agententypen
|
|
||||||
|
|
||||||
### git permanent login with vs code
|
### git permanent login with vs code
|
||||||
git remote set-url origin https://valueon@github.com/valueonag/gateway
|
git remote set-url origin https://valueon@github.com/valueonag/gateway
|
||||||
|
|
@ -116,10 +37,3 @@ foreach ($run in $runs) {
|
||||||
Write-Host "Deleting run $run"
|
Write-Host "Deleting run $run"
|
||||||
echo "y" | gh run delete $run
|
echo "y" | gh run delete $run
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
## Lizenz
|
|
||||||
|
|
||||||
PRIVATE LICENSE PATRICK MOTSCH ValueOn AG
|
|
||||||
---
|
|
||||||
Für Fragen oder Unterstützung wenden Sie sich bitte an p.motsch@valueon.ch
|
|
||||||
Loading…
Reference in a new issue