Merge pull request #49 from valueonag/int

enhanced workflow
This commit is contained in:
ValueOn AG 2025-09-18 17:21:50 +02:00 committed by GitHub
commit 41d2b463db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 621 additions and 155 deletions

7
app.py
View file

@ -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
@ -254,3 +255,7 @@ app.include_router(voiceGoogleRouter)
from modules.routes.routeVoiceStreaming import router as 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)

Binary file not shown.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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]:

View file

@ -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"}
}
)

View file

@ -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:

View 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")

View file

@ -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,16 +670,30 @@ 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
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

View file

@ -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)
# 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
# The client should discard the JWT token on logout
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:

View file

@ -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,12 +506,32 @@ 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
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

View file

@ -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}")
@ -119,6 +126,50 @@ def _getUserBase(token: str = Depends(oauth2Scheme)) -> User:
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
def getCurrentUser(currentUser: User = Depends(_getUserBase)) -> User:

View file

@ -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