landvex: Fixar och tester klara för alla komponenter
- Datafabrik: Dockerfile fix, agentorkestrering fungerar - Vision: Identify-modell, FAISS, OCR alla testade - API: Alla 7 integrationstester passerade - Upplösare: Entitetsupplösning verifierad
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
LandveX Admin Backend — Configuration
|
||||
"""
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
extra="allow",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True
|
||||
)
|
||||
|
||||
# Application
|
||||
APP_NAME: str = "LandveX Admin Backend"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/landvex_admin"
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 10
|
||||
|
||||
# Schema isolation
|
||||
DEFAULT_TENANT_SCHEMA: str = "public"
|
||||
TENANT_SCHEMA_PREFIX: str = "tenant_"
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str = "change-me-in-production-landvex-secret-key-2026"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
ALGORITHM: str = "HS256"
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: List[str] = ["*"]
|
||||
CORS_ALLOW_CREDENTIALS: bool = True
|
||||
CORS_ALLOW_METHODS: List[str] = ["*"]
|
||||
CORS_ALLOW_HEADERS: List[str] = ["*"]
|
||||
|
||||
# Audit logging
|
||||
AUDIT_LOG_TABLE: str = "audit_logs"
|
||||
AUDIT_RETENTION_DAYS: int = 365
|
||||
|
||||
# Pagination
|
||||
DEFAULT_PAGE_SIZE: int = 20
|
||||
MAX_PAGE_SIZE: int = 100
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
LandveX Admin Backend — Custom Exceptions
|
||||
"""
|
||||
from fastapi import HTTPException, status
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
class LandvexException(Exception):
|
||||
"""Base exception for LandveX application."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = 500,
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.details = details or {}
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class TenantNotFoundException(LandvexException):
|
||||
"""Raised when a tenant is not found."""
|
||||
|
||||
def __init__(self, tenant_id: str):
|
||||
super().__init__(
|
||||
message=f"Tenant not found: {tenant_id}",
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
details={"tenant_id": tenant_id}
|
||||
)
|
||||
|
||||
|
||||
class TenantAlreadyExistsException(LandvexException):
|
||||
"""Raised when attempting to create a tenant that already exists."""
|
||||
|
||||
def __init__(self, identifier: str, field: str = "slug"):
|
||||
super().__init__(
|
||||
message=f"Tenant with {field} '{identifier}' already exists",
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
details={"field": field, "value": identifier}
|
||||
)
|
||||
|
||||
|
||||
class TenantValidationException(LandvexException):
|
||||
"""Raised when tenant data validation fails."""
|
||||
|
||||
def __init__(self, message: str, field: Optional[str] = None):
|
||||
details = {}
|
||||
if field:
|
||||
details["field"] = field
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
details=details
|
||||
)
|
||||
|
||||
|
||||
class TenantSchemaException(LandvexException):
|
||||
"""Raised when tenant schema operations fail."""
|
||||
|
||||
def __init__(self, tenant_id: str, operation: str, original_error: str):
|
||||
super().__init__(
|
||||
message=f"Schema operation '{operation}' failed for tenant {tenant_id}: {original_error}",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
details={"tenant_id": tenant_id, "operation": operation}
|
||||
)
|
||||
|
||||
|
||||
class UnauthorizedTenantAccessException(LandvexException):
|
||||
"""Raised when a user tries to access a tenant they don't have permission for."""
|
||||
|
||||
def __init__(self, tenant_id: str, user_id: str):
|
||||
super().__init__(
|
||||
message=f"User {user_id} is not authorized to access tenant {tenant_id}",
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
details={"tenant_id": tenant_id, "user_id": user_id}
|
||||
)
|
||||
|
||||
|
||||
class AuditLogException(LandvexException):
|
||||
"""Raised when audit logging fails."""
|
||||
|
||||
def __init__(self, operation: str, original_error: str):
|
||||
super().__init__(
|
||||
message=f"Audit logging failed for operation '{operation}': {original_error}",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
details={"operation": operation}
|
||||
)
|
||||
|
||||
|
||||
def http_exception_handler(exc: LandvexException) -> HTTPException:
|
||||
"""Convert LandvexException to FastAPI HTTPException."""
|
||||
return HTTPException(
|
||||
status_code=exc.status_code,
|
||||
detail={
|
||||
"error": exc.message,
|
||||
"details": exc.details
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
LandveX Admin Backend — Security utilities
|
||||
JWT tokens, password hashing, and auth helpers
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a plain password against a hashed password."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash a password."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(
|
||||
data: Dict[str, Any],
|
||||
expires_delta: Optional[timedelta] = None
|
||||
) -> str:
|
||||
"""Create a JWT access token."""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(data: Dict[str, Any]) -> str:
|
||||
"""Create a JWT refresh token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Decode and validate a JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||
) -> Dict[str, Any]:
|
||||
"""Get the current authenticated user from the JWT token."""
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return {
|
||||
"id": user_id,
|
||||
"email": payload.get("email"),
|
||||
"role": payload.get("role", "user"),
|
||||
"tenant_id": payload.get("tenant_id"),
|
||||
"permissions": payload.get("permissions", []),
|
||||
}
|
||||
|
||||
|
||||
async def require_admin(
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""Require admin role."""
|
||||
if current_user.get("role") != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_tenant_access(
|
||||
tenant_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""Require access to a specific tenant."""
|
||||
user_role = current_user.get("role", "")
|
||||
user_tenant = current_user.get("tenant_id")
|
||||
|
||||
# Admin can access all tenants
|
||||
if user_role == "admin":
|
||||
return current_user
|
||||
|
||||
# Users can only access their own tenant
|
||||
if user_tenant != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access denied for tenant {tenant_id}"
|
||||
)
|
||||
|
||||
return current_user
|
||||
Reference in New Issue
Block a user