enhanced workflow
This commit is contained in:
parent
a941963e78
commit
55a3cac2df
14 changed files with 621 additions and 155 deletions
9
app.py
9
app.py
|
|
@ -57,7 +57,8 @@ def initLogging():
|
|||
'_send_single_request',
|
||||
'httpcore.http11',
|
||||
'httpx._client',
|
||||
'HTTP Request'
|
||||
'HTTP Request',
|
||||
'multipart.multipart'
|
||||
]
|
||||
return not any(pattern in record.msg for pattern in http_debug_patterns)
|
||||
return True
|
||||
|
|
@ -253,4 +254,8 @@ from modules.routes.routeVoiceGoogle import router as voiceGoogleRouter
|
|||
app.include_router(voiceGoogleRouter)
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# CORS Configuration
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ DB_MANAGEMENT_PASSWORD_SECRET=VkAjgECESbEVQ$Tu
|
|||
DB_MANAGEMENT_PORT=5432
|
||||
|
||||
# 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
|
||||
|
||||
# CORS Configuration
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ DB_MANAGEMENT_PASSWORD_SECRET=prod_password_very_secure.2025
|
|||
DB_MANAGEMENT_PORT=5432
|
||||
|
||||
# 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
|
||||
|
||||
# CORS Configuration
|
||||
|
|
|
|||
|
|
@ -57,14 +57,6 @@ class DatabaseConnector:
|
|||
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):
|
||||
# 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
|
||||
self.dbHost = dbHost
|
||||
self.dbDatabase = dbDatabase
|
||||
|
|
@ -109,10 +101,6 @@ class DatabaseConnector:
|
|||
def _create_database_if_not_exists(self):
|
||||
"""Create the database if it doesn't exist."""
|
||||
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
|
||||
conn = psycopg2.connect(
|
||||
host=self.dbHost,
|
||||
|
|
@ -132,11 +120,8 @@ class DatabaseConnector:
|
|||
if not exists:
|
||||
# Create database with proper quoting for names with hyphens
|
||||
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}")
|
||||
logger.info(f"Created database: {self.dbDatabase}")
|
||||
else:
|
||||
logger.info(f"Database {self.dbDatabase} already exists")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
|
@ -171,9 +156,6 @@ class DatabaseConnector:
|
|||
_modifiedAt DOUBLE PRECISION
|
||||
)
|
||||
""")
|
||||
|
||||
logger.info("System table created successfully")
|
||||
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -195,7 +177,6 @@ class DatabaseConnector:
|
|||
cursor_factory=psycopg2.extras.RealDictCursor
|
||||
)
|
||||
self.connection.autocommit = False # Use transactions
|
||||
logger.info(f"Connected to PostgreSQL database: {self.dbDatabase}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to PostgreSQL: {e}")
|
||||
raise
|
||||
|
|
@ -299,8 +280,6 @@ class DatabaseConnector:
|
|||
|
||||
if '_modifiedAt' not in existing_columns:
|
||||
cursor.execute(f'ALTER TABLE "{self._systemTableName}" ADD COLUMN "_modifiedAt" DOUBLE PRECISION')
|
||||
logger.info("Added _modifiedAt column to existing system table")
|
||||
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
|
|
@ -610,15 +589,8 @@ class DatabaseConnector:
|
|||
raise ValueError("userId must be provided")
|
||||
|
||||
self.userId = userId
|
||||
logger.info(f"Updated database context: userId={self.userId}")
|
||||
|
||||
# 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
|
||||
|
||||
def getTables(self) -> List[str]:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ class ConnectionStatus(str, Enum):
|
|||
REVOKED = "revoked"
|
||||
PENDING = "pending"
|
||||
|
||||
class TokenStatus(str, Enum):
|
||||
"""Status of an issued gateway JWT access token"""
|
||||
ACTIVE = "active"
|
||||
REVOKED = "revoked"
|
||||
|
||||
class Mandate(BaseModel, ModelMixin):
|
||||
"""Data model for a mandate"""
|
||||
id: str = Field(
|
||||
|
|
@ -321,6 +326,13 @@ class Token(BaseModel, ModelMixin):
|
|||
expiresAt: float = Field(description="When the token expires (UTC timestamp in seconds)")
|
||||
tokenRefresh: Optional[str] = None
|
||||
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:
|
||||
useEnumValues = True
|
||||
|
|
@ -338,7 +350,13 @@ register_model_labels(
|
|||
"tokenType": {"en": "Token Type", "fr": "Type de jeton"},
|
||||
"expiresAt": {"en": "Expires At", "fr": "Expire le"},
|
||||
"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 (
|
||||
User, Mandate, UserInDB, UserConnection,
|
||||
AuthAuthority, UserPrivilege,
|
||||
ConnectionStatus, Token, AuthEvent,
|
||||
ConnectionStatus, Token, AuthEvent, TokenStatus,
|
||||
DataNeutraliserConfig, DataNeutralizerAttributes
|
||||
)
|
||||
|
||||
|
|
@ -949,6 +949,99 @@ class AppObjects:
|
|||
logger.error(f"Error deleting connection token for connectionId {connectionId}: {str(e)}")
|
||||
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:
|
||||
"""Clean up expired tokens for all connections, returns count of cleaned tokens"""
|
||||
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
|
||||
)
|
||||
|
||||
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(
|
||||
"https://accounts.google.com/o/oauth2/auth",
|
||||
access_type="offline",
|
||||
include_granted_scopes="true",
|
||||
state=state_param,
|
||||
prompt="consent select_account"
|
||||
**extra_params
|
||||
)
|
||||
|
||||
logger.info(f"Generated Google OAuth URL using OAuth2Session: {auth_url}")
|
||||
|
|
@ -646,20 +670,34 @@ async def refresh_token(
|
|||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> 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:
|
||||
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
|
||||
logger.debug(f"Looking for Google connection for user {currentUser.id}")
|
||||
|
||||
connections = appInterface.getUserConnections(currentUser.id)
|
||||
google_connection = None
|
||||
|
||||
for conn in connections:
|
||||
if conn.authority == AuthAuthority.GOOGLE:
|
||||
google_connection = conn
|
||||
break
|
||||
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:
|
||||
if conn.authority == AuthAuthority.GOOGLE:
|
||||
google_connection = conn
|
||||
break
|
||||
|
||||
if not google_connection:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ import logging
|
|||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse
|
||||
import uuid
|
||||
from jose import jwt
|
||||
from pydantic import BaseModel
|
||||
|
||||
# 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.interfaceAppModel import User, UserInDB, AuthAuthority, UserPrivilege, Token
|
||||
from modules.shared.attributeUtils import ModelMixin
|
||||
|
|
@ -33,7 +35,7 @@ router = APIRouter(
|
|||
)
|
||||
|
||||
@router.post("/login")
|
||||
@limiter.limit("5/minute")
|
||||
@limiter.limit("30/minute")
|
||||
async def login(
|
||||
request: Request,
|
||||
formData: OAuth2PasswordRequestForm = Depends(),
|
||||
|
|
@ -84,6 +86,10 @@ async def login(
|
|||
"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
|
||||
access_token, expires_at = createAccessToken(token_data)
|
||||
if not access_token:
|
||||
|
|
@ -95,13 +101,24 @@ async def login(
|
|||
# Get user-specific interface for token operations
|
||||
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
|
||||
token = Token(
|
||||
id=jti,
|
||||
userId=user.id,
|
||||
authority=AuthAuthority.LOCAL,
|
||||
tokenAccess=access_token,
|
||||
tokenType="bearer",
|
||||
expiresAt=expires_at.timestamp()
|
||||
expiresAt=expires_at.timestamp(),
|
||||
sessionId=session_id,
|
||||
mandateId=str(user.mandateId)
|
||||
)
|
||||
|
||||
# Save access token
|
||||
|
|
@ -111,7 +128,8 @@ async def login(
|
|||
response_data = {
|
||||
"type": "local_auth_success",
|
||||
"access_token": access_token,
|
||||
"token_data": token.dict()
|
||||
"token_data": token.dict(),
|
||||
"authenticationAuthority": "local"
|
||||
}
|
||||
|
||||
return response_data
|
||||
|
|
@ -215,12 +233,29 @@ async def logout(request: Request, currentUser: User = Depends(getCurrentUser))
|
|||
try:
|
||||
# Get user interface with current user context
|
||||
appInterface = getInterface(currentUser)
|
||||
|
||||
# Note: JWT tokens are stateless, so no server-side cleanup needed
|
||||
# The client should discard the JWT token on logout
|
||||
|
||||
# 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")
|
||||
|
||||
revoked = 0
|
||||
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({
|
||||
"message": "Successfully logged out"
|
||||
"message": "Successfully logged out",
|
||||
"revokedTokens": revoked
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -77,12 +77,40 @@ async def login(
|
|||
"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
|
||||
auth_url = msal_app.get_authorization_request_url(
|
||||
scopes=SCOPES, # Only our business scopes - MSAL adds the required ones automatically
|
||||
redirect_uri=REDIRECT_URI,
|
||||
state=state_param,
|
||||
prompt="select_account" # Force account selection screen
|
||||
**login_kwargs
|
||||
)
|
||||
|
||||
return RedirectResponse(auth_url)
|
||||
|
|
@ -478,16 +506,36 @@ async def refresh_token(
|
|||
try:
|
||||
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
|
||||
logger.debug(f"Looking for Microsoft connection for user {currentUser.id}")
|
||||
|
||||
connections = appInterface.getUserConnections(currentUser.id)
|
||||
msft_connection = None
|
||||
|
||||
for conn in connections:
|
||||
if conn.authority == AuthAuthority.MSFT:
|
||||
msft_connection = conn
|
||||
break
|
||||
|
||||
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:
|
||||
if conn.authority == AuthAuthority.MSFT:
|
||||
msft_connection = conn
|
||||
break
|
||||
|
||||
if not msft_connection:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Handles JWT-based authentication, token generation, and user context.
|
|||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
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.timezoneUtils import get_utc_now, get_utc_timestamp
|
||||
from modules.interfaces.interfaceAppObjects import getRootInterface
|
||||
from modules.interfaces.interfaceAppModel import User
|
||||
from modules.interfaces.interfaceAppModel import User, AuthAuthority, Token
|
||||
|
||||
# Get Config Data
|
||||
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)
|
||||
"""
|
||||
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:
|
||||
expire = get_utc_now() + expiresDelta
|
||||
|
|
@ -86,6 +90,9 @@ def _getUserBase(token: str = Depends(oauth2Scheme)) -> User:
|
|||
# Extract mandate ID and user ID from token
|
||||
mandateId: str = payload.get("mandateId")
|
||||
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:
|
||||
logger.error(f"Missing context in token: mandateId={mandateId}, userId={userId}")
|
||||
|
|
@ -118,6 +125,50 @@ def _getUserBase(token: str = Depends(oauth2Scheme)) -> User:
|
|||
detail="User context has changed. Please log in again.",
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 remote set-url origin https://valueon@github.com/valueonag/gateway
|
||||
|
|
@ -116,10 +37,3 @@ foreach ($run in $runs) {
|
|||
Write-Host "Deleting run $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