latest code
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("AI_Runner")
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 4:
|
||||
print(json.dumps({"status": "failed", "error": "Invalid arguments. Usage: python ai_runner.py <input_path> <session_id> <user_id>"}))
|
||||
sys.exit(1)
|
||||
|
||||
input_path = sys.argv[1]
|
||||
session_id = sys.argv[2]
|
||||
user_id = sys.argv[3]
|
||||
|
||||
try:
|
||||
from app.core.model_manager import ModelManager
|
||||
from app.modules.documents.processors.pdf.pdf_converter import UniversalContentIntelligence
|
||||
from app.modules.documents.processors.pdf.image_extractor import ImageExtractor
|
||||
|
||||
logger.info(f"🧠 Step 1: Running Marker AI on {input_path}")
|
||||
with ModelManager() as ai:
|
||||
result = ai.process_document(input_path)
|
||||
|
||||
if hasattr(result, "markdown"):
|
||||
content = result.markdown
|
||||
elif isinstance(result, dict) and "markdown" in result:
|
||||
content = result["markdown"]
|
||||
else:
|
||||
try:
|
||||
from marker.output import text_from_rendered
|
||||
content = text_from_rendered(result)
|
||||
except:
|
||||
content = str(result)
|
||||
|
||||
logger.info("🔍 Step 2: Running Universal Content Intelligence")
|
||||
metadata = UniversalContentIntelligence.extract_universal_metadata(content)
|
||||
|
||||
figure_image_map = {}
|
||||
pattern1 = r'!\[Image\s+(\d+)\]\((http[s]?://[^)]+)\)\s*\n\s*\*\*Figure\s+(\d+):'
|
||||
matches1 = re.findall(pattern1, content, re.IGNORECASE | re.MULTILINE)
|
||||
for _, image_url, figure_num in matches1:
|
||||
figure_image_map[figure_num] = image_url
|
||||
|
||||
pattern2 = r'!\[Image\s+(\d+)\]\(([^)]+)\)'
|
||||
matches2 = re.findall(pattern2, content, re.IGNORECASE)
|
||||
for image_num, image_url in matches2:
|
||||
if image_num not in figure_image_map:
|
||||
figure_image_map[image_num] = image_url
|
||||
|
||||
logger.info("🖼️ Step 3: Extracting images")
|
||||
images = []
|
||||
try:
|
||||
extractor = ImageExtractor()
|
||||
images = extractor.process_markdown_output_images(result, session_id=session_id, user_id=user_id)
|
||||
logger.info(f"✅ Processed {len(images)} images")
|
||||
except Exception as img_err:
|
||||
logger.error(f"⚠️ Image extraction failed: {img_err}")
|
||||
|
||||
print(json.dumps({
|
||||
"status": "success",
|
||||
"markdown_content": content,
|
||||
"session_id": session_id,
|
||||
"images": images,
|
||||
"metadata": {
|
||||
"figures": figure_image_map,
|
||||
"title": metadata.title if hasattr(metadata, 'title') else ""
|
||||
}
|
||||
}))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ AI Runner Failed: {e}")
|
||||
print(json.dumps({
|
||||
"status": "failed",
|
||||
"error": str(e)
|
||||
}))
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,31 @@
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
from app.core.model_manager import ModelManager
|
||||
|
||||
app = FastAPI(title="AI Model Server")
|
||||
|
||||
model = ModelManager()
|
||||
|
||||
|
||||
class ProcessRequest(BaseModel):
|
||||
file_path: str
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/process")
|
||||
def process_document(req: ProcessRequest):
|
||||
"""
|
||||
Heavy AI processing happens here.
|
||||
This process should be run separately (e.g., on port 9000).
|
||||
"""
|
||||
result = model.process_document(req.file_path)
|
||||
return {"result": result}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=9000)
|
||||
@@ -0,0 +1,68 @@
|
||||
from fastapi import APIRouter, WebSocket, Query, WebSocketDisconnect
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
import logging
|
||||
|
||||
from app.infrastructure.realtime.connection_manager import manager
|
||||
from app.core.settings import settings
|
||||
from typing import Optional
|
||||
|
||||
from app.core.ws_auth import authenticate_websocket
|
||||
from app.db.database import SessionLocal
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.documents.models.document_model import Project
|
||||
from app.core.token_blacklist import TokenBlacklist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def _authenticate_ws(token: str, db: Session) -> Optional[User]:
|
||||
"""
|
||||
Kept as a thin delegation, not deleted.
|
||||
|
||||
Both WebSocket modules carried their own copy of this and drifted apart;
|
||||
`app/core/ws_auth.py` is now the single implementation. The name stays
|
||||
because the handshake below calls it and the probes resolve it by name --
|
||||
the point was to remove the second set of *rules*, not the local symbol.
|
||||
"""
|
||||
return authenticate_websocket(token, db)
|
||||
|
||||
@router.websocket("/ws/{task_id}")
|
||||
async def websocket_progress(websocket: WebSocket, task_id: str, token: str = Query(None)):
|
||||
auth_token = token or websocket.cookies.get("docqube_access_token")
|
||||
|
||||
if auth_token and TokenBlacklist.is_token_blacklisted(auth_token):
|
||||
await websocket.accept()
|
||||
await websocket.close(code=4001, reason="Token revoked")
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = _authenticate_ws(auth_token, db) if auth_token else None
|
||||
if not user:
|
||||
logger.warning(f"WebSocket progress auth failed for task {task_id}")
|
||||
await websocket.close(code=4001, reason="Authentication failed")
|
||||
return
|
||||
|
||||
project = db.query(Project).filter(Project.session_id == task_id, Project.user_id == user.id).first()
|
||||
if not project:
|
||||
logger.warning(f"Unauthorized WebSocket progress access: User {user.id} task {task_id}")
|
||||
await websocket.close(code=4003, reason="Access denied to this task")
|
||||
return
|
||||
|
||||
user_id = user.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
await manager.connect_task(task_id, websocket)
|
||||
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"WebSocket progress disconnected: user={user_id} task={task_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket progress error: user={user_id} task={task_id}: {e}")
|
||||
finally:
|
||||
manager.disconnect(websocket)
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"runtimeTarget": {
|
||||
"name": ".NETCoreApp,Version=v8.0",
|
||||
"signature": ""
|
||||
},
|
||||
"compilationOptions": {},
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v8.0": {
|
||||
"Stylesheet/1.0.0": {
|
||||
"runtime": {
|
||||
"Stylesheet.dll": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"Stylesheet/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"runtimeOptions": {
|
||||
"tfm": "net8.0",
|
||||
"frameworks": [
|
||||
{
|
||||
"name": "Microsoft.NETCore.App",
|
||||
"version": "8.0.0"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft.WindowsDesktop.App",
|
||||
"version": "8.0.0"
|
||||
}
|
||||
],
|
||||
"configProperties": {
|
||||
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": true
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
import redis
|
||||
from app.core.settings import settings
|
||||
|
||||
redis_client = redis.from_url(
|
||||
settings.REDIS_URL or "redis://localhost:6379/0",
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
def cancel_task(task_id: str):
|
||||
redis_client.set(f"cancel:{task_id}", "1", ex=86400)
|
||||
|
||||
def is_cancelled(task_id: str):
|
||||
return redis_client.exists(f"cancel:{task_id}") == 1
|
||||
@@ -0,0 +1,118 @@
|
||||
import io
|
||||
import socket
|
||||
import struct
|
||||
import logging
|
||||
from typing import BinaryIO, Iterable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.core.settings import settings
|
||||
|
||||
class ClamAVError(Exception):
|
||||
"""Base exception for ClamAV scanning failures."""
|
||||
|
||||
|
||||
class ClamAVConnectionError(ClamAVError):
|
||||
"""Raised when the ClamAV service cannot be reached."""
|
||||
|
||||
|
||||
class MalwareDetectedError(ClamAVError):
|
||||
"""Raised when ClamAV reports malware in a payload."""
|
||||
|
||||
def __init__(self, virus_name: str, response: str):
|
||||
self.virus_name = virus_name
|
||||
self.response = response
|
||||
super().__init__(f"Malware detected: {virus_name}")
|
||||
|
||||
|
||||
def _response_status(response: str) -> tuple[str, Optional[str]]:
|
||||
text = response.strip().strip("\x00")
|
||||
|
||||
if " FOUND" in text:
|
||||
payload = text.split(": ", 1)[1] if ": " in text else text
|
||||
virus_name = payload.rsplit(" FOUND", 1)[0].strip() or "Unknown threat"
|
||||
return "found", virus_name
|
||||
|
||||
if text.endswith("OK") or " OK" in text:
|
||||
return "ok", None
|
||||
|
||||
return "error", None
|
||||
|
||||
|
||||
def _recv_clamav_response(sock: socket.socket) -> str:
|
||||
chunks = []
|
||||
while True:
|
||||
data = sock.recv(4096)
|
||||
if not data:
|
||||
break
|
||||
chunks.append(data)
|
||||
if b"\x00" in data:
|
||||
break
|
||||
return b"".join(chunks).decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _scan_chunks(chunks: Iterable[bytes]) -> None:
|
||||
host = settings.CLAMAV_HOST
|
||||
port = settings.CLAMAV_PORT
|
||||
timeout = float(settings.CLAMAV_TIMEOUT_SECONDS)
|
||||
|
||||
try:
|
||||
logger.info(f"Connecting to ClamAV for scan at {host}:{port}...")
|
||||
with socket.create_connection((host, port), timeout=timeout) as sock:
|
||||
sock.settimeout(timeout)
|
||||
sock.sendall(b"zINSTREAM\0")
|
||||
|
||||
total_bytes = 0
|
||||
for chunk in chunks:
|
||||
if not chunk:
|
||||
continue
|
||||
total_bytes += len(chunk)
|
||||
sock.sendall(struct.pack("!I", len(chunk)))
|
||||
sock.sendall(chunk)
|
||||
|
||||
sock.sendall(struct.pack("!I", 0))
|
||||
response = _recv_clamav_response(sock)
|
||||
logger.info(f"ClamAV scan completed. Sent {total_bytes} bytes. Response: {response.strip()}")
|
||||
except (OSError, socket.timeout) as exc:
|
||||
raise ClamAVConnectionError(
|
||||
f"Unable to connect to ClamAV at {host}:{port}"
|
||||
) from exc
|
||||
|
||||
status, virus_name = _response_status(response)
|
||||
if status == "found":
|
||||
logger.warning(f"SCAN RESULT: MALWARE FOUND - {virus_name}")
|
||||
raise MalwareDetectedError(virus_name=virus_name or "Unknown threat", response=response)
|
||||
if status != "ok":
|
||||
logger.error(f"SCAN RESULT: ERROR - Unexpected response: {response}")
|
||||
raise ClamAVError(f"Unexpected ClamAV response: {response}")
|
||||
|
||||
logger.info("SCAN RESULT: CLEAN - Document is safe.")
|
||||
|
||||
|
||||
def scan_file_for_malware(file_obj: BinaryIO) -> None:
|
||||
"""
|
||||
Scan a file-like object using ClamAV INSTREAM and rewind it for later reads.
|
||||
"""
|
||||
chunk_size = max(1, int(settings.CLAMAV_CHUNK_SIZE))
|
||||
|
||||
can_seek = hasattr(file_obj, "seek")
|
||||
if can_seek:
|
||||
file_obj.seek(0)
|
||||
|
||||
try:
|
||||
def iter_chunks():
|
||||
while True:
|
||||
chunk = file_obj.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
_scan_chunks(iter_chunks())
|
||||
finally:
|
||||
if can_seek:
|
||||
file_obj.seek(0)
|
||||
|
||||
|
||||
def scan_bytes_for_malware(payload: bytes) -> None:
|
||||
"""Scan an in-memory payload using ClamAV."""
|
||||
scan_file_for_malware(io.BytesIO(payload))
|
||||
@@ -0,0 +1,4 @@
|
||||
from contextvars import ContextVar
|
||||
|
||||
client_ip_ctx_var: ContextVar[str] = ContextVar("client_ip", default=None)
|
||||
user_agent_ctx_var: ContextVar[str] = ContextVar("user_agent", default=None)
|
||||
@@ -0,0 +1,35 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from app.core.settings import settings
|
||||
|
||||
def _get_key() -> bytes:
|
||||
"""Derive a consistent 32-byte key from APP_SECRET for AES-256."""
|
||||
return hashlib.sha256(settings.APP_SECRET.encode('utf-8')).digest()
|
||||
|
||||
def encrypt_data(data: str) -> str:
|
||||
"""Encrypts a string using AES-256-GCM and returns a base64 encoded string."""
|
||||
if not data:
|
||||
return data
|
||||
aesgcm = AESGCM(_get_key())
|
||||
nonce = os.urandom(12)
|
||||
ciphertext = aesgcm.encrypt(nonce, data.encode('utf-8'), None)
|
||||
combined = nonce + ciphertext
|
||||
return base64.b64encode(combined).decode('utf-8')
|
||||
|
||||
def decrypt_data(token: str) -> str:
|
||||
"""Decrypts a base64 encoded AES-256-GCM string."""
|
||||
if not token:
|
||||
return token
|
||||
try:
|
||||
combined = base64.b64decode(token.encode('utf-8'))
|
||||
if len(combined) < 12:
|
||||
return ""
|
||||
nonce = combined[:12]
|
||||
ciphertext = combined[12:]
|
||||
aesgcm = AESGCM(_get_key())
|
||||
decrypted = aesgcm.decrypt(nonce, ciphertext, None)
|
||||
return decrypted.decode('utf-8')
|
||||
except Exception:
|
||||
return ""
|
||||
@@ -0,0 +1,250 @@
|
||||
import smtplib
|
||||
import logging
|
||||
import os
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.base import MIMEBase
|
||||
from email import encoders
|
||||
from typing import Optional, Dict, Any, List
|
||||
from uuid import UUID
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.settings import settings
|
||||
from app.core.crypto import decrypt_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
template_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "templates", "emails"
|
||||
)
|
||||
try:
|
||||
jinja_env = Environment(loader=FileSystemLoader(template_dir))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize Jinja template environment: {e}")
|
||||
jinja_env = None
|
||||
|
||||
|
||||
def send_email(
|
||||
subject: str,
|
||||
recipient: str,
|
||||
body: Optional[str] = None,
|
||||
html_body: Optional[str] = None,
|
||||
template_name: Optional[str] = None,
|
||||
template_context: Optional[Dict[str, Any]] = None,
|
||||
db: Optional[Session] = None,
|
||||
tenant_id: Optional[UUID] = None,
|
||||
user_id: Optional[int] = None,
|
||||
attachments: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
"""
|
||||
Sends an email using the configured SMTP settings.
|
||||
In development, if SMTP is not configured, it logs the email.
|
||||
"""
|
||||
if template_name and template_context is not None and jinja_env:
|
||||
try:
|
||||
template = jinja_env.get_template(template_name)
|
||||
html_body = template.render(**template_context)
|
||||
if not body:
|
||||
body = f"Please view this email in an HTML-compatible client. Subject: {subject}"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to render email template {template_name}: {e}")
|
||||
return
|
||||
elif template_name and not jinja_env:
|
||||
logger.error("Jinja2 environment not initialized. Cannot render template '%s'", template_name)
|
||||
return
|
||||
|
||||
if not body and not html_body:
|
||||
logger.error("Email must have either a body or html_body")
|
||||
return
|
||||
|
||||
smtp_config = _resolve_smtp_config(db=db, tenant_id=tenant_id, user_id=user_id)
|
||||
logger.info(
|
||||
"SMTP source selected: %s (tenant_id=%s)",
|
||||
smtp_config.get("source", "unknown"),
|
||||
str(tenant_id) if tenant_id else "none",
|
||||
)
|
||||
|
||||
if not smtp_config["smtp_host"]:
|
||||
return
|
||||
|
||||
try:
|
||||
_send_with_config(
|
||||
smtp_config=smtp_config,
|
||||
subject=subject,
|
||||
recipient=recipient,
|
||||
body=body,
|
||||
html_body=html_body,
|
||||
attachments=attachments,
|
||||
)
|
||||
logger.info(
|
||||
"Email sent successfully to %s via %s SMTP",
|
||||
recipient,
|
||||
smtp_config.get("source", "unknown"),
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {recipient} via {smtp_config.get('source', 'unknown')} SMTP: {e}")
|
||||
|
||||
fallback_config = None
|
||||
if smtp_config.get("source") == "user":
|
||||
fallback_config = _resolve_smtp_config(db=db, tenant_id=tenant_id, user_id=None)
|
||||
elif smtp_config.get("source") == "tenant":
|
||||
fallback_config = _resolve_smtp_config(db=None, tenant_id=None, user_id=None)
|
||||
|
||||
if fallback_config and fallback_config.get("smtp_host"):
|
||||
logger.warning(
|
||||
"Retrying email delivery via fallback SMTP after %s SMTP failure (tenant_id=%s)",
|
||||
smtp_config.get("source", "unknown"),
|
||||
str(tenant_id) if tenant_id else "none",
|
||||
)
|
||||
try:
|
||||
_send_with_config(
|
||||
smtp_config=fallback_config,
|
||||
subject=subject,
|
||||
recipient=recipient,
|
||||
body=body,
|
||||
html_body=html_body,
|
||||
attachments=attachments,
|
||||
)
|
||||
logger.info(
|
||||
"Email sent successfully to %s via fallback SMTP after %s SMTP failure",
|
||||
recipient,
|
||||
smtp_config.get("source", "unknown"),
|
||||
)
|
||||
return
|
||||
except Exception as fallback_error:
|
||||
logger.error(
|
||||
"Fallback SMTP also failed for %s: %s",
|
||||
recipient,
|
||||
fallback_error,
|
||||
)
|
||||
|
||||
|
||||
def _send_with_config(
|
||||
smtp_config: Dict[str, Any],
|
||||
subject: str,
|
||||
recipient: str,
|
||||
body: Optional[str],
|
||||
html_body: Optional[str],
|
||||
attachments: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
msg = MIMEMultipart()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = smtp_config["mail_from"]
|
||||
msg["To"] = recipient
|
||||
|
||||
if body or html_body:
|
||||
content_part = MIMEMultipart("alternative")
|
||||
if body:
|
||||
content_part.attach(MIMEText(body, "plain"))
|
||||
if html_body:
|
||||
content_part.attach(MIMEText(html_body, "html"))
|
||||
msg.attach(content_part)
|
||||
|
||||
if attachments:
|
||||
for attachment in attachments:
|
||||
part = MIMEBase("application", "octet-stream")
|
||||
part.set_payload(attachment["content"])
|
||||
encoders.encode_base64(part)
|
||||
part.add_header(
|
||||
"Content-Disposition",
|
||||
f"attachment; filename={attachment['filename']}",
|
||||
)
|
||||
msg.attach(part)
|
||||
|
||||
if smtp_config["smtp_secure"] or smtp_config["smtp_port"] == 465:
|
||||
with smtplib.SMTP_SSL(smtp_config["smtp_host"], smtp_config["smtp_port"]) as server:
|
||||
if smtp_config["smtp_user"] and smtp_config["smtp_password"]:
|
||||
server.login(smtp_config["smtp_user"], smtp_config["smtp_password"])
|
||||
server.send_message(msg)
|
||||
else:
|
||||
with smtplib.SMTP(smtp_config["smtp_host"], smtp_config["smtp_port"]) as server:
|
||||
if smtp_config["smtp_user"] and smtp_config["smtp_password"]:
|
||||
server.starttls()
|
||||
server.login(smtp_config["smtp_user"], smtp_config["smtp_password"])
|
||||
server.send_message(msg)
|
||||
|
||||
|
||||
def _resolve_smtp_config(
|
||||
db: Optional[Session] = None,
|
||||
tenant_id: Optional[UUID] = None,
|
||||
user_id: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
fallback = {
|
||||
"source": "fallback",
|
||||
"smtp_host": settings.SMTP_HOST,
|
||||
"smtp_port": settings.SMTP_PORT or 587,
|
||||
"smtp_user": settings.SMTP_USER,
|
||||
"smtp_password": settings.SMTP_PASSWORD,
|
||||
"smtp_secure": settings.SMTP_SECURE,
|
||||
"mail_from": settings.MAIL_FROM,
|
||||
}
|
||||
|
||||
close_db = False
|
||||
if db is None and (tenant_id is not None or user_id is not None):
|
||||
try:
|
||||
from app.db.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
close_db = True
|
||||
except Exception as err:
|
||||
logger.error("Failed to create temporary DB session for SMTP resolution: %s", err)
|
||||
|
||||
try:
|
||||
base_config = fallback
|
||||
|
||||
if db is not None and tenant_id is not None:
|
||||
try:
|
||||
from app.modules.tenant.repositories.tenant_smtp_repository import TenantSMTPRepository
|
||||
|
||||
cfg = TenantSMTPRepository(db).get_by_tenant_id(tenant_id)
|
||||
if cfg and cfg.is_active:
|
||||
base_config = {
|
||||
"source": "tenant",
|
||||
"smtp_host": cfg.smtp_host,
|
||||
"smtp_port": cfg.smtp_port,
|
||||
"smtp_user": cfg.smtp_user,
|
||||
"smtp_password": (
|
||||
decrypt_data(cfg.encrypted_smtp_password)
|
||||
if cfg.encrypted_smtp_password
|
||||
else None
|
||||
),
|
||||
"smtp_secure": cfg.smtp_secure,
|
||||
"mail_from": cfg.mail_from,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"SMTP config lookup failed for tenant_id=%s, using fallback SMTP: %s",
|
||||
tenant_id,
|
||||
e,
|
||||
)
|
||||
|
||||
if db is None or user_id is None:
|
||||
return base_config
|
||||
|
||||
try:
|
||||
from app.modules.auth.models.user_model import User
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or not user.smtp_user or not user.encrypted_smtp_password:
|
||||
return base_config
|
||||
|
||||
return {
|
||||
"source": "user",
|
||||
"smtp_host": base_config["smtp_host"],
|
||||
"smtp_port": base_config["smtp_port"],
|
||||
"smtp_user": user.smtp_user,
|
||||
"smtp_password": decrypt_data(user.encrypted_smtp_password),
|
||||
"smtp_secure": base_config["smtp_secure"],
|
||||
"mail_from": user.mail_from or base_config["mail_from"],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"User SMTP credential lookup failed for user_id=%s, using base SMTP: %s",
|
||||
user_id,
|
||||
e,
|
||||
)
|
||||
return base_config
|
||||
finally:
|
||||
if close_db and db is not None:
|
||||
db.close()
|
||||
@@ -0,0 +1,70 @@
|
||||
import logging
|
||||
import torch
|
||||
|
||||
logger = logging.getLogger("AI_Model_Manager")
|
||||
|
||||
|
||||
class ModelManager:
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(ModelManager, cls).__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def load_models(self):
|
||||
|
||||
if ModelManager._initialized:
|
||||
return
|
||||
|
||||
logger.info("🧠 Lazy loading AI models...")
|
||||
|
||||
torch.set_num_threads(2)
|
||||
|
||||
from marker.converters.pdf import PdfConverter
|
||||
from marker.models import create_model_dict
|
||||
|
||||
self.model_cache = create_model_dict()
|
||||
|
||||
config = {
|
||||
"use_llm": False,
|
||||
"extract_images": True,
|
||||
"extract_tables": True,
|
||||
"extract_equations": True,
|
||||
"extract_layout": True,
|
||||
}
|
||||
|
||||
self.converter = PdfConverter(
|
||||
artifact_dict=self.model_cache,
|
||||
config=config
|
||||
)
|
||||
|
||||
ModelManager._initialized = True
|
||||
logger.info("✅ Models ready")
|
||||
|
||||
def process_document(self, file_path):
|
||||
|
||||
self.load_models()
|
||||
|
||||
ext = file_path.lower()
|
||||
|
||||
if ext.endswith(".pdf"):
|
||||
return self.converter(file_path)
|
||||
|
||||
if ext.endswith((".jpg", ".jpeg", ".png")):
|
||||
from surya.ocr import run_ocr
|
||||
return run_ocr(file_path)
|
||||
|
||||
if ext.endswith(".docx"):
|
||||
from docx import Document
|
||||
doc = Document(file_path)
|
||||
return {"markdown": "\n".join(p.text for p in doc.paragraphs)}
|
||||
|
||||
raise ValueError("Unsupported file")
|
||||
|
||||
model_manager = ModelManager()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
path_validation.py — server-side file path and filename sanitization utilities.
|
||||
|
||||
These helpers prevent path traversal attacks (C-017 / H-038) by rejecting
|
||||
dangerous patterns before paths are stored in the DB or used in file I/O.
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TRAVERSAL_PATTERN = re.compile(
|
||||
r"""
|
||||
\.\. # double-dot (parent directory traversal)
|
||||
| \x00 # null byte
|
||||
| [<>:"|?*] # Windows-forbidden characters
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
_SEPARATOR_PATTERN = re.compile(r"[/\\]")
|
||||
|
||||
MAX_PATH_LENGTH = 512
|
||||
MAX_FILENAME_LENGTH = 255
|
||||
|
||||
|
||||
def validate_storage_path(path: str) -> str:
|
||||
"""
|
||||
Validate a storage key / relative path before writing it to the DB or
|
||||
using it in an S3 / B2 operation.
|
||||
|
||||
Accepts:
|
||||
- B2 / S3 object keys (e.g. "chatbot/42/abc123.pdf")
|
||||
- Internal sentinel URIs (e.g. "drive://123")
|
||||
|
||||
Raises:
|
||||
ValueError – with a human-readable reason on rejection.
|
||||
|
||||
Returns:
|
||||
The original `path` unchanged if it passes all checks.
|
||||
"""
|
||||
if not path:
|
||||
raise ValueError("File path must not be empty.")
|
||||
|
||||
if len(path) > MAX_PATH_LENGTH:
|
||||
raise ValueError(
|
||||
f"File path exceeds the maximum allowed length "
|
||||
f"({len(path)} > {MAX_PATH_LENGTH})."
|
||||
)
|
||||
|
||||
if path.startswith("drive://"):
|
||||
return path
|
||||
|
||||
if _TRAVERSAL_PATTERN.search(path):
|
||||
logger.warning("Rejected dangerous storage path: %r", path)
|
||||
raise ValueError("File path contains disallowed characters or sequences.")
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def validate_filename(filename: str) -> str:
|
||||
"""
|
||||
Validate an upload filename before storing it or using it on disk.
|
||||
|
||||
Raises:
|
||||
ValueError – with a human-readable reason on rejection.
|
||||
|
||||
Returns:
|
||||
The original `filename` unchanged if it passes all checks.
|
||||
"""
|
||||
if not filename:
|
||||
raise ValueError("Filename must not be empty.")
|
||||
|
||||
if len(filename) > MAX_FILENAME_LENGTH:
|
||||
raise ValueError(
|
||||
f"Filename exceeds the maximum allowed length "
|
||||
f"({len(filename)} > {MAX_FILENAME_LENGTH})."
|
||||
)
|
||||
|
||||
if _SEPARATOR_PATTERN.search(filename):
|
||||
logger.warning("Rejected filename containing path separator: %r", filename)
|
||||
raise ValueError("Filename must not contain path separators.")
|
||||
|
||||
if _TRAVERSAL_PATTERN.search(filename):
|
||||
logger.warning("Rejected dangerous filename: %r", filename)
|
||||
raise ValueError("Filename contains disallowed characters or sequences.")
|
||||
|
||||
return filename
|
||||
@@ -0,0 +1,17 @@
|
||||
import redis
|
||||
import json
|
||||
from app.core.settings import settings
|
||||
|
||||
redis_client = redis.from_url(
|
||||
settings.REDIS_URL or "redis://localhost:6379/0",
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
def publish_progress(task_id, progress, message):
|
||||
redis_client.publish(
|
||||
f"task:{task_id}",
|
||||
json.dumps({
|
||||
"progress": progress,
|
||||
"message": message
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
import asyncio
|
||||
import json
|
||||
import redis
|
||||
import logging
|
||||
from app.infrastructure.realtime.connection_manager import manager
|
||||
from app.core.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def start_listener():
|
||||
redis_client = redis.from_url(
|
||||
settings.REDIS_URL or "redis://localhost:6379/0",
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
pubsub = redis_client.pubsub()
|
||||
pubsub.psubscribe("task:*")
|
||||
|
||||
logger.info("📡 Redis Pub/Sub listener started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
message = await asyncio.get_event_loop().run_in_executor(None, pubsub.get_message, False, 1.0)
|
||||
|
||||
if message and message["type"] == "pmessage":
|
||||
parts = message["channel"].split(":")
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
task_id = parts[1]
|
||||
try:
|
||||
data = json.loads(message["data"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.error(f"❌ Received malformed JSON on channel {message['channel']}")
|
||||
continue
|
||||
|
||||
await manager.send_task(task_id, data)
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Pub/Sub listener: {e}")
|
||||
await asyncio.sleep(1)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Async Redis Pub/Sub client for real-time collaboration messaging.
|
||||
|
||||
This module is SEPARATE from app/db/redis.py (sync cache client).
|
||||
It uses redis.asyncio for non-blocking publish/subscribe operations
|
||||
intended for WebSocket fan-out across backend workers.
|
||||
|
||||
Usage:
|
||||
from app.core.redis import redis_pubsub
|
||||
|
||||
# Publish a message to a file's chat channel
|
||||
await redis_pubsub.publish(file_id=42, message={...})
|
||||
|
||||
# Subscribe and iterate over incoming messages
|
||||
async for msg in redis_pubsub.subscribe(file_id=42):
|
||||
await websocket.send_json(msg)
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncIterator, Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.core.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHANNEL_PREFIX = "chat:file"
|
||||
|
||||
|
||||
def _channel(file_id: int) -> str:
|
||||
"""Build the canonical Redis channel name for a file's chat room."""
|
||||
return f"{CHANNEL_PREFIX}:{file_id}"
|
||||
|
||||
|
||||
class RedisPubSub:
|
||||
"""
|
||||
Lightweight async wrapper around redis.asyncio for chat pub/sub.
|
||||
|
||||
• Lazily creates a shared connection pool on first use.
|
||||
• publish() and subscribe() are the only public API.
|
||||
• Graceful degradation: if Redis is unreachable, publish is a no-op
|
||||
and subscribe yields nothing (the app stays up).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pool: Optional[aioredis.ConnectionPool] = None
|
||||
self._client: Optional[aioredis.Redis] = None
|
||||
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Create the shared async connection pool (idempotent)."""
|
||||
if self._client is not None:
|
||||
return
|
||||
try:
|
||||
url = settings.REDIS_URL or "redis://localhost:6379/0"
|
||||
self._pool = aioredis.ConnectionPool.from_url(
|
||||
url, decode_responses=True
|
||||
)
|
||||
self._client = aioredis.Redis(connection_pool=self._pool)
|
||||
await self._client.ping()
|
||||
logger.info("✅ Async Redis Pub/Sub connected")
|
||||
except Exception as e:
|
||||
logger.warning(f"Async Redis Pub/Sub unavailable: {e}")
|
||||
self._client = None
|
||||
self._pool = None
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Tear down the pool (call on app shutdown)."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
if self._pool:
|
||||
await self._pool.disconnect()
|
||||
self._pool = None
|
||||
logger.info("Async Redis Pub/Sub disconnected")
|
||||
|
||||
|
||||
async def publish(self, file_id: int, message: dict[str, Any]) -> None:
|
||||
"""
|
||||
Publish a JSON message to ``chat:file:{file_id}``.
|
||||
|
||||
No-op if Redis is not connected.
|
||||
"""
|
||||
if not self._client:
|
||||
await self.connect()
|
||||
if not self._client:
|
||||
return
|
||||
try:
|
||||
channel = _channel(file_id)
|
||||
payload = json.dumps(message, default=str)
|
||||
await self._client.publish(channel, payload)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis publish error: {e}")
|
||||
|
||||
async def subscribe(self, file_id: int) -> AsyncIterator[dict[str, Any]]:
|
||||
"""
|
||||
Subscribe to ``chat:file:{file_id}`` and yield parsed messages.
|
||||
|
||||
Intended to be consumed inside a WebSocket handler::
|
||||
|
||||
async for msg in redis_pubsub.subscribe(file_id):
|
||||
await ws.send_json(msg)
|
||||
|
||||
Yields nothing (returns immediately) if Redis is not available.
|
||||
"""
|
||||
if not self._client:
|
||||
await self.connect()
|
||||
if not self._client:
|
||||
return
|
||||
|
||||
channel = _channel(file_id)
|
||||
pubsub = self._client.pubsub()
|
||||
|
||||
try:
|
||||
await pubsub.subscribe(channel)
|
||||
logger.debug(f"Subscribed to {channel}")
|
||||
|
||||
async for raw in pubsub.listen():
|
||||
if raw["type"] != "message":
|
||||
continue
|
||||
try:
|
||||
yield json.loads(raw["data"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning(f"Bad payload on {channel}: {raw['data']}")
|
||||
except Exception as e:
|
||||
logger.error(f"Redis subscribe error on {channel}: {e}")
|
||||
finally:
|
||||
try:
|
||||
await pubsub.unsubscribe(channel)
|
||||
await pubsub.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
redis_pubsub = RedisPubSub()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Reading a JSON request body without turning the caller's mistake into our fault.
|
||||
|
||||
`await request.json()` raises `JSONDecodeError` on a malformed body. Nothing in
|
||||
these routes caught it, so it fell through to the generic handler and became a
|
||||
**500**. That is wrong in three separate ways:
|
||||
|
||||
- it reports a client error as a server fault, so it pages whoever is on call
|
||||
and buries genuine failures in noise;
|
||||
- on the webhook endpoints it tells the sender to retry, because 5xx means
|
||||
"try again later" and 4xx means "your request is wrong" — a malformed
|
||||
DocuSeal payload would be redelivered on a schedule, forever;
|
||||
- `POST /api/sso/login` is unauthenticated, so anyone could produce a 500 with
|
||||
a one-byte body.
|
||||
|
||||
The discovery harness found this by sending a form body to a JSON endpoint —
|
||||
which is exactly what a misconfigured integration does on its first day.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
|
||||
async def read_json_body(request: Request, *, what: str = "request body") -> dict:
|
||||
"""
|
||||
Parse the body as a JSON object, or raise 400.
|
||||
|
||||
Returns a `dict` specifically: every caller immediately does `.get(...)`, so
|
||||
a bare list or string would fail with `AttributeError` a line later and land
|
||||
back in the 500 handler this exists to avoid.
|
||||
"""
|
||||
raw = await request.body()
|
||||
if not raw:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Expected a JSON {what}, but it was empty.",
|
||||
)
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"The {what} is not valid JSON.",
|
||||
) from None
|
||||
if not isinstance(parsed, dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"The {what} must be a JSON object.",
|
||||
)
|
||||
return parsed
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
A memo that lives exactly as long as one request.
|
||||
|
||||
Access resolution is asked the same question many times in a single request.
|
||||
`require_access` runs once per dependency — several times on a route that
|
||||
declares more than one — `get_current_user` now resolves the caller's codes so
|
||||
the response can report them, and `/api/admin/users` resolves assignments for a
|
||||
page of users. Without a memo each of those repeats the same three queries, one
|
||||
of which reads the whole `accesses` table.
|
||||
|
||||
Modelled on `app/core/scope.py`, deliberately, and for the same reason: a
|
||||
`ContextVar` reset once per request is the only cache shape that cannot outlive
|
||||
the authority it caches. A process-wide cache of "what may this user do" is the
|
||||
bug where revoking a role takes effect after a restart.
|
||||
|
||||
**Default of `None` means "not in a request".** Celery tasks, scripts and any
|
||||
code path that never passes through `TenantContextMiddleware` compute every
|
||||
time. That is correct rather than merely safe — a long-running task must not
|
||||
pin a permission decision made at its start.
|
||||
|
||||
The trap worth naming, because it is the same one `ScopeService` documents: a
|
||||
grant that expires *mid-request* stays honoured to the end of that request.
|
||||
Permissions must not change under a half-finished operation, so this is
|
||||
intended. Do not "fix" it by shortening the cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
_cache: ContextVar[Optional[dict]] = ContextVar("request_cache", default=None)
|
||||
|
||||
|
||||
def reset_request_cache() -> None:
|
||||
"""Drop everything memoised. Called once per request, before routing."""
|
||||
_cache.set({})
|
||||
|
||||
|
||||
def request_cached(key: str, compute: Callable[[], Any]) -> Any:
|
||||
"""
|
||||
Return `compute()`, memoised under *key* for the rest of this request.
|
||||
|
||||
Outside a request the memo is absent and `compute` runs every time.
|
||||
"""
|
||||
store = _cache.get()
|
||||
if store is None:
|
||||
return compute()
|
||||
if key not in store:
|
||||
store[key] = compute()
|
||||
return store[key]
|
||||
|
||||
|
||||
def invalidate_request_cache(prefix: str) -> None:
|
||||
"""
|
||||
Forget every memoised key starting with *prefix*.
|
||||
|
||||
Needed because a request may **change** the thing it cached. Granting a role
|
||||
and then returning the user's roles in the same response would otherwise
|
||||
serve the pre-grant answer, which reads as "the grant did not work".
|
||||
"""
|
||||
store = _cache.get()
|
||||
if not store:
|
||||
return
|
||||
for key in [k for k in store if k.startswith(prefix)]:
|
||||
del store[key]
|
||||
|
||||
|
||||
__all__ = ["reset_request_cache", "request_cached", "invalidate_request_cache"]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
B1.3 — hand the tenant context to PostgreSQL.
|
||||
|
||||
The RLS policy compares each row's `tenant_id` against `docqube.tenant_id`, a
|
||||
session variable. Something has to set it, and it has to be set on the same
|
||||
connection the query runs on — which is why this hooks the session rather than
|
||||
the request.
|
||||
|
||||
`set_config(..., true)` scopes the setting to the current **transaction**, so it
|
||||
cannot leak to the next request through a pooled connection. That matters more
|
||||
than it looks: a connection-scoped setting left behind by one tenant would be
|
||||
inherited by whoever picked the connection up next.
|
||||
|
||||
Off by the same switch as the ORM listener. The policy itself is inert without a
|
||||
variable, so an unset switch means RLS is enabled in the database and permissive
|
||||
in practice — which is the safe way round while it is being rolled out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import event, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
from app.core.settings import settings
|
||||
|
||||
return bool(getattr(settings, "TENANT_FILTER_ENABLED", False))
|
||||
|
||||
|
||||
def apply_tenant_context(session: Session) -> None:
|
||||
"""Push the current tenant context onto the session's connection."""
|
||||
from app.core.tenant_context import (
|
||||
current_tenant_id,
|
||||
is_bypassed,
|
||||
is_super_admin,
|
||||
)
|
||||
|
||||
bypass = is_bypassed() or is_super_admin()
|
||||
tenant_id = current_tenant_id()
|
||||
|
||||
session.execute(
|
||||
text("SELECT set_config('docqube.bypass', :v, true)"),
|
||||
{"v": "on" if bypass else "off"},
|
||||
)
|
||||
session.execute(
|
||||
text("SELECT set_config('docqube.tenant_id', :v, true)"),
|
||||
{"v": str(tenant_id) if tenant_id else ""},
|
||||
)
|
||||
|
||||
|
||||
@event.listens_for(Session, "after_begin")
|
||||
def _set_tenant_on_begin(session: Session, transaction: Any, connection: Any) -> None:
|
||||
if not _enabled():
|
||||
return
|
||||
try:
|
||||
from app.core.tenant_context import (
|
||||
current_tenant_id,
|
||||
is_bypassed,
|
||||
is_super_admin,
|
||||
)
|
||||
|
||||
bypass = is_bypassed() or is_super_admin()
|
||||
tenant_id = current_tenant_id()
|
||||
|
||||
connection.execute(
|
||||
text("SELECT set_config('docqube.bypass', :v, true)"),
|
||||
{"v": "on" if bypass else "off"},
|
||||
)
|
||||
connection.execute(
|
||||
text("SELECT set_config('docqube.tenant_id', :v, true)"),
|
||||
{"v": str(tenant_id) if tenant_id else ""},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - never break a request over this
|
||||
logger.exception("Failed to apply tenant context to the connection")
|
||||
@@ -0,0 +1,78 @@
|
||||
from bs4 import BeautifulSoup
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_TAGS = [
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'p', 'br', 'hr', 'pre', 'code', 'blockquote',
|
||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
|
||||
'b', 'strong', 'i', 'em', 'u', 's', 'small', 'sub', 'sup',
|
||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col',
|
||||
'div', 'span', 'section', 'article', 'aside', 'header', 'footer',
|
||||
'img', 'a', 'figure', 'figcaption',
|
||||
'math', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'msubsup', 'mfrac', 'msqrt', 'mroot', 'mover', 'munder', 'munderover', 'mtd', 'mtr', 'mtable', 'mstyle', 'merror', 'mpadded', 'mphantom', 'mfenced', 'menclose'
|
||||
]
|
||||
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
'*': ['id', 'class', 'title', 'lang'],
|
||||
'a': ['href', 'target', 'rel', 'name'],
|
||||
'img': ['src', 'alt', 'width', 'height', 'loading'],
|
||||
'table': ['border', 'cellpadding', 'cellspacing', 'frame', 'rules', 'summary', 'width'],
|
||||
'th': ['align', 'valign', 'colspan', 'rowspan', 'scope', 'width'],
|
||||
'td': ['align', 'valign', 'colspan', 'rowspan', 'width'],
|
||||
'col': ['span', 'width'],
|
||||
'colgroup': ['span', 'width'],
|
||||
'section': ['sec-type'],
|
||||
'div': ['id', 'class'],
|
||||
'span': ['id', 'class'],
|
||||
'math': ['display', 'xmlns'],
|
||||
'mtd': ['columnspan', 'rowspan', 'align'],
|
||||
'mtr': ['rowalign'],
|
||||
'mtable': ['equalrows', 'equalcolumns', 'displaystyle'],
|
||||
}
|
||||
|
||||
def sanitize_html(html_content: str) -> str:
|
||||
"""
|
||||
Sanitize HTML content using BeautifulSoup4.
|
||||
Removes potentially dangerous tags (script, iframe, object, etc.) and attributes.
|
||||
"""
|
||||
if not html_content:
|
||||
return ""
|
||||
|
||||
try:
|
||||
soup = BeautifulSoup(html_content, 'lxml')
|
||||
|
||||
for tag in soup.findAll():
|
||||
if tag.name.lower() not in ALLOWED_TAGS:
|
||||
if tag.name.lower() in ['script', 'style', 'iframe', 'object', 'embed', 'applet', 'link']:
|
||||
tag.decompose()
|
||||
else:
|
||||
tag.unwrap()
|
||||
|
||||
for tag in soup.findAll():
|
||||
allowed_attrs = ALLOWED_ATTRIBUTES.get(tag.name.lower(), [])
|
||||
global_attrs = ALLOWED_ATTRIBUTES.get('*', [])
|
||||
|
||||
attrs_to_remove = [attr for attr in tag.attrs if attr.lower() not in allowed_attrs and attr.lower() not in global_attrs]
|
||||
|
||||
for attr in ['href', 'src']:
|
||||
if attr in tag.attrs:
|
||||
val = tag[attr].lower().strip()
|
||||
if val.startswith('javascript:') or val.startswith('data:text/html') or val.startswith('vbscript:'):
|
||||
attrs_to_remove.append(attr)
|
||||
|
||||
for attr in list(tag.attrs.keys()):
|
||||
if attr.lower().startswith('on'):
|
||||
attrs_to_remove.append(attr)
|
||||
|
||||
for attr in attrs_to_remove:
|
||||
del tag[attr]
|
||||
|
||||
if soup.body:
|
||||
return "".join([str(c) for c in soup.body.contents])
|
||||
return str(soup)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"HTML Sanitization failed: {e}")
|
||||
return "Sanitization Error - Access Denied"
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Shared response envelopes.
|
||||
|
||||
DocQube's mutations return a small number of repeated shapes — `{"message": …}`,
|
||||
`{"status": …}`, `{"success": …}` and a couple of counters. Declaring them once
|
||||
means the generated frontend client sees one type per shape instead of a
|
||||
hundred anonymous inline objects.
|
||||
|
||||
Every model here matches a response observed in
|
||||
`tests/characterization/discovered_mutation_shapes.json`. None of them is a
|
||||
guess, and none carries an optional field the endpoint does not return — a
|
||||
declared field is *added* as null rather than omitted, so a wider model changes
|
||||
the payload just as surely as a narrower one drops it.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
"""`{"message": "..."}` — the most common acknowledgement in the codebase."""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
class StatusOut(BaseModel):
|
||||
"""`{"status": "ok"}` / `{"status": "success"}`."""
|
||||
|
||||
status: str
|
||||
|
||||
|
||||
class StatusMessageOut(BaseModel):
|
||||
"""`{"status": "...", "message": "..."}`."""
|
||||
|
||||
status: str
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class SuccessOut(BaseModel):
|
||||
"""`{"success": true}`."""
|
||||
|
||||
success: bool
|
||||
|
||||
|
||||
class DeletedCountOut(BaseModel):
|
||||
"""`{"deleted": n}` — bulk delete."""
|
||||
|
||||
deleted: int
|
||||
|
||||
|
||||
class MarkedCountOut(BaseModel):
|
||||
"""`{"marked": n}` — bulk mark-as-read."""
|
||||
|
||||
marked: int
|
||||
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
C4 — where does this user hold this permission?
|
||||
|
||||
Ported from `docqube_platform_backend/app/services/scope_service.py`, adapted to
|
||||
resolve through DocQube's `RoleAccess`/`Access` rather than the base's
|
||||
`RolePermission`/`Permission`.
|
||||
|
||||
The whole design rests on one separation, and it is worth stating plainly
|
||||
because it is what makes the rollout incremental:
|
||||
|
||||
- `require_access("code")` stays exactly as it is — the **coarse** gate,
|
||||
answering "do you hold this code at all". Every one of the 227 endpoints
|
||||
keeps working, unchanged, today and after.
|
||||
- `ScopeService.authority(user, code)` answers the **scope** question
|
||||
separately, at the data layer, for endpoints that have been converted.
|
||||
|
||||
So this file adds a capability without altering a single existing decision. An
|
||||
endpoint becomes org-aware when someone converts it, one at a time, with a test.
|
||||
|
||||
`authority()` returns one of two things, and the difference is deliberate:
|
||||
|
||||
TENANT_WIDE a sentinel — the user holds this everywhere in their tenant
|
||||
set[UUID] the org units they hold it in, *including descendants*
|
||||
|
||||
It never returns None. A caller must not be able to confuse "no authority" with
|
||||
"authority everywhere" by testing falsiness — an empty set is falsy and
|
||||
`TENANT_WIDE` is truthy, which is the safe way round, but the sentinel is a
|
||||
distinct object so `== TENANT_WIDE` is unambiguous.
|
||||
|
||||
**Subtree inheritance is the point.** Grant a role at *Engineering* and it
|
||||
applies to *Platform* and *Infrastructure* without further assignment. That is a
|
||||
recursive CTE walking `parent_id` **downward**; a join written the other way
|
||||
matches ancestors just as happily and silently promotes every team lead to a
|
||||
department head. `tests/probes/test_org_scope.py` asserts the direction
|
||||
explicitly for exactly that reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
from typing import Union
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
TENANT_WIDE = "TENANT_WIDE"
|
||||
|
||||
Authority = Union[str, set]
|
||||
|
||||
_cache: ContextVar[dict] = ContextVar("scope_cache", default=None)
|
||||
|
||||
|
||||
def reset_scope_cache() -> None:
|
||||
"""Drop any memoised authority. Called once per request."""
|
||||
_cache.set({})
|
||||
|
||||
|
||||
def _cached(key: str, compute):
|
||||
store = _cache.get()
|
||||
if store is None:
|
||||
return compute()
|
||||
if key not in store:
|
||||
store[key] = compute()
|
||||
return store[key]
|
||||
|
||||
|
||||
class ScopeService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
|
||||
def authority(self, user, access_code: str) -> Authority:
|
||||
"""`TENANT_WIDE`, or the set of org units where the user holds *code*."""
|
||||
key = f"{user.id}:{access_code}"
|
||||
return _cached(key, lambda: self._resolve(user, access_code))
|
||||
|
||||
def can_reach(self, user, access_code: str, org_unit_id) -> bool:
|
||||
"""Does the user hold *code* over this particular unit?"""
|
||||
auth = self.authority(user, access_code)
|
||||
if auth == TENANT_WIDE:
|
||||
return True
|
||||
if org_unit_id is None:
|
||||
return False
|
||||
return org_unit_id in auth
|
||||
|
||||
def can_access_user(self, actor, target_user_id: int, access_code: str) -> bool:
|
||||
"""
|
||||
May *actor* act on this user, under this access code?
|
||||
|
||||
Three ways to yes, and the order matters:
|
||||
|
||||
1. the actor holds the code tenant-wide — the state every user is in
|
||||
today, so this is the answer that keeps the application unchanged;
|
||||
2. the target is the actor themselves. Someone scoped to one team must
|
||||
still be able to read their own record, or scoping a user removes
|
||||
their own profile from them;
|
||||
3. the target belongs to a unit the actor has authority over.
|
||||
|
||||
A user in **no** unit is reachable only by (1) or (2). That is
|
||||
deliberate: an unfiled user belongs to the tenant, not to a team, and a
|
||||
manager scoped to one team should not inherit everyone nobody has
|
||||
filed yet.
|
||||
"""
|
||||
auth = self.authority(actor, access_code)
|
||||
if auth == TENANT_WIDE:
|
||||
return True
|
||||
if int(target_user_id) == int(actor.id):
|
||||
return True
|
||||
if not auth:
|
||||
return False
|
||||
|
||||
from app.modules.org.models.org_model import UserOrgUnit
|
||||
|
||||
return (
|
||||
self.db.query(UserOrgUnit.id)
|
||||
.filter(
|
||||
UserOrgUnit.user_id == target_user_id,
|
||||
UserOrgUnit.org_unit_id.in_(auth),
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
def visible_user_ids(self, actor, access_code: str):
|
||||
"""
|
||||
The users *actor* may see, or `None` for "no restriction".
|
||||
|
||||
`None` and `set()` mean opposite things and both are falsy, which is
|
||||
exactly the confusion that makes scoped access fail open. Callers must
|
||||
branch on `is None`, never on truthiness — and there is a probe that
|
||||
fails if a converted endpoint gets it wrong.
|
||||
"""
|
||||
auth = self.authority(actor, access_code)
|
||||
if auth == TENANT_WIDE:
|
||||
return None
|
||||
if not auth:
|
||||
return {int(actor.id)}
|
||||
|
||||
from app.modules.org.models.org_model import UserOrgUnit
|
||||
|
||||
rows = (
|
||||
self.db.query(UserOrgUnit.user_id)
|
||||
.filter(UserOrgUnit.org_unit_id.in_(auth))
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
return {int(r[0]) for r in rows} | {int(actor.id)}
|
||||
|
||||
def can_delegate(self, actor, access_code: str, target_org_unit_id) -> bool:
|
||||
"""
|
||||
May *actor* grant a role that applies at `target_org_unit_id`?
|
||||
|
||||
The escalation this closes: without it, a manager scoped to one team can
|
||||
grant **themselves** a tenant-wide role and become an administrator of
|
||||
the whole tenant. Every scoped-permission system has this hole unless it
|
||||
is closed deliberately, because granting is just another write and looks
|
||||
like one.
|
||||
|
||||
Two rules:
|
||||
|
||||
- **tenant-wide can only be granted by tenant-wide.** `None` means "this
|
||||
role, everywhere", and nobody may hand out more authority than they
|
||||
hold;
|
||||
- a scoped actor may only grant within the subtree they already have
|
||||
authority over.
|
||||
|
||||
Mirrors `_can_delegate` in the base's `user_service.py`.
|
||||
"""
|
||||
auth = self.authority(actor, access_code)
|
||||
if auth == TENANT_WIDE:
|
||||
return True
|
||||
if target_org_unit_id is None:
|
||||
return False
|
||||
return target_org_unit_id in auth
|
||||
|
||||
def visible_unit_ids(self, user, access_code: str):
|
||||
"""
|
||||
`None` when the user's authority is tenant-wide, else the id set.
|
||||
|
||||
Shaped for query building: `None` means "add no predicate", a set means
|
||||
`.filter(Model.org_unit_id.in_(ids))`, and an **empty** set means the
|
||||
user sees nothing — which callers must handle rather than treating as
|
||||
"no filter". That distinction is the whole failure mode of scoped
|
||||
access, so it is expressed in the return type.
|
||||
"""
|
||||
auth = self.authority(user, access_code)
|
||||
return None if auth == TENANT_WIDE else auth
|
||||
|
||||
|
||||
def _group_ids(self, user) -> list:
|
||||
"""
|
||||
The groups this user belongs to, within their own tenant.
|
||||
|
||||
Filtered by tenant on **both** sides. A membership row naming another
|
||||
tenant's group must confer nothing, and `user_access_groups` carries its
|
||||
own `tenant_id` precisely so that a forged or stale row cannot reach
|
||||
across.
|
||||
"""
|
||||
from app.modules.org.models.group_model import AccessGroup, UserAccessGroup
|
||||
|
||||
rows = (
|
||||
self.db.query(UserAccessGroup.group_id)
|
||||
.join(AccessGroup, AccessGroup.id == UserAccessGroup.group_id)
|
||||
.filter(
|
||||
UserAccessGroup.user_id == user.id,
|
||||
UserAccessGroup.tenant_id == user.tenant_id,
|
||||
AccessGroup.tenant_id == user.tenant_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [r[0] for r in rows]
|
||||
|
||||
def _resolve(self, user, access_code: str) -> Authority:
|
||||
from app.modules.auth.models.access_model import Access
|
||||
from app.modules.auth.models.role_access_model import RoleAccess
|
||||
from app.modules.auth.models.role_model import Role
|
||||
from app.modules.auth.models.user_role_model import UserRole
|
||||
|
||||
rows = (
|
||||
self.db.query(UserRole.org_unit_id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.join(RoleAccess, RoleAccess.role_id == Role.id)
|
||||
.join(Access, Access.id == RoleAccess.access_id)
|
||||
.filter(
|
||||
or_(
|
||||
UserRole.user_id == user.id,
|
||||
UserRole.group_id.in_(self._group_ids(user)),
|
||||
),
|
||||
Access.access_code == access_code,
|
||||
or_(
|
||||
Role.tenant_id == user.tenant_id,
|
||||
Role.tenant_id.is_(None),
|
||||
),
|
||||
or_(
|
||||
UserRole.expires_at.is_(None),
|
||||
UserRole.expires_at > func.now(),
|
||||
),
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
|
||||
if user.role_id and self._legacy_role_grants(user, access_code):
|
||||
return TENANT_WIDE
|
||||
|
||||
if not rows:
|
||||
return set()
|
||||
|
||||
anchors: set[uuid.UUID] = set()
|
||||
for (org_unit_id,) in rows:
|
||||
if org_unit_id is None:
|
||||
return TENANT_WIDE
|
||||
anchors.add(org_unit_id)
|
||||
|
||||
return self._subtree_ids(user.tenant_id, anchors)
|
||||
|
||||
def _legacy_role_grants(self, user, access_code: str) -> bool:
|
||||
from app.modules.auth.models.access_model import Access
|
||||
from app.modules.auth.models.role_access_model import RoleAccess
|
||||
|
||||
return (
|
||||
self.db.query(RoleAccess.role_id)
|
||||
.join(Access, Access.id == RoleAccess.access_id)
|
||||
.filter(
|
||||
RoleAccess.role_id == user.role_id,
|
||||
Access.access_code == access_code,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
def _subtree_ids(self, tenant_id, root_ids: set) -> set:
|
||||
"""
|
||||
Every live unit at or beneath *root_ids*, within this tenant.
|
||||
|
||||
Both filters matter. `tenant_id` means a grant naming another tenant's
|
||||
unit resolves to nothing rather than reaching into it — defence against
|
||||
a forged or stale `org_unit_id`. `NOT is_deleted` means a removed unit
|
||||
stops conferring access immediately, including for the branch beneath
|
||||
it, because the walk cannot pass *through* a deleted node.
|
||||
"""
|
||||
from app.modules.org.models.org_model import OrgUnit
|
||||
|
||||
if not root_ids:
|
||||
return set()
|
||||
|
||||
anchors = (
|
||||
self.db.query(OrgUnit.id, OrgUnit.path)
|
||||
.filter(
|
||||
OrgUnit.id.in_(root_ids),
|
||||
OrgUnit.tenant_id == tenant_id,
|
||||
OrgUnit.is_deleted.is_(False),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if anchors and all(path for _, path in anchors):
|
||||
found: set = set()
|
||||
for _, path in anchors:
|
||||
rows = (
|
||||
self.db.query(OrgUnit.id)
|
||||
.filter(
|
||||
OrgUnit.tenant_id == tenant_id,
|
||||
OrgUnit.is_deleted.is_(False),
|
||||
OrgUnit.path.like(f"{path}%"),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
found |= {r[0] for r in rows}
|
||||
return found
|
||||
|
||||
anchor = (
|
||||
select(OrgUnit.id.label("id"))
|
||||
.where(
|
||||
OrgUnit.id.in_(root_ids),
|
||||
OrgUnit.tenant_id == tenant_id,
|
||||
OrgUnit.is_deleted.is_(False),
|
||||
)
|
||||
.cte("org_subtree", recursive=True)
|
||||
)
|
||||
|
||||
descendants = select(OrgUnit.id).where(
|
||||
OrgUnit.parent_id == anchor.c.id,
|
||||
OrgUnit.tenant_id == tenant_id,
|
||||
OrgUnit.is_deleted.is_(False),
|
||||
)
|
||||
|
||||
tree = anchor.union(descendants)
|
||||
return set(self.db.execute(select(tree.c.id)).scalars().all())
|
||||
|
||||
|
||||
__all__ = ["ScopeService", "TENANT_WIDE", "reset_scope_cache"]
|
||||
@@ -0,0 +1,39 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.settings import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + (
|
||||
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"jti": str(uuid.uuid4())
|
||||
})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.APP_SECRET, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + (
|
||||
expires_delta or timedelta(days=7)
|
||||
)
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"jti": str(uuid.uuid4()),
|
||||
"type": "refresh"
|
||||
})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.APP_SECRET, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def get_password_hash(password: str):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
Central Application Settings
|
||||
Loads environment variables using Pydantic v2
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional, List
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import ConfigDict, model_validator
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
APP_ENV: str
|
||||
|
||||
HOST: str
|
||||
PORT: int
|
||||
DEBUG: bool
|
||||
|
||||
DB_HOST: str
|
||||
ZOHO_CLIENT_ID: str = ""
|
||||
ZOHO_CLIENT_SECRET: str = ""
|
||||
ZOHO_REFRESH_TOKEN: str = ""
|
||||
ZOHO_WEBHOOK_SECRET: str = ""
|
||||
ZOHO_DOMAIN: str = "in"
|
||||
DB_PORT: int
|
||||
DB_NAME: str
|
||||
DB_USER: str
|
||||
DB_PASSWORD: str
|
||||
DB_SSLMODE: str
|
||||
|
||||
PGBOUNCER_PORT: Optional[int] = None
|
||||
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
import urllib.parse
|
||||
user = urllib.parse.quote(self.DB_USER, safe='')
|
||||
password = urllib.parse.quote(self.DB_PASSWORD, safe='')
|
||||
return (
|
||||
f"postgresql+psycopg2://{user}:"
|
||||
f"{password}@"
|
||||
f"{self.DB_HOST}:{self.DB_PORT}/"
|
||||
f"{self.DB_NAME}?sslmode={self.DB_SSLMODE}"
|
||||
)
|
||||
|
||||
@property
|
||||
def PGBOUNCER_URL(self) -> Optional[str]:
|
||||
if not self.PGBOUNCER_PORT:
|
||||
return None
|
||||
import urllib.parse
|
||||
user = urllib.parse.quote(self.DB_USER, safe='')
|
||||
password = urllib.parse.quote(self.DB_PASSWORD, safe='')
|
||||
return (
|
||||
f"postgresql+psycopg2://{user}:"
|
||||
f"{password}@"
|
||||
f"{self.DB_HOST}:{self.PGBOUNCER_PORT}/"
|
||||
f"{self.DB_NAME}?sslmode={self.DB_SSLMODE}"
|
||||
)
|
||||
|
||||
APP_SECRET: str
|
||||
ALGORITHM: str
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int
|
||||
MAX_ACTIVE_DEVICES: int = 3
|
||||
|
||||
REDIS_HOST: Optional[str]
|
||||
REDIS_PORT: Optional[int]
|
||||
REDIS_PASSWORD: Optional[str]
|
||||
|
||||
@property
|
||||
def REDIS_URL(self) -> Optional[str]:
|
||||
if not self.REDIS_HOST:
|
||||
return None
|
||||
|
||||
import urllib.parse
|
||||
|
||||
encoded_password = (
|
||||
urllib.parse.quote(
|
||||
self.REDIS_PASSWORD) if self.REDIS_PASSWORD else None
|
||||
)
|
||||
|
||||
if encoded_password:
|
||||
return f"redis://:{encoded_password}@{self.REDIS_HOST}:{self.REDIS_PORT}/0"
|
||||
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/0"
|
||||
|
||||
CHAT_REDIS_MAX_MESSAGES: int
|
||||
CHAT_REDIS_TTL_SECONDS: int
|
||||
|
||||
STORAGE_DRIVE_DIR: str
|
||||
|
||||
CLAMAV_HOST: str
|
||||
CLAMAV_PORT: int
|
||||
CLAMAV_TIMEOUT_SECONDS: float = 20.0
|
||||
CLAMAV_CHUNK_SIZE: int = 1024 * 1024
|
||||
|
||||
API_BASE_URL: str
|
||||
|
||||
GOOGLE_CLIENT_ID: Optional[str] = None
|
||||
GOOGLE_CLIENT_SECRET: Optional[str] = None
|
||||
|
||||
SAAS_TRUST_SECRET: Optional[str] = None
|
||||
SAAS_BASE_URL: Optional[str] = None
|
||||
SAAS_PUBLIC_KEY: Optional[str] = None
|
||||
|
||||
DOCUSEAL_API_URL: str = "http://localhost:3000"
|
||||
DOCUSEAL_API_KEY: Optional[str] = None
|
||||
DOCUSEAL_ACCOUNT_EMAIL: Optional[str] = None
|
||||
|
||||
DEEPSEEK_API_KEY: Optional[str] = None
|
||||
DEEPSEEK_BASE_URL: str = "https://api.deepseek.com/v1"
|
||||
DEEPSEEK_MODEL: str = "deepseek-chat"
|
||||
LLM_TEMPERATURE: float = 0.2
|
||||
|
||||
EMBEDDING_MODEL: str = "BAAI/bge-base-en-v1.5"
|
||||
|
||||
EXTRACTION_API_KEY: Optional[str] = None
|
||||
EXTRACTION_BASE_URL: str = "https://integrate.api.nvidia.com/v1"
|
||||
EXTRACTION_MODEL: str = "mistralai/mistral-medium-3-instruct"
|
||||
EXTRACTION_VISION_MODEL: str = "meta/llama-3.2-90b-vision-instruct"
|
||||
|
||||
HF_HUB_OFFLINE: bool = False
|
||||
TRANSFORMERS_OFFLINE: bool = False
|
||||
|
||||
CHUNK_SIZE: int = 850
|
||||
CHUNK_OVERLAP: int = 180
|
||||
TOP_K_QA: int = 3
|
||||
TOP_K_RETRIEVAL: int = 12
|
||||
TOP_K_RERANK: int = 6
|
||||
MAX_CHAT_HISTORY: int = 5
|
||||
TOP_K_SUMMARY: int = 4
|
||||
MAX_CONTEXT_CHARS: int = 4000
|
||||
MAX_RESPONSE_TOKENS: int = 1024
|
||||
CHAT_DAILY_CREDITS_LIMIT: int
|
||||
MIN_SIMILARITY_SCORE: float = 0.18
|
||||
RAG_DOMAIN_PROFILE: str = "auto"
|
||||
RAG_K_SCHEDULE: str = "8,16,24,36,48"
|
||||
RAG_MAX_CANDIDATES: int = 180
|
||||
RAG_STAGNATION_PATIENCE: int = 2
|
||||
RAG_RERANK_MAX_DOCS: int = 48
|
||||
RAG_SEMANTIC_WEIGHT: float = 1.0
|
||||
RAG_KEYWORD_HIT_WEIGHT: float = 0.08
|
||||
RAG_KEYWORD_COVERAGE_WEIGHT: float = 0.35
|
||||
RAG_COVERAGE_TARGET_RATIO: float = 0.75
|
||||
RAG_MAX_SELECTED_DOCS: int = 40
|
||||
RAG_CONTEXT_BASE_BUDGET: int = 4200
|
||||
RAG_CONTEXT_MAX_BUDGET: int = 9000
|
||||
RAG_PER_SOURCE_CHAR_CAP: int = 680
|
||||
RAG_ENABLE_EXHAUSTIVE_COVERAGE: bool = True
|
||||
RAG_COVERAGE_HARD_FAIL: bool = False
|
||||
RAG_MIN_COVERAGE_TERM_LEN: int = 4
|
||||
RAG_COVERAGE_MAX_POINTS: int = 20000
|
||||
RAG_COVERAGE_MAX_PASSES: int = 4
|
||||
RAG_SCOPE_CACHE_TTL_SECONDS: int = 300
|
||||
RAG_SCOPE_CACHE_MAX_DOCS: int = 24
|
||||
RAG_MICRO_MAX_SENTENCES: int = 6
|
||||
RAG_MICRO_CHUNK_CHAR_CAP: int = 1200
|
||||
RAG_STRICT_CONTEXT_MAX_BUDGET: int = 18000
|
||||
RAG_STRICT_PER_SOURCE_CHAR_CAP: int = 2000
|
||||
RAG_STRICT_EVIDENCE_ONLY_CONTEXT: bool = True
|
||||
RAG_SECTION_FULL_CHUNK_MAX_CHARS: int = 2600
|
||||
RAG_SECTION_FOCUS_ONLY: bool = True
|
||||
RAG_SECTION_FOCUS_CHUNK_WINDOW: int = 0
|
||||
RAG_SECTION_PROBE_NEIGHBOR_WINDOW: int = 2
|
||||
RAG_ENABLE_SECTION_FAST_PATH: bool = True
|
||||
RAG_ENABLE_FAST_PATH_AUTO_DOC: bool = True
|
||||
RAG_FAST_PATH_AUTO_DOC_TOP_K: int = 10
|
||||
RAG_FAST_PATH_AUTO_DOC_MAX_TRY_DOCS: int = 4
|
||||
RAG_ENABLE_EARLY_OOD_CHECK: bool = True
|
||||
RAG_SECTION_FAST_MAX_CHARS: int = 7000
|
||||
RAG_SECTION_FAST_MAX_BULLETS: int = 32
|
||||
RAG_SECTION_FAST_PER_SUBSECTION_BULLETS: int = 8
|
||||
RAG_SECTION_FAST_COMPACT_ROOT: bool = True
|
||||
RAG_SECTION_FAST_SUBSECTION_CHAR_CAP: int = 900
|
||||
RAG_SECTION_FAST_USE_LLM_FOR_ROOT: bool = False
|
||||
RAG_SECTION_FAST_LLM_MAX_CHARS: int = 7000
|
||||
RAG_REQUIRE_DOCUMENT_ID: bool = True
|
||||
RAG_DOC_OVERVIEW_CONTEXT_CHARS: int = 16000
|
||||
RAG_DOC_OVERVIEW_MAX_SECTIONS: int = 14
|
||||
RAG_DOC_OVERVIEW_POINTS_PER_SECTION: int = 3
|
||||
RAG_RESCUE_CONTEXT_CHAR_CAP: int = 2600
|
||||
RAG_RESPONSE_TOKEN_SMALL: int = 180
|
||||
RAG_RESPONSE_TOKEN_MEDIUM: int = 280
|
||||
RAG_RESPONSE_TOKEN_LARGE: int = 420
|
||||
RAG_RESPONSE_TOKEN_HARD_CAP: int = 520
|
||||
VECTOR_DB: str = "qdrant"
|
||||
QDRANT_HOST: str = "localhost"
|
||||
QDRANT_PORT: int = 6333
|
||||
QDRANT_COLLECTION: str = "documents"
|
||||
RERANKER_MODEL: str = "cross-encoder/ms-marco-MiniLM-L-12-v2"
|
||||
|
||||
SMTP_HOST: Optional[str] = None
|
||||
SMTP_PORT: Optional[int] = 587
|
||||
SMTP_USER: Optional[str] = None
|
||||
SMTP_PASSWORD: Optional[str] = None
|
||||
SMTP_SECURE: bool = False
|
||||
MAIL_FROM: Optional[str] = "noreply@docqube.com"
|
||||
|
||||
FRONTEND_URL: str = "http://localhost:5173"
|
||||
|
||||
LOG_LEVEL: str
|
||||
|
||||
CORS_ORIGINS: str
|
||||
|
||||
@property
|
||||
def CORS_ORIGINS_LIST(self) -> List[str]:
|
||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin]
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||
f".env.{os.getenv('APP_ENV', 'development')}",
|
||||
),
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_production_config(self) -> "Settings":
|
||||
if self.MAX_ACTIVE_DEVICES < 1:
|
||||
raise ValueError("MAX_ACTIVE_DEVICES must be at least 1")
|
||||
|
||||
if self.APP_ENV == "production":
|
||||
if not self.REDIS_HOST:
|
||||
raise ValueError(
|
||||
"REDIS_HOST must be explicitly set in production environment "
|
||||
"to prevent silent fallback to insecure defaults."
|
||||
)
|
||||
# Enforce PgBouncer in production: app/db/database.py silently
|
||||
# falls back to a direct PostgreSQL connection (DATABASE_URL,
|
||||
# DB_PORT) whenever PGBOUNCER_PORT is unset — with no error and
|
||||
# no log warning at the point it matters. That silent fallback
|
||||
# is what let the API run for a time connected straight to
|
||||
# Postgres on 5432 instead of through PgBouncer on 6432 during
|
||||
# a prior incident. Failing fast here, before the DB engine is
|
||||
# ever created, converts that into a startup error instead.
|
||||
if not self.PGBOUNCER_PORT:
|
||||
raise ValueError(
|
||||
"PGBOUNCER_PORT must be explicitly set in production environment "
|
||||
"to prevent silently falling back to a direct PostgreSQL connection."
|
||||
)
|
||||
# CORS check
|
||||
if "*" in self.CORS_ORIGINS_LIST:
|
||||
raise ValueError(
|
||||
"CORS_ORIGINS must not contain '*' in production environment "
|
||||
"to prevent unauthorized cross-origin requests."
|
||||
)
|
||||
if self.ZOHO_CLIENT_ID and not self.ZOHO_WEBHOOK_SECRET:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"ZOHO_WEBHOOK_SECRET is empty in production. This silently disables webhook authentication "
|
||||
"and leaves your Zoho Sign integration vulnerable to spoofed requests."
|
||||
)
|
||||
return self
|
||||
|
||||
COOKIE_DOMAIN: Optional[str] = None
|
||||
DISABLE_CSRF: bool = False
|
||||
|
||||
TENANT_FILTER_ENABLED: bool = False
|
||||
|
||||
ORG_SCOPE_ENABLED: bool = False
|
||||
|
||||
SUBSCRIPTION_REQUIRED_FOR_MAPPED_TENANTS: bool = False
|
||||
|
||||
SUBSCRIPTION_ENFORCEMENT_ENABLED: bool = False
|
||||
|
||||
ACCESS_LOG_RETENTION_DAYS: int = 365
|
||||
|
||||
settings = Settings()
|
||||
|
||||
DATABASE_URL = settings.DATABASE_URL
|
||||
@@ -0,0 +1,79 @@
|
||||
import socket
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
from ipaddress import ip_address, ip_network
|
||||
from fastapi import HTTPException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PRIVATE_IP_RANGES = [
|
||||
ip_network('10.0.0.0/8'),
|
||||
ip_network('172.16.0.0/12'),
|
||||
ip_network('192.168.0.0/16'),
|
||||
ip_network('127.0.0.0/8'),
|
||||
ip_network('169.254.0.0/16'),
|
||||
ip_network('::1/128'),
|
||||
ip_network('fc00::/7'),
|
||||
ip_network('fe80::/10'),
|
||||
]
|
||||
|
||||
ALLOWED_SCHEMES = ['http', 'https']
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
def is_safe_url(url: str, allowed_hosts: list = None, allowed_schemes: list = None, require_https: bool = False) -> bool:
|
||||
"""
|
||||
Check if a URL is safe from SSRF by validating the scheme, host, and IP.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
|
||||
schemes = allowed_schemes if allowed_schemes is not None else ALLOWED_SCHEMES
|
||||
if require_https:
|
||||
schemes = ['https']
|
||||
|
||||
if parsed.scheme.lower() not in schemes:
|
||||
logger.warning(f"SSRF Prevention: Invalid scheme {parsed.scheme} in URL {url}")
|
||||
return False
|
||||
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
logger.warning(f"SSRF Prevention: No hostname in URL {url}")
|
||||
return False
|
||||
|
||||
if allowed_hosts and host not in allowed_hosts:
|
||||
logger.warning(f"SSRF Prevention: Host {host} not in whitelist for URL {url}")
|
||||
return False
|
||||
|
||||
try:
|
||||
ips = socket.getaddrinfo(host, parsed.port or (443 if parsed.scheme == 'https' else 80))
|
||||
for family, _, _, _, sockaddr in ips:
|
||||
addr_str = sockaddr[0]
|
||||
if '%' in addr_str:
|
||||
addr_str = addr_str.split('%')[0]
|
||||
|
||||
addr = ip_address(addr_str)
|
||||
for private_range in PRIVATE_IP_RANGES:
|
||||
if addr in private_range:
|
||||
logger.warning(f"SSRF Prevention: URL {url} resolved to private IP {addr}")
|
||||
return False
|
||||
except (socket.gaierror, ValueError) as e:
|
||||
logger.warning(f"SSRF Prevention: Could not resolve or parse host {host} for URL {url}: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"SSRF Prevention: Error validating URL {url}: {e}")
|
||||
return False
|
||||
|
||||
def get_safe_fetcher():
|
||||
"""
|
||||
Returns a URL fetcher function compatible with WeasyPrint.
|
||||
"""
|
||||
from weasyprint import default_url_fetcher
|
||||
|
||||
def safe_fetcher(url, timeout=10, **kwargs):
|
||||
if not is_safe_url(url):
|
||||
raise ValueError(f"SSRF Prevention: Blocked request to unsafe URL: {url}")
|
||||
return default_url_fetcher(url, timeout=timeout, **kwargs)
|
||||
|
||||
return safe_fetcher
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
B1.1 — tenant context.
|
||||
|
||||
The request's tenant, held in context variables so the query listener can reach
|
||||
it without every call site passing it down.
|
||||
|
||||
The important state is not the tenant id but **whether the context was set at
|
||||
all**. DocQube's defect is that a lost tenant currently escalates: `tenant_id IS
|
||||
NULL` means super admin, so code that forgets to scope a query sees everything.
|
||||
Distinguishing "no tenant, because nobody set one" from "tenant is None, which
|
||||
means superuser" is what lets the listener deny rather than grant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar, Token
|
||||
from typing import Any, TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
_tenant_id: ContextVar[UUID | None] = ContextVar("tenant_id", default=None)
|
||||
_is_super_admin: ContextVar[bool] = ContextVar("is_super_admin", default=False)
|
||||
_bypass: ContextVar[bool] = ContextVar("tenant_bypass", default=False)
|
||||
_context_set: ContextVar[bool] = ContextVar("tenant_context_set", default=False)
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def current_tenant_id() -> UUID | None:
|
||||
return _tenant_id.get()
|
||||
|
||||
|
||||
def is_super_admin() -> bool:
|
||||
return _is_super_admin.get()
|
||||
|
||||
|
||||
def is_bypassed() -> bool:
|
||||
return _bypass.get()
|
||||
|
||||
|
||||
def context_was_set() -> bool:
|
||||
"""True when something deliberately established a tenant context."""
|
||||
return _context_set.get()
|
||||
|
||||
|
||||
def set_context(
|
||||
tenant_id: UUID | None, is_super: bool = False, bypass: bool = False
|
||||
) -> tuple[Token, Token, Token, Token]:
|
||||
return (
|
||||
_tenant_id.set(tenant_id),
|
||||
_is_super_admin.set(is_super),
|
||||
_bypass.set(bypass),
|
||||
_context_set.set(True),
|
||||
)
|
||||
|
||||
|
||||
def reset_context(tokens: tuple[Token, Token, Token, Token]) -> None:
|
||||
t_tid, t_super, t_bypass, t_set = tokens
|
||||
_tenant_id.reset(t_tid)
|
||||
_is_super_admin.reset(t_super)
|
||||
_bypass.reset(t_bypass)
|
||||
_context_set.reset(t_set)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def scoped_to(tenant_id: UUID | None, is_super: bool = False) -> Iterator[None]:
|
||||
"""Run a block scoped to one tenant."""
|
||||
tokens = set_context(tenant_id, is_super=is_super, bypass=False)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reset_context(tokens)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def unscoped() -> Iterator[None]:
|
||||
"""
|
||||
Run a block with tenant filtering switched off.
|
||||
|
||||
For genuine system work — migrations, background sweeps, the seed script.
|
||||
Every use is a place where the guarantee does not apply, so they should be
|
||||
few and obvious.
|
||||
"""
|
||||
tokens = (_bypass.set(True), _context_set.set(True))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_bypass.reset(tokens[0])
|
||||
_context_set.reset(tokens[1])
|
||||
|
||||
|
||||
def system_operation(fn: F) -> F:
|
||||
"""Decorator form of `unscoped()`."""
|
||||
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
with unscoped():
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class TenantUnscoped:
|
||||
"""
|
||||
Opt a model *out* of tenant filtering.
|
||||
|
||||
Scoping is derived from the presence of a `tenant_id` column rather than
|
||||
from a marker mixin, so a new tenant-owned model is protected the day it is
|
||||
written and nobody has to remember anything. This marker exists for the rare
|
||||
model that carries a `tenant_id` but must be readable across tenants — and
|
||||
applying it should require an argument in review.
|
||||
"""
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
B1.1 — the fail-closed query listener.
|
||||
|
||||
Every SELECT against a tenant-owned table gets a tenant predicate added. With no
|
||||
tenant context it matches **nothing**, which is the whole point: a bug that loses
|
||||
the tenant used to see everything, because `tenant_id IS NULL` doubled as super
|
||||
admin. Now the same bug returns an empty page.
|
||||
|
||||
**Off by default, but no longer blocked.** The ordering constraint is satisfied:
|
||||
the `tenant_id IS NULL` fallback in `is_superadmin()` is gone, privilege is an
|
||||
explicit flag, and `TenantContextMiddleware` (B1.4) sets both the tenant and the
|
||||
flag on every request. The whole suite passes with `TENANT_FILTER_ENABLED=true`,
|
||||
and CI runs it that way.
|
||||
|
||||
It stays off by default because the switch is the rollback — turning it off takes
|
||||
effect without a deploy — and because enabling it needs the *data* to be ready,
|
||||
not just the code. `scripts/b1_preflight.py` is the check.
|
||||
|
||||
Which models are scoped is **derived from the schema**, not from a marker list.
|
||||
A model with a `tenant_id` column is protected the day it is written. Opting out
|
||||
requires inheriting `TenantUnscoped`, which is visible in review.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import event, false, or_
|
||||
from sqlalchemy.orm import Session, with_loader_criteria
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scoped_cache: dict[str, Any] | None = None
|
||||
_scoped_cache_mappers: int = -1
|
||||
|
||||
|
||||
def scoped_models() -> dict[str, Any]:
|
||||
"""
|
||||
Mapped classes carrying a `tenant_id` column, by table name.
|
||||
|
||||
Cached, but **invalidated when the registry grows** — and that second half
|
||||
is load-bearing, not tidiness.
|
||||
|
||||
The cache used to be computed once and kept forever. The first call happens
|
||||
on the first query, which in a running application is early: before every
|
||||
model module has necessarily been imported. Anything mapped after that point
|
||||
was absent from the set and therefore **silently never filtered** — a
|
||||
tenant-owned table with no tenant predicate, and nothing anywhere to say so.
|
||||
|
||||
Found when `user_roles` gained a `tenant_id` and the isolation ratchet
|
||||
reported it unscoped with the filter enabled and scoped with it disabled.
|
||||
The difference was import order: enabling the filter makes the listener run
|
||||
during startup, which populated the cache before the model was loaded.
|
||||
|
||||
Counting mappers is enough. SQLAlchemy's registry only grows, so a changed
|
||||
count means new classes and a stale set.
|
||||
"""
|
||||
global _scoped_cache, _scoped_cache_mappers
|
||||
|
||||
from app.core.tenant_context import TenantUnscoped
|
||||
from app.db.database import Base
|
||||
|
||||
mapper_count = len(Base.registry.mappers)
|
||||
if _scoped_cache is not None and _scoped_cache_mappers == mapper_count:
|
||||
return _scoped_cache
|
||||
|
||||
found: dict[str, Any] = {}
|
||||
for mapper in Base.registry.mappers:
|
||||
cls = mapper.class_
|
||||
if issubclass(cls, TenantUnscoped):
|
||||
continue
|
||||
if "tenant_id" in mapper.columns:
|
||||
found[mapper.local_table.name] = cls
|
||||
_scoped_cache = found
|
||||
_scoped_cache_mappers = mapper_count
|
||||
return found
|
||||
|
||||
|
||||
def reset_scoped_cache() -> None:
|
||||
"""Forget the derived set — used by tests that define models on the fly."""
|
||||
global _scoped_cache, _scoped_cache_mappers
|
||||
_scoped_cache = None
|
||||
_scoped_cache_mappers = -1
|
||||
|
||||
|
||||
def _table_names(statement: Any) -> set[str]:
|
||||
"""
|
||||
Every table a statement touches, including through subqueries.
|
||||
|
||||
Walking `get_final_froms()` by hand is not enough: `query(X).count()`
|
||||
compiles to SELECT count(*) FROM (SELECT ...) and the outer FROM is an
|
||||
anonymous subquery, so a naive walk sees only the alias. SQLAlchemy's own
|
||||
`find_tables` handles the nesting.
|
||||
"""
|
||||
from sqlalchemy.sql.util import find_tables
|
||||
|
||||
try:
|
||||
return {
|
||||
table.name
|
||||
for table in find_tables(
|
||||
statement,
|
||||
include_aliases=True,
|
||||
include_joins=True,
|
||||
include_selects=True,
|
||||
include_crud=True,
|
||||
)
|
||||
}
|
||||
except Exception: # noqa: BLE001 - not every construct can be walked
|
||||
return set()
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
from app.core.settings import settings
|
||||
|
||||
return bool(getattr(settings, "TENANT_FILTER_ENABLED", False))
|
||||
|
||||
|
||||
@event.listens_for(Session, "do_orm_execute")
|
||||
def _apply_tenant_filter(execute_state: Any) -> None:
|
||||
if not _enabled():
|
||||
return
|
||||
if not execute_state.is_select:
|
||||
return
|
||||
if execute_state.is_column_load or execute_state.is_relationship_load:
|
||||
return
|
||||
|
||||
from app.core.tenant_context import (
|
||||
context_was_set,
|
||||
current_tenant_id,
|
||||
is_bypassed,
|
||||
is_super_admin,
|
||||
)
|
||||
|
||||
if is_bypassed() or is_super_admin():
|
||||
from app.core.tenant_metrics import metrics
|
||||
|
||||
metrics.record_bypassed()
|
||||
return
|
||||
|
||||
by_table = scoped_models()
|
||||
if not by_table:
|
||||
return
|
||||
|
||||
targets = {
|
||||
mapper.class_
|
||||
for mapper in execute_state.all_mappers
|
||||
if mapper.local_table is not None
|
||||
and mapper.local_table.name in by_table
|
||||
}
|
||||
|
||||
if not targets:
|
||||
targets = {
|
||||
by_table[name]
|
||||
for name in _table_names(execute_state.statement)
|
||||
if name in by_table
|
||||
}
|
||||
if not targets:
|
||||
return
|
||||
|
||||
fail_closed = not context_was_set()
|
||||
tenant_id = current_tenant_id()
|
||||
|
||||
from app.core.tenant_metrics import metrics
|
||||
|
||||
if fail_closed:
|
||||
names = sorted(cls.__name__ for cls in targets)
|
||||
metrics.record_fail_closed(names)
|
||||
logger.warning("Tenant filter closed a query with no tenant context: %s", names)
|
||||
else:
|
||||
metrics.record_scoped()
|
||||
|
||||
for cls in targets:
|
||||
if fail_closed:
|
||||
criteria = false()
|
||||
else:
|
||||
criteria = or_(cls.tenant_id == tenant_id, cls.tenant_id.is_(None))
|
||||
execute_state.statement = execute_state.statement.options(
|
||||
with_loader_criteria(cls, criteria, include_aliases=True)
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
B1.6 / B4.5 — see what the tenant filter is doing.
|
||||
|
||||
After B1 lands, the failure mode is not a crash. It is a listener quietly
|
||||
returning nothing for a legitimate query, and the first report arrives from a
|
||||
customer saying a page is empty. Counters make that visible in minutes instead.
|
||||
|
||||
Three numbers, deliberately few:
|
||||
|
||||
``fail_closed`` a query ran with no tenant context and was denied. In
|
||||
steady state this should be **zero**. Anything above zero
|
||||
is a code path that lost the tenant — a bug that used to
|
||||
escalate silently and now denies loudly.
|
||||
``scoped`` queries filtered normally. The denominator.
|
||||
``bypassed`` explicit system operations. Should be small and stable; a
|
||||
rise means someone is reaching for ``unscoped()`` to make
|
||||
a problem go away.
|
||||
|
||||
In-process and reset on restart. That is enough to answer "is the rollout
|
||||
safe?", which is what B1.6 needs. Wiring these to the metrics backend is B4.5.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class TenantFilterMetrics:
|
||||
fail_closed: int = 0
|
||||
scoped: int = 0
|
||||
bypassed: int = 0
|
||||
fail_closed_by_model: dict[str, int] = field(default_factory=dict)
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||
|
||||
def record_fail_closed(self, models: list[str]) -> None:
|
||||
with self._lock:
|
||||
self.fail_closed += 1
|
||||
for name in models:
|
||||
self.fail_closed_by_model[name] = (
|
||||
self.fail_closed_by_model.get(name, 0) + 1
|
||||
)
|
||||
|
||||
def record_scoped(self) -> None:
|
||||
with self._lock:
|
||||
self.scoped += 1
|
||||
|
||||
def record_bypassed(self) -> None:
|
||||
with self._lock:
|
||||
self.bypassed += 1
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
with self._lock:
|
||||
total = self.fail_closed + self.scoped + self.bypassed
|
||||
return {
|
||||
"fail_closed": self.fail_closed,
|
||||
"scoped": self.scoped,
|
||||
"bypassed": self.bypassed,
|
||||
"total": total,
|
||||
"fail_closed_rate": (self.fail_closed / total) if total else 0.0,
|
||||
"fail_closed_by_model": dict(self.fail_closed_by_model),
|
||||
}
|
||||
|
||||
def reset(self) -> None:
|
||||
with self._lock:
|
||||
self.fail_closed = 0
|
||||
self.scoped = 0
|
||||
self.bypassed = 0
|
||||
self.fail_closed_by_model.clear()
|
||||
|
||||
|
||||
metrics = TenantFilterMetrics()
|
||||
@@ -0,0 +1,74 @@
|
||||
from app.db.redis import redis_cache
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TokenBlacklist:
|
||||
"""
|
||||
Service for managing revoked JWT tokens using Redis.
|
||||
Uses the 'jti' (JWT ID) claim to identify tokens for blacklisting.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def add(jti: str, expires_in: int = 3600):
|
||||
"""
|
||||
Add a token's JTI to the blacklist with an expiration time.
|
||||
|
||||
Args:
|
||||
jti: The unique JWT ID (jti claim)
|
||||
expires_in: Time in seconds until the token naturally expires
|
||||
"""
|
||||
if not jti:
|
||||
return
|
||||
|
||||
try:
|
||||
key = f"blacklist:{jti}"
|
||||
redis_cache.set(key, {"revoked": True}, ttl=expires_in)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to blacklist JTI: {e}")
|
||||
|
||||
@staticmethod
|
||||
def is_token_blacklisted(token: str) -> bool:
|
||||
"""
|
||||
Check whether a raw JWT has been revoked, by reading its `jti` claim.
|
||||
|
||||
Callers that hold a raw token must use this rather than passing the
|
||||
token to `is_blacklisted`: the blacklist is keyed on `jti`, so a raw
|
||||
token builds the key `blacklist:<entire JWT>`, which is never written
|
||||
and therefore never matches. Both WebSocket endpoints did exactly that,
|
||||
which silently disabled revocation on the whole real-time surface.
|
||||
"""
|
||||
if not token:
|
||||
return False
|
||||
try:
|
||||
from jose import jwt
|
||||
|
||||
from app.core.settings import settings
|
||||
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.APP_SECRET,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
return TokenBlacklist.is_blacklisted(payload.get("jti"))
|
||||
|
||||
@staticmethod
|
||||
def is_blacklisted(jti: str) -> bool:
|
||||
"""
|
||||
Check if a token's JTI is in the blacklist.
|
||||
|
||||
Takes a `jti` claim, not a raw JWT. See `is_token_blacklisted`.
|
||||
"""
|
||||
if not jti:
|
||||
return False
|
||||
|
||||
try:
|
||||
key = f"blacklist:{jti}"
|
||||
value = redis_cache.get(key)
|
||||
return value is not None
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check JTI blacklist: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
B2.4 — keeping object storage and the database consistent.
|
||||
|
||||
B2 made the request own the database transaction. Object storage has no such
|
||||
boundary: an upload to Backblaze is durable the moment it returns, and no
|
||||
rollback undoes it. So making the database atomic on its own moves the problem
|
||||
rather than solving it —
|
||||
|
||||
* a **write** that succeeds in storage and then fails in the database leaves
|
||||
an object with no row pointing at it, and now not even a half-written row to
|
||||
find it by;
|
||||
* a **delete** that removes the object first and then fails in the database
|
||||
leaves a row pointing at nothing, which is worse: the application will try
|
||||
to serve it.
|
||||
|
||||
Two primitives, used at the point where the storage call happens:
|
||||
|
||||
compensate_on_rollback(session, lambda: delete(key))
|
||||
The object is already written. If the transaction rolls back, remove it.
|
||||
|
||||
defer_until_commit(session, lambda: delete(key))
|
||||
Do not touch storage yet. If the transaction commits, do it then.
|
||||
|
||||
Both are per-session and fire once. A failure inside a callback is logged and
|
||||
swallowed: compensation is best-effort by nature, and raising from it would
|
||||
replace a tidy-up problem with a failed request.
|
||||
|
||||
This is the cheap half of the outbox pattern. The full version — durable intent
|
||||
that survives a process restart — is B5.1, and is worth having if the roadmap
|
||||
ever needs guaranteed delivery. What is here covers the common case: a crash
|
||||
between the two systems within a single request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable, List
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ON_ROLLBACK = "_docqube_compensate_on_rollback"
|
||||
_ON_COMMIT = "_docqube_defer_until_commit"
|
||||
|
||||
|
||||
def _bucket(session: Session, key: str) -> List[Callable[[], None]]:
|
||||
actions = session.info.get(key)
|
||||
if actions is None:
|
||||
actions = []
|
||||
session.info[key] = actions
|
||||
return actions
|
||||
|
||||
|
||||
def compensate_on_rollback(session: Session, action: Callable[[], None]) -> None:
|
||||
"""Run `action` if this session's transaction is rolled back."""
|
||||
_bucket(session, _ON_ROLLBACK).append(action)
|
||||
|
||||
|
||||
def defer_until_commit(session: Session, action: Callable[[], None]) -> None:
|
||||
"""Run `action` only once this session's transaction has committed."""
|
||||
_bucket(session, _ON_COMMIT).append(action)
|
||||
|
||||
|
||||
def _drain(session: Session, key: str, what: str) -> None:
|
||||
actions = session.info.pop(key, None)
|
||||
if not actions:
|
||||
return
|
||||
for action in actions:
|
||||
try:
|
||||
action()
|
||||
except Exception: # noqa: BLE001 - tidy-up must not fail the request
|
||||
logger.exception("Storage %s action failed", what)
|
||||
|
||||
|
||||
@event.listens_for(Session, "after_commit")
|
||||
def _run_deferred(session: Session) -> None:
|
||||
_drain(session, _ON_COMMIT, "post-commit")
|
||||
session.info.pop(_ON_ROLLBACK, None)
|
||||
|
||||
|
||||
@event.listens_for(Session, "after_soft_rollback")
|
||||
def _run_compensation(session: Session, previous_transaction) -> None:
|
||||
if getattr(previous_transaction, "nested", False):
|
||||
return
|
||||
_drain(session, _ON_ROLLBACK, "compensating")
|
||||
session.info.pop(_ON_COMMIT, None)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
B0.4.5 — one authenticator for the WebSocket surface.
|
||||
|
||||
There were two `_authenticate_ws` functions, in `app/api/ws_router.py` and
|
||||
`app/modules/collab/routes/chat_routes.py`, near-identical and independently
|
||||
maintained. That is how they drifted, and the drift was not cosmetic:
|
||||
|
||||
- both keyed the token blacklist on the raw JWT where a `jti` was expected, so
|
||||
revocation silently did nothing on the entire real-time surface;
|
||||
- neither rejected `refresh` tokens, so a long-lived refresh token could open a
|
||||
socket it could not have opened an HTTP request with;
|
||||
- neither checked whether the user was deleted or deactivated;
|
||||
- and neither checked the **tenant**, so suspending a tenant closed no sockets.
|
||||
A suspended customer kept live document collaboration and chat for as long as
|
||||
the connection stayed open, which on a WebSocket is indefinitely.
|
||||
|
||||
The first two were fixed in both copies. Fixing the rest in both copies would
|
||||
have been the third round of the same edit, so this is the shared version the
|
||||
plan called for: one function, one set of checks, both callers delegating.
|
||||
|
||||
**Deliberately not `get_current_user`.** That is a FastAPI dependency built on
|
||||
`Request` and `Depends`; a WebSocket handshake has neither. What it *does* have
|
||||
is the same token, so the checks are what get shared, not the plumbing. Every
|
||||
rule here mirrors one in `get_current_user`, and
|
||||
`tests/probes/test_websocket_auth.py` asserts the two surfaces agree.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.settings import settings
|
||||
from app.core.token_blacklist import TokenBlacklist
|
||||
from app.modules.auth.models.user_model import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def authenticate_websocket(token: str, db: Session) -> Optional[User]:
|
||||
"""
|
||||
The user this token authorises for a socket, or None.
|
||||
|
||||
Returns None for every failure rather than raising: the caller's only
|
||||
remedy is to close the handshake, and distinguishing *why* would tell an
|
||||
unauthenticated peer whether an account exists.
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.APP_SECRET, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
if payload.get("type") in ("refresh", "device_management"):
|
||||
return None
|
||||
|
||||
jti = payload.get("jti")
|
||||
if jti and TokenBlacklist.is_blacklisted(jti):
|
||||
return None
|
||||
|
||||
sub = payload.get("sub")
|
||||
if not sub:
|
||||
return None
|
||||
|
||||
try:
|
||||
user = db.get(User, int(sub))
|
||||
except (ValueError, TypeError):
|
||||
user = db.query(User).filter(User.email == str(sub)).first()
|
||||
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
if getattr(user, "is_deleted", False) or not getattr(user, "is_active", True):
|
||||
return None
|
||||
|
||||
tenant = getattr(user, "tenant", None)
|
||||
if tenant is not None and not getattr(tenant, "is_active", True):
|
||||
logger.info(
|
||||
"WebSocket auth refused: tenant %s is not active", getattr(tenant, "id", "?")
|
||||
)
|
||||
return None
|
||||
if tenant is not None and getattr(tenant, "is_deleted", False):
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
__all__ = ["authenticate_websocket"]
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Every mapped class, registered — importing this module is the guarantee.
|
||||
|
||||
SQLAlchemy only knows about a model once its module has been imported. Nothing
|
||||
was making that happen for the whole set: `alembic/env.py` imported `app.main`
|
||||
and trusted that starting the application would drag every model in behind it.
|
||||
Mostly it did, because routes import the models they use at module level.
|
||||
|
||||
Three did not. `UserRole`, `AccessGroup` and `UserAccessGroup` are imported
|
||||
*inside functions* everywhere they appear — a deliberate style elsewhere in the
|
||||
codebase, and harmless right up until something needs the full registry:
|
||||
|
||||
- **`alembic revision --autogenerate` proposed `DROP TABLE user_roles`,
|
||||
`access_groups` and `user_access_groups`.** Autogenerate diffs the database
|
||||
against `Base.metadata`; a table present in one and absent from the other
|
||||
reads as "deleted". The three tables missing are the three that carry scoped
|
||||
role grants and group membership — the entire SBAC layer — so the next person
|
||||
to run autogenerate and apply the result without reading it drops
|
||||
authorization and keeps the app booting.
|
||||
- `Base.metadata.create_all()` would not create them.
|
||||
- Anything deriving behaviour from the registry sees a partial picture. The
|
||||
tenant filter is safe here only because `scoped_models()` invalidates on
|
||||
mapper count, which was itself added after this class of bug bit once.
|
||||
|
||||
So the imports are explicit and in one place. `tests/probes/test_model_registry.py`
|
||||
fails if a model module exists and is not listed here, which is what keeps the
|
||||
list honest — a new model is registered on the day it is written, not the day
|
||||
someone notices it missing from a migration.
|
||||
"""
|
||||
|
||||
from app.modules.activity_logs.models import activity_log as _activity_log
|
||||
from app.modules.auth.models import access_model as _access
|
||||
from app.modules.auth.models import device_model as _device
|
||||
from app.modules.auth.models import role_access_model as _role_access
|
||||
from app.modules.auth.models import role_model as _role
|
||||
from app.modules.auth.models import saas_models as _saas
|
||||
from app.modules.auth.models import session_model as _session
|
||||
from app.modules.auth.models import user_model as _user
|
||||
from app.modules.auth.models import user_role_model as _user_role
|
||||
from app.modules.billing.models import plan_model as _plan
|
||||
from app.modules.chat.models import chat_message_model as _chat_message
|
||||
from app.modules.chat.models import chat_model as _chat
|
||||
from app.modules.chat.models import chat_session_model as _chat_session
|
||||
from app.modules.chat.models import chat_usage_model as _chat_usage
|
||||
from app.modules.collab.models import collab_model as _collab
|
||||
from app.modules.configuration.models import (
|
||||
system_configuration_model as _system_configuration,
|
||||
)
|
||||
from app.modules.documents.models import document_model as _document
|
||||
from app.modules.drive.models import drive_model as _drive
|
||||
from app.modules.extraction.models import extraction_model as _extraction
|
||||
from app.modules.notifications.models import notification_model as _notification
|
||||
from app.modules.org.models import group_model as _group
|
||||
from app.modules.org.models import org_model as _org
|
||||
from app.modules.signing.models import signature_config_model as _signature_config
|
||||
from app.modules.signing.models import signature_imprint as _signature_imprint
|
||||
from app.modules.signing.models import signing_request as _signing_request
|
||||
from app.modules.storage.models import storage_model as _storage
|
||||
from app.modules.tenant.models import tenant_contact_model as _tenant_contact
|
||||
from app.modules.tenant.models import tenant_model as _tenant
|
||||
from app.modules.tenant.models import tenant_smtp_config_model as _tenant_smtp
|
||||
from app.modules.tenant.models import tenant_storage_config_model as _tenant_storage
|
||||
|
||||
__all__ = [
|
||||
"_access",
|
||||
"_activity_log",
|
||||
"_chat",
|
||||
"_chat_message",
|
||||
"_chat_session",
|
||||
"_chat_usage",
|
||||
"_collab",
|
||||
"_device",
|
||||
"_document",
|
||||
"_drive",
|
||||
"_extraction",
|
||||
"_group",
|
||||
"_notification",
|
||||
"_org",
|
||||
"_plan",
|
||||
"_role",
|
||||
"_role_access",
|
||||
"_saas",
|
||||
"_session",
|
||||
"_signature_config",
|
||||
"_signature_imprint",
|
||||
"_signing_request",
|
||||
"_storage",
|
||||
"_system_configuration",
|
||||
"_tenant",
|
||||
"_tenant_contact",
|
||||
"_tenant_smtp",
|
||||
"_tenant_storage",
|
||||
"_user",
|
||||
"_user_role",
|
||||
]
|
||||
@@ -0,0 +1,100 @@
|
||||
import logging
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase, Session
|
||||
from contextlib import contextmanager
|
||||
from app.core.settings import DATABASE_URL, settings
|
||||
|
||||
from sqlalchemy.pool import NullPool, QueuePool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
active_url = settings.PGBOUNCER_URL if (settings.PGBOUNCER_URL and settings.PGBOUNCER_PORT) else DATABASE_URL
|
||||
|
||||
if settings.PGBOUNCER_URL and settings.PGBOUNCER_PORT:
|
||||
engine = create_engine(
|
||||
active_url,
|
||||
poolclass=NullPool,
|
||||
echo=False,
|
||||
)
|
||||
elif settings.APP_ENV == "development":
|
||||
engine = create_engine(
|
||||
active_url,
|
||||
poolclass=NullPool,
|
||||
echo=False,
|
||||
)
|
||||
else:
|
||||
engine = create_engine(
|
||||
active_url,
|
||||
poolclass=QueuePool,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=900,
|
||||
pool_size=3,
|
||||
max_overflow=7,
|
||||
pool_timeout=30,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(
|
||||
bind=engine,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@contextmanager
|
||||
def get_db_connection() -> Session:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _to_dict(obj):
|
||||
if not obj: return None
|
||||
if hasattr(obj, '__table__'):
|
||||
return {c.name: getattr(obj, c.name) for c in obj.__table__.columns}
|
||||
if isinstance(obj, dict):
|
||||
return obj
|
||||
return None
|
||||
|
||||
def init_db_pool():
|
||||
"""Log pool configuration on startup."""
|
||||
pool = engine.pool
|
||||
|
||||
if settings.PGBOUNCER_URL and settings.PGBOUNCER_PORT:
|
||||
logger.info(f"✅ PgBouncer connection successfully routed! (Strict Port {settings.PGBOUNCER_PORT})")
|
||||
|
||||
logger.info(
|
||||
f"DB pool initialized: size={getattr(pool, 'size', 'N/A')}, "
|
||||
f"overflow={getattr(pool, '_max_overflow', 'N/A')}, "
|
||||
f"class={pool.__class__.__name__}"
|
||||
)
|
||||
|
||||
def close_db_pool():
|
||||
engine.dispose()
|
||||
logger.info("DB pool disposed — all connections closed")
|
||||
|
||||
def init_db():
|
||||
pass
|
||||
|
||||
|
||||
from app.core import tenant_filter # noqa: E402,F401
|
||||
|
||||
from app.core import rls # noqa: E402,F401
|
||||
@@ -0,0 +1,94 @@
|
||||
import redis
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Any, Dict
|
||||
from app.core.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REDIS_HOST = settings.REDIS_HOST or "localhost"
|
||||
REDIS_PORT = settings.REDIS_PORT or 6379
|
||||
REDIS_PASSWORD = settings.REDIS_PASSWORD
|
||||
|
||||
class RedisClient:
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
try:
|
||||
self.client = redis.Redis(
|
||||
host=REDIS_HOST,
|
||||
port=REDIS_PORT,
|
||||
password=REDIS_PASSWORD,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5
|
||||
)
|
||||
self.client.ping()
|
||||
logger.info(f"✅ Redis Connected: {REDIS_HOST}:{REDIS_PORT}")
|
||||
except Exception as e:
|
||||
logger.info(f"Redis not available: {e}. Running without caching.")
|
||||
self.client = None
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
if not self.client:
|
||||
return None
|
||||
try:
|
||||
val = self.client.get(key)
|
||||
return json.loads(val) if val else None
|
||||
except Exception as e:
|
||||
logger.debug(f"Redis get error: {e}")
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any, ttl: int = 300):
|
||||
if not self.client:
|
||||
return
|
||||
try:
|
||||
self.client.setex(key, ttl, json.dumps(value, default=str))
|
||||
except Exception as e:
|
||||
logger.debug(f"Redis set error: {e}")
|
||||
|
||||
def delete(self, key: str):
|
||||
if not self.client:
|
||||
return
|
||||
try:
|
||||
self.client.delete(key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def hgetall(self, key: str) -> Dict[str, str]:
|
||||
if not self.client:
|
||||
return {}
|
||||
try:
|
||||
return self.client.hgetall(key) or {}
|
||||
except Exception as e:
|
||||
logger.debug(f"Redis hgetall error: {e}")
|
||||
return {}
|
||||
|
||||
def hset(self, key: str, field: str, value: str):
|
||||
if not self.client:
|
||||
return
|
||||
try:
|
||||
self.client.hset(key, field, value)
|
||||
except Exception as e:
|
||||
logger.debug(f"Redis hset error: {e}")
|
||||
|
||||
def hdel(self, key: str, *fields: str):
|
||||
if not self.client or not fields:
|
||||
return
|
||||
try:
|
||||
self.client.hdel(key, *fields)
|
||||
except Exception as e:
|
||||
logger.debug(f"Redis hdel error: {e}")
|
||||
|
||||
def invalidate_pattern(self, pattern: str):
|
||||
"""Invalidate keys matching pattern (e.g. 'drive:user:1:*')"""
|
||||
if not self.client:
|
||||
return
|
||||
try:
|
||||
keys = list(self.client.scan_iter(pattern))
|
||||
if keys:
|
||||
self.client.delete(*keys)
|
||||
logger.debug(f"Invalidated {len(keys)} keys for pattern {pattern}")
|
||||
except Exception as e:
|
||||
logger.error(f"Redis invalidate error: {e}")
|
||||
|
||||
redis_cache = RedisClient()
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Redis Chat Subscriber Worker — single-per-process background task.
|
||||
|
||||
Subscribes to the Redis pattern ``chat:file:*`` via PSUBSCRIBE and
|
||||
forwards incoming messages to the local ChatConnectionManager rooms.
|
||||
|
||||
Why one global worker instead of one per WebSocket connection?
|
||||
• One Redis subscription per process (not per connection) — O(1) vs O(N).
|
||||
• Works across multiple FastAPI / Uvicorn workers: each worker runs its
|
||||
own instance of this task, so every local room receives messages
|
||||
published by any other worker.
|
||||
• The ``_origin_worker`` tag prevents echo: messages that originated on
|
||||
THIS worker are skipped (they were already broadcast locally).
|
||||
|
||||
Usage (in main.py lifespan):
|
||||
from app.infrastructure.realtime.chat_subscriber import start_chat_subscriber, stop_chat_subscriber
|
||||
|
||||
# Startup
|
||||
await start_chat_subscriber()
|
||||
|
||||
# Shutdown
|
||||
await stop_chat_subscriber()
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.core.settings import settings
|
||||
from app.infrastructure.realtime.connection_manager import manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CHANNEL_PATTERN = "chat:file:*"
|
||||
|
||||
_subscriber_task: Optional[asyncio.Task] = None
|
||||
|
||||
|
||||
async def _listen_forever() -> None:
|
||||
"""
|
||||
Connect to Redis, pattern-subscribe, and loop forever.
|
||||
|
||||
Automatically reconnects on transient errors with exponential backoff.
|
||||
"""
|
||||
backoff = 1
|
||||
|
||||
while True:
|
||||
client: Optional[aioredis.Redis] = None
|
||||
try:
|
||||
url = settings.REDIS_URL or "redis://localhost:6379/0"
|
||||
client = aioredis.from_url(url, decode_responses=True)
|
||||
await client.ping()
|
||||
logger.info(f"📡 Chat subscriber connected (pattern: {_CHANNEL_PATTERN})")
|
||||
backoff = 1
|
||||
|
||||
pubsub = client.pubsub()
|
||||
await pubsub.psubscribe(_CHANNEL_PATTERN)
|
||||
|
||||
async for raw in pubsub.listen():
|
||||
if raw["type"] != "pmessage":
|
||||
continue
|
||||
|
||||
channel: str = raw["channel"]
|
||||
try:
|
||||
file_id = int(channel.rsplit(":", 1)[-1])
|
||||
except (ValueError, IndexError):
|
||||
logger.warning(f"Bad channel name: {channel}")
|
||||
continue
|
||||
|
||||
try:
|
||||
data: dict = json.loads(raw["data"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning(f"Bad payload on {channel}")
|
||||
continue
|
||||
|
||||
await manager.broadcast_chat(file_id, data)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Chat subscriber task cancelled — shutting down")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Chat subscriber error: {e} — reconnecting in {backoff}s"
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30)
|
||||
|
||||
finally:
|
||||
if client:
|
||||
try:
|
||||
await client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def start_chat_subscriber() -> None:
|
||||
"""Launch the subscriber as a background task (idempotent)."""
|
||||
global _subscriber_task
|
||||
if _subscriber_task is not None and not _subscriber_task.done():
|
||||
return
|
||||
_subscriber_task = asyncio.create_task(_listen_forever())
|
||||
logger.info("✅ Chat subscriber worker started")
|
||||
|
||||
|
||||
async def stop_chat_subscriber() -> None:
|
||||
"""Cancel the background task gracefully."""
|
||||
global _subscriber_task
|
||||
if _subscriber_task is None:
|
||||
return
|
||||
_subscriber_task.cancel()
|
||||
try:
|
||||
await _subscriber_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_subscriber_task = None
|
||||
logger.info("Chat subscriber worker stopped")
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
ConnectionManager — unified WebSocket manager for all real-time features.
|
||||
|
||||
Currently handles two types of connectivity:
|
||||
1. Room-based (Chat): file-based collaboration where multiple users share a room.
|
||||
2. Direct/Task-based: 1:1 connections for tracking specific backend task progress.
|
||||
|
||||
This replaces the old infrastructure/websocket/ws_manager.py for a
|
||||
unified, production-ready implementation.
|
||||
"""
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, Set, Dict
|
||||
|
||||
from fastapi import WebSocket
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class Connection:
|
||||
"""Metadata associated with a single WebSocket connection."""
|
||||
websocket: WebSocket
|
||||
room_id: str
|
||||
user_id: Optional[int] = None
|
||||
conn_type: str = 'chat'
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""
|
||||
Manages WebSocket connections partitioned by room_id.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._rooms: Dict[str, Set[Connection]] = defaultdict(set)
|
||||
self._ws_index: Dict[int, Connection] = {}
|
||||
|
||||
|
||||
async def connect(
|
||||
self,
|
||||
room_id: str,
|
||||
websocket: WebSocket,
|
||||
user_id: Optional[int] = None,
|
||||
conn_type: str = 'chat'
|
||||
) -> Connection:
|
||||
"""Accept the WebSocket and register it in the specified room."""
|
||||
await websocket.accept()
|
||||
|
||||
conn = Connection(
|
||||
websocket=websocket,
|
||||
room_id=room_id,
|
||||
user_id=user_id,
|
||||
conn_type=conn_type
|
||||
)
|
||||
self._rooms[room_id].add(conn)
|
||||
self._ws_index[id(websocket)] = conn
|
||||
|
||||
logger.info(
|
||||
f"WS connected: type={conn_type} room={room_id} user={user_id} "
|
||||
f"(room size: {len(self._rooms[room_id])})"
|
||||
)
|
||||
return conn
|
||||
|
||||
async def connect_chat(self, user_id: int, file_id: int, websocket: WebSocket) -> Connection:
|
||||
"""Helper to connect to a chat room."""
|
||||
return await self.connect(
|
||||
room_id=f"chat:{file_id}",
|
||||
websocket=websocket,
|
||||
user_id=user_id,
|
||||
conn_type='chat'
|
||||
)
|
||||
|
||||
async def connect_task(self, task_id: str, websocket: WebSocket) -> Connection:
|
||||
"""Helper to connect to a task status channel."""
|
||||
return await self.connect(
|
||||
room_id=f"task:{task_id}",
|
||||
websocket=websocket,
|
||||
conn_type='task'
|
||||
)
|
||||
|
||||
def disconnect(self, websocket: WebSocket) -> None:
|
||||
"""Remove a WebSocket from its room."""
|
||||
conn = self._ws_index.pop(id(websocket), None)
|
||||
if conn is None:
|
||||
return
|
||||
|
||||
room = self._rooms.get(conn.room_id)
|
||||
if room:
|
||||
room.discard(conn)
|
||||
if not room:
|
||||
del self._rooms[conn.room_id]
|
||||
|
||||
logger.info(
|
||||
f"WS disconnected: room={conn.room_id} user={conn.user_id} "
|
||||
f"(room size: {len(self._rooms.get(conn.room_id, []))})"
|
||||
)
|
||||
|
||||
async def broadcast(self, room_id: str, message: Dict[str, Any]) -> None:
|
||||
"""Send a JSON payload to every connection in the room."""
|
||||
room = self._rooms.get(room_id)
|
||||
if not room:
|
||||
return
|
||||
|
||||
stale: list[Connection] = []
|
||||
|
||||
for conn in room:
|
||||
try:
|
||||
if conn.websocket.client_state == WebSocketState.CONNECTED:
|
||||
await conn.websocket.send_json(message)
|
||||
else:
|
||||
stale.append(conn)
|
||||
except Exception:
|
||||
stale.append(conn)
|
||||
|
||||
for conn in stale:
|
||||
self.disconnect(conn.websocket)
|
||||
|
||||
async def broadcast_chat(self, file_id: int, data: Dict[str, Any]) -> None:
|
||||
"""Legacy helper for broadcast on chat:file:{file_id}."""
|
||||
await self.broadcast(f"chat:{file_id}", data)
|
||||
|
||||
async def send_task(self, task_id: str, data: Dict[str, Any]) -> None:
|
||||
"""Send message to a specific task status channel."""
|
||||
await self.broadcast(f"task:{task_id}", data)
|
||||
|
||||
|
||||
def get_room_users(self, file_id: int) -> list[int]:
|
||||
"""Return a list of user IDs currently in a file's chat room."""
|
||||
room_id = f"chat:{file_id}"
|
||||
return list({conn.user_id for conn in self._rooms.get(room_id, set()) if conn.user_id})
|
||||
|
||||
def get_room_size(self, room_id: str) -> int:
|
||||
"""Number of active connections in a specific room."""
|
||||
return len(self._rooms.get(room_id, set()))
|
||||
|
||||
@property
|
||||
def total_connections(self) -> int:
|
||||
"""Total number of active WebSocket connections across all rooms."""
|
||||
return len(self._ws_index)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
@@ -0,0 +1 @@
|
||||
from .vector_client import VectorStoreManager, get_model, get_qdrant_client
|
||||
@@ -0,0 +1,361 @@
|
||||
import logging
|
||||
import uuid
|
||||
import threading
|
||||
import numpy as np
|
||||
from typing import List, Optional
|
||||
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import (
|
||||
Distance,
|
||||
VectorParams,
|
||||
PointStruct,
|
||||
Filter,
|
||||
FieldCondition,
|
||||
MatchValue,
|
||||
FilterSelector,
|
||||
PayloadSchemaType,
|
||||
)
|
||||
|
||||
from app.core.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_model = None
|
||||
_model_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_model():
|
||||
"""
|
||||
Get the embedding library (SentenceTransformer) for generating vector representations.
|
||||
Uses centralized setting settings.EMBEDDING_MODEL.
|
||||
Thread-safe singleton — the model is loaded only once.
|
||||
"""
|
||||
global _model
|
||||
if _model is None:
|
||||
with _model_lock:
|
||||
if _model is None:
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
logger.info(f"Loading SentenceTransformer: {settings.EMBEDDING_MODEL}")
|
||||
_model = SentenceTransformer(settings.EMBEDDING_MODEL, device='cpu')
|
||||
_model.to('cpu')
|
||||
logger.info(f"SentenceTransformer loaded successfully (dim={_model.get_sentence_embedding_dimension()})")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load embedding model: {e}")
|
||||
raise
|
||||
return _model
|
||||
|
||||
|
||||
_qdrant_client = None
|
||||
_qdrant_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_qdrant_client() -> QdrantClient:
|
||||
"""Get or create a singleton Qdrant client."""
|
||||
global _qdrant_client
|
||||
if _qdrant_client is None:
|
||||
with _qdrant_lock:
|
||||
if _qdrant_client is None:
|
||||
_qdrant_client = QdrantClient(
|
||||
host=settings.QDRANT_HOST,
|
||||
port=settings.QDRANT_PORT,
|
||||
)
|
||||
logger.info(
|
||||
f"Connected to Qdrant at {settings.QDRANT_HOST}:{settings.QDRANT_PORT}"
|
||||
)
|
||||
return _qdrant_client
|
||||
|
||||
|
||||
_manager_instance = None
|
||||
_manager_lock = threading.Lock()
|
||||
|
||||
|
||||
class VectorStoreManager:
|
||||
"""
|
||||
Handles all vector operations via Qdrant.
|
||||
Uses singleton pattern — only one instance exists per process.
|
||||
|
||||
Architecture:
|
||||
Upload -> Chunk -> Embed (BGE) -> Qdrant -> Search -> DeepSeek
|
||||
|
||||
Each Qdrant point stores:
|
||||
- id: unique UUID
|
||||
- vector: BGE embedding
|
||||
- payload: { user_id, document_id, chunk_text, chunk_index }
|
||||
"""
|
||||
|
||||
_collection_verified = False
|
||||
|
||||
def __new__(cls):
|
||||
"""Singleton: reuse the same instance across all callers."""
|
||||
global _manager_instance
|
||||
if _manager_instance is None:
|
||||
with _manager_lock:
|
||||
if _manager_instance is None:
|
||||
instance = super().__new__(cls)
|
||||
instance._initialized = False
|
||||
_manager_instance = instance
|
||||
return _manager_instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self.client = get_qdrant_client()
|
||||
self.collection_name = settings.QDRANT_COLLECTION
|
||||
self._ensure_collection()
|
||||
self._initialized = True
|
||||
|
||||
def _ensure_collection(self):
|
||||
"""Create the Qdrant collection if it doesn't exist. Runs only once."""
|
||||
if VectorStoreManager._collection_verified:
|
||||
return
|
||||
|
||||
try:
|
||||
collections = self.client.get_collections().collections
|
||||
exists = any(c.name == self.collection_name for c in collections)
|
||||
|
||||
if not exists:
|
||||
model = get_model()
|
||||
dimension = model.get_sentence_embedding_dimension()
|
||||
|
||||
self.client.create_collection(
|
||||
collection_name=self.collection_name,
|
||||
vectors_config=VectorParams(
|
||||
size=dimension,
|
||||
distance=Distance.COSINE,
|
||||
),
|
||||
)
|
||||
|
||||
self.client.create_payload_index(
|
||||
collection_name=self.collection_name,
|
||||
field_name="user_id",
|
||||
field_schema=PayloadSchemaType.INTEGER,
|
||||
)
|
||||
self.client.create_payload_index(
|
||||
collection_name=self.collection_name,
|
||||
field_name="document_id",
|
||||
field_schema=PayloadSchemaType.KEYWORD,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created Qdrant collection '{self.collection_name}' "
|
||||
f"(dim={dimension}, distance=COSINE)"
|
||||
)
|
||||
else:
|
||||
logger.info(f"Qdrant collection '{self.collection_name}' ready")
|
||||
|
||||
VectorStoreManager._collection_verified = True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ensure Qdrant collection: {e}")
|
||||
raise
|
||||
|
||||
def create_index(self, user_id: int, document_id: str, chunks: List[str]):
|
||||
"""
|
||||
Calculates embeddings for chunks and upserts them into Qdrant.
|
||||
Returns the number of indexed chunks.
|
||||
"""
|
||||
if not chunks:
|
||||
return 0
|
||||
|
||||
user_id = int(user_id)
|
||||
model = get_model()
|
||||
|
||||
embeddings = model.encode(chunks, show_progress_bar=False)
|
||||
embeddings = np.array(embeddings).astype("float32")
|
||||
|
||||
points = []
|
||||
for idx, (chunk_text, embedding) in enumerate(zip(chunks, embeddings)):
|
||||
point_id = str(uuid.uuid4())
|
||||
points.append(
|
||||
PointStruct(
|
||||
id=point_id,
|
||||
vector=embedding.tolist(),
|
||||
payload={
|
||||
"user_id": user_id,
|
||||
"document_id": document_id,
|
||||
"chunk_text": chunk_text,
|
||||
"chunk_index": idx,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
batch_size = 100
|
||||
for i in range(0, len(points), batch_size):
|
||||
batch = points[i : i + batch_size]
|
||||
self.client.upsert(
|
||||
collection_name=self.collection_name,
|
||||
points=batch,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Indexed {len(chunks)} chunks in Qdrant "
|
||||
f"(user={user_id}, doc={document_id})"
|
||||
)
|
||||
return len(chunks)
|
||||
|
||||
except Exception as e:
|
||||
error_str = str(e)
|
||||
if "doesn't exist" in error_str or "Not found: Collection" in error_str:
|
||||
logger.warning(f"Qdrant collection '{self.collection_name}' missing. Re-creating...")
|
||||
VectorStoreManager._collection_verified = False
|
||||
self._ensure_collection()
|
||||
|
||||
batch_size = 100
|
||||
for i in range(0, len(points), batch_size):
|
||||
batch = points[i : i + batch_size]
|
||||
self.client.upsert(
|
||||
collection_name=self.collection_name,
|
||||
points=batch,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Indexed {len(chunks)} chunks in Qdrant after recreation "
|
||||
f"(user={user_id}, doc={document_id})"
|
||||
)
|
||||
return len(chunks)
|
||||
|
||||
logger.error(f"Failed to upsert vectors to Qdrant: {e}")
|
||||
raise
|
||||
|
||||
def search(
|
||||
self,
|
||||
user_id: int,
|
||||
question: str,
|
||||
document_id: Optional[str] = None,
|
||||
top_k: int = 10,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Searches Qdrant for chunks most similar to the question.
|
||||
Returns the top_k most relevant chunks with their scores.
|
||||
"""
|
||||
try:
|
||||
user_id = int(user_id)
|
||||
model = get_model()
|
||||
|
||||
query_embedding = model.encode([question], show_progress_bar=False)
|
||||
query_vector = query_embedding[0].tolist()
|
||||
|
||||
must_conditions = [
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id))
|
||||
]
|
||||
|
||||
if document_id:
|
||||
must_conditions.append(
|
||||
FieldCondition(
|
||||
key="document_id", match=MatchValue(value=document_id)
|
||||
)
|
||||
)
|
||||
|
||||
query_filter = Filter(must=must_conditions)
|
||||
|
||||
results = self.client.search(
|
||||
collection_name=self.collection_name,
|
||||
query_vector=query_vector,
|
||||
query_filter=query_filter,
|
||||
limit=top_k,
|
||||
score_threshold=None,
|
||||
)
|
||||
|
||||
min_score = settings.MIN_SIMILARITY_SCORE or 0.2
|
||||
filtered = []
|
||||
|
||||
for hit in results:
|
||||
print(f"SCORE: {hit.score:.4f} | TEXT: {hit.payload['chunk_text'][:80]}...")
|
||||
|
||||
if hit.score >= min_score:
|
||||
filtered.append({
|
||||
"text": hit.payload["chunk_text"],
|
||||
"score": hit.score
|
||||
})
|
||||
|
||||
logger.info(
|
||||
f"Qdrant search: {len(filtered)} filtered results (from {len(results)}) for user {user_id}"
|
||||
)
|
||||
return filtered
|
||||
|
||||
except Exception as e:
|
||||
error_str = str(e)
|
||||
if "doesn't exist" in error_str or "Not found: Collection" in error_str:
|
||||
logger.warning(f"Qdrant collection '{self.collection_name}' missing during search. Re-creating...")
|
||||
VectorStoreManager._collection_verified = False
|
||||
self._ensure_collection()
|
||||
return []
|
||||
|
||||
logger.error(f"Search failed for user {user_id}: {e}")
|
||||
return []
|
||||
|
||||
def delete_document(self, user_id: int, document_id: str):
|
||||
"""
|
||||
Deletes all vectors associated with a specific document from Qdrant.
|
||||
"""
|
||||
try:
|
||||
self.client.delete(
|
||||
collection_name=self.collection_name,
|
||||
points_selector=FilterSelector(
|
||||
filter=Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="user_id", match=MatchValue(value=user_id)
|
||||
),
|
||||
FieldCondition(
|
||||
key="document_id",
|
||||
match=MatchValue(value=document_id),
|
||||
),
|
||||
]
|
||||
)
|
||||
),
|
||||
)
|
||||
logger.info(f"Deleted vectors for user {user_id}, doc {document_id}")
|
||||
|
||||
except Exception as e:
|
||||
error_str = str(e)
|
||||
if "doesn't exist" in error_str or "Not found: Collection" in error_str:
|
||||
logger.warning(f"Qdrant collection '{self.collection_name}' missing during delete. Ignoring.")
|
||||
VectorStoreManager._collection_verified = False
|
||||
self._ensure_collection()
|
||||
else:
|
||||
logger.error(f"Delete failed: {e}")
|
||||
|
||||
def delete_user_data(self, user_id: int):
|
||||
"""
|
||||
Deletes ALL vectors for a user (used for account cleanup / reset).
|
||||
"""
|
||||
try:
|
||||
self.client.delete(
|
||||
collection_name=self.collection_name,
|
||||
points_selector=FilterSelector(
|
||||
filter=Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="user_id", match=MatchValue(value=user_id)
|
||||
),
|
||||
]
|
||||
)
|
||||
),
|
||||
)
|
||||
logger.info(f"Deleted all vectors for user {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
error_str = str(e)
|
||||
if "doesn't exist" in error_str or "Not found: Collection" in error_str:
|
||||
logger.warning(f"Qdrant collection '{self.collection_name}' missing during delete_user_data. Ignoring.")
|
||||
VectorStoreManager._collection_verified = False
|
||||
self._ensure_collection()
|
||||
else:
|
||||
logger.error(f"Delete all failed for user {user_id}: {e}")
|
||||
|
||||
|
||||
def preload():
|
||||
"""
|
||||
Preload the embedding model and verify Qdrant connection at startup.
|
||||
Call this during app initialization to avoid cold-start latency.
|
||||
"""
|
||||
try:
|
||||
logger.info("Preloading embedding model and Qdrant connection...")
|
||||
get_model()
|
||||
VectorStoreManager()
|
||||
logger.info("Preload complete - ready for queries")
|
||||
except Exception as e:
|
||||
logger.warning(f"Preload failed (will retry on first request): {e}")
|
||||
+449
@@ -0,0 +1,449 @@
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
import tempfile
|
||||
import shutil
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, Request, Response, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from app.middleware.csrf import CSRFMiddleware
|
||||
from app.middleware.ip_middleware import IPMiddleware
|
||||
|
||||
from app.core.settings import settings
|
||||
from app.db.database import init_db, init_db_pool, close_db_pool, get_db
|
||||
|
||||
import app.db.all_models # noqa: F401
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.auth.models.access_model import Access
|
||||
from app.modules.auth.models.role_model import Role
|
||||
from app.modules.auth.models.role_access_model import RoleAccess
|
||||
from app.modules.documents.models.document_model import Project
|
||||
|
||||
from app.modules.auth.routes.auth_routes import router as auth_router
|
||||
from app.modules.auth.routes.user_routes import router as user_router
|
||||
from app.modules.auth.routes.me_routes import router as me_router
|
||||
from app.modules.auth.routes.access_routes import router as access_router
|
||||
from app.modules.auth.routes.role_routes import router as role_router
|
||||
from app.modules.auth.routes.admin_dashboard_routes import router as admin_dashboard_router
|
||||
from app.modules.auth.routes.admin_user_routes import router as admin_user_router
|
||||
from app.modules.documents.routes.document_routes import router as projects_router
|
||||
from app.modules.documents.routes.sharing_routes import router as sharing_router
|
||||
from app.modules.documents.routes.version_routes import router as version_router
|
||||
from app.modules.documents.routes.comment_routes import (
|
||||
router as project_comments_router,
|
||||
)
|
||||
from app.modules.documents.routes.details_routes import router as project_details_router
|
||||
from app.modules.drive.routes.drive_routes import (
|
||||
router as drive_router,
|
||||
public_router as public_drive_router,
|
||||
)
|
||||
from app.modules.storage.routes.storage_routes import router as storage_router
|
||||
from app.modules.chat.routes.chat_routes import router as chatbot_router
|
||||
from app.tasks.routes import router as task_results_router
|
||||
from app.modules.notifications.routes.notification_routes import (
|
||||
router as notifications_router,
|
||||
)
|
||||
from app.modules.documents.routes.export_routes import router as export_router
|
||||
|
||||
from app.modules.speech.routes.speech_routes import router as speech_router
|
||||
from app.api.ws_router import router as ws_router
|
||||
from app.core.pubsub_listener import start_listener
|
||||
from app.core.redis import redis_pubsub
|
||||
from app.modules.collab.routes.chat_routes import (
|
||||
router as collab_chat_router,
|
||||
rest_router as collab_rest_router,
|
||||
)
|
||||
from app.modules.tenant.routes.tenant_routes import router as tenant_router
|
||||
from app.modules.tenant.routes.tenant_contact_routes import router as tenant_contact_router
|
||||
from app.modules.signing.routes.signing_routes import router as signing_router
|
||||
from app.modules.signing.routes.config_routes import router as signing_config_router
|
||||
from app.modules.configuration.routes.system_configuration_routes import (
|
||||
router as system_configuration_router,
|
||||
)
|
||||
from app.modules.activity_logs.routes import router as activity_logs_router
|
||||
from app.infrastructure.realtime.chat_subscriber import (
|
||||
start_chat_subscriber,
|
||||
stop_chat_subscriber,
|
||||
)
|
||||
from app.modules.drive.routes.scan_routes import router as scan_router
|
||||
from app.modules.extraction.routes.extraction_routes import router as extraction_router
|
||||
from app.modules.editor.pdf_router import router as pdf_editor_router
|
||||
from app.modules.auth.sso_router import router as sso_router
|
||||
from app.modules.security.routes.security_routes import router as security_router
|
||||
|
||||
TEMP_DIR = tempfile.mkdtemp(prefix="ai_uploads_")
|
||||
processing_semaphore = asyncio.Semaphore(1)
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.LOG_LEVEL),
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logging.info(f"Server is starting up in {settings.APP_ENV} mode...")
|
||||
try:
|
||||
init_db_pool()
|
||||
init_db()
|
||||
|
||||
asyncio.create_task(start_listener())
|
||||
|
||||
await redis_pubsub.connect()
|
||||
|
||||
await start_chat_subscriber()
|
||||
|
||||
from app.infrastructure.vector.vector_client import preload as preload_vector
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.run_in_executor(None, preload_vector)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Critical Startup failure: {e}")
|
||||
raise e
|
||||
|
||||
yield
|
||||
|
||||
logging.info("Server is shutting down...")
|
||||
try:
|
||||
await stop_chat_subscriber()
|
||||
await redis_pubsub.disconnect()
|
||||
logging.info("✅ Chat subscriber & Redis Pub/Sub disconnected")
|
||||
except Exception as e:
|
||||
logging.error(f"Shutdown error during subscriber/redis stop: {e}")
|
||||
try:
|
||||
close_db_pool()
|
||||
logging.info("✅ Database connection pool closed")
|
||||
except Exception as e:
|
||||
logging.error(f"Error closing database pool: {e}")
|
||||
|
||||
try:
|
||||
shutil.rmtree(TEMP_DIR, ignore_errors=True)
|
||||
logging.info("🧹 Temp directory cleaned")
|
||||
except Exception as e:
|
||||
logging.error(f"Shutdown error during temp cleanup: {e}")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
title="DocQube Document Processing Pipeline API",
|
||||
description="An interactive API and UI to process PDFs into JATS XML.",
|
||||
version="2.0.0",
|
||||
)
|
||||
|
||||
app.add_middleware(CSRFMiddleware)
|
||||
app.add_middleware(IPMiddleware)
|
||||
|
||||
from app.middleware.tenant_context_middleware import TenantContextMiddleware # noqa: E402
|
||||
|
||||
app.add_middleware(TenantContextMiddleware)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS_LIST,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
# Browsers only expose a small header safelist to JS on cross-origin
|
||||
# responses by default (Content-Range/Accept-Ranges/Content-Length are
|
||||
# NOT in it) — pdf.js reads these itself to decide whether it can use
|
||||
# HTTP Range requests for progressive PDF loading, so without this it
|
||||
# falls back to downloading the entire file before rendering anything.
|
||||
expose_headers=["Content-Range", "Accept-Ranges", "Content-Length"],
|
||||
)
|
||||
|
||||
|
||||
# File Serving Internal Helper
|
||||
async def _get_s3_file_response(key: str, db: Session = None, tenant_id: str = None):
|
||||
from app.infrastructure.storage.local_storage_handler import (
|
||||
get_storage_client,
|
||||
)
|
||||
from fastapi.responses import StreamingResponse
|
||||
import mimetypes
|
||||
|
||||
storage, quarantine_bucket, clean_bucket = get_storage_client(
|
||||
db=db, tenant_id=tenant_id)
|
||||
try:
|
||||
try:
|
||||
response = storage.get_object(Bucket=clean_bucket, Key=key)
|
||||
except Exception:
|
||||
response = storage.get_object(Bucket=quarantine_bucket, Key=key)
|
||||
|
||||
body = response["Body"]
|
||||
|
||||
content_type, _ = mimetypes.guess_type(key)
|
||||
if not content_type:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
return StreamingResponse(body, media_type=content_type)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(f"Storage File Proxy Error: {e}")
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
|
||||
@app.get("/api/storage_drive/{key:path}")
|
||||
async def serve_storage_file(key: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
if ".." in key:
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
|
||||
tenant_part = f"tenants/{current_user.tenant_id}/" if current_user.tenant_id else ""
|
||||
user_prefix1 = f"{tenant_part}user_{current_user.id}/"
|
||||
user_prefix2 = f"{tenant_part}users/{current_user.id}/"
|
||||
conv_prefix = f"{tenant_part}doc_conversion/{current_user.id}/"
|
||||
chatbot_prefix = f"{tenant_part}chatbot_documents/{current_user.id}/"
|
||||
images_prefix = f"extracted_images/{current_user.id}/"
|
||||
|
||||
allowed_prefixes = [
|
||||
user_prefix1,
|
||||
user_prefix2,
|
||||
conv_prefix,
|
||||
chatbot_prefix,
|
||||
images_prefix,
|
||||
f"user_{current_user.id}/",
|
||||
f"users/{current_user.id}/",
|
||||
f"doc_conversion/{current_user.id}/",
|
||||
f"chatbot_documents/{current_user.id}/",
|
||||
f"extracted_images/{current_user.id}/",
|
||||
]
|
||||
|
||||
if not any(key.startswith(p) for p in allowed_prefixes):
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Access denied to this storage key")
|
||||
|
||||
return await _get_s3_file_response(key, db=db, tenant_id=current_user.tenant_id)
|
||||
|
||||
|
||||
@app.get("/api/serve-image/{session_id}/{filename}")
|
||||
async def serve_image(
|
||||
session_id: str,
|
||||
filename: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Alias for serving images extracted during document processing.
|
||||
"""
|
||||
if ".." in session_id or ".." in filename:
|
||||
raise HTTPException(status_code=400, detail="Invalid parameters")
|
||||
|
||||
project = (
|
||||
db.query(Project)
|
||||
.filter(Project.session_id == session_id, Project.user_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Access denied to this session's resources"
|
||||
)
|
||||
|
||||
key = f"extracted_images/{current_user.id}/{session_id}/{filename}"
|
||||
return await _get_s3_file_response(key, db=db, tenant_id=current_user.tenant_id)
|
||||
|
||||
|
||||
def _check_database_ready(db: Session) -> bool:
|
||||
"""Lightweight DB reachability check shared by /api/health/ready and the
|
||||
legacy /api/health. On failure it rolls back before returning — without
|
||||
this, the session handed back to Depends(get_db)'s own cleanup (which
|
||||
unconditionally calls db.commit() after a successful return from this
|
||||
function) is left in an aborted-transaction state, and that commit()
|
||||
itself raises. That turns a DB failure this function already caught
|
||||
into an unhandled 500 from the dependency's own teardown — which is
|
||||
exactly what took the original /api/health down during the PgBouncer
|
||||
incident, even though it appeared to catch the error internally.
|
||||
"""
|
||||
try:
|
||||
db.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
@app.get("/api/health/live")
|
||||
def health_live():
|
||||
"""Liveness: is the API process able to serve HTTP at all. No database,
|
||||
no PgBouncer, no external dependency of any kind — this is what the
|
||||
Docker HEALTHCHECK now uses, so a temporarily unreachable database can
|
||||
no longer get the whole API container killed as unhealthy.
|
||||
"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "api",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/health/ready")
|
||||
def health_ready(response: Response, db: Session = Depends(get_db)):
|
||||
"""Readiness: is the API ready to serve real traffic, i.e. can it reach
|
||||
the database through the application's actual configured pool/engine
|
||||
(app.db.database.engine via get_db) — the same one every request uses,
|
||||
not a separate health-only connection.
|
||||
"""
|
||||
if _check_database_ready(db):
|
||||
return {
|
||||
"status": "ready",
|
||||
"database": "connected",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return {
|
||||
"status": "not_ready",
|
||||
"database": "disconnected",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health_check(db: Session = Depends(get_db)):
|
||||
"""Backward-compatible general health endpoint — response shape kept
|
||||
identical to before this refactor. Reuses the same DB-reachability
|
||||
check as /api/health/ready instead of duplicating it; the Docker
|
||||
HEALTHCHECK no longer depends on this endpoint (see /api/health/live).
|
||||
"""
|
||||
if not _check_database_ready(db):
|
||||
return {"status": "degraded", "database": "error", "error": "database unreachable"}
|
||||
|
||||
health = {"status": "ok", "database": "connected"}
|
||||
try:
|
||||
version = db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
health["migration_version"] = version
|
||||
except Exception:
|
||||
db.rollback()
|
||||
health["status"] = "degraded"
|
||||
health["error"] = "migration version lookup failed"
|
||||
|
||||
return health
|
||||
|
||||
|
||||
app.include_router(tenant_router, prefix="/api")
|
||||
app.include_router(tenant_contact_router, prefix="/api/tenant")
|
||||
app.include_router(auth_router, prefix="/api")
|
||||
app.include_router(sso_router, prefix="/api")
|
||||
app.include_router(me_router, prefix="/api")
|
||||
app.include_router(access_router)
|
||||
app.include_router(role_router)
|
||||
app.include_router(admin_dashboard_router)
|
||||
app.include_router(admin_user_router)
|
||||
app.include_router(user_router, prefix="/api")
|
||||
app.include_router(projects_router, prefix="/api")
|
||||
app.include_router(sharing_router, prefix="/api")
|
||||
app.include_router(version_router, prefix="/api")
|
||||
app.include_router(project_comments_router, prefix="/api")
|
||||
app.include_router(project_details_router, prefix="/api")
|
||||
app.include_router(drive_router, prefix="/api")
|
||||
app.include_router(public_drive_router, prefix="/api")
|
||||
app.include_router(storage_router, prefix="/api")
|
||||
app.include_router(task_results_router, prefix="/api")
|
||||
app.include_router(chatbot_router)
|
||||
app.include_router(notifications_router, prefix="/api")
|
||||
app.include_router(system_configuration_router)
|
||||
app.include_router(activity_logs_router)
|
||||
app.include_router(export_router, prefix="/api")
|
||||
|
||||
app.include_router(speech_router)
|
||||
app.include_router(ws_router, prefix="/api")
|
||||
app.include_router(collab_chat_router, prefix="/api")
|
||||
app.include_router(collab_rest_router, prefix="/api")
|
||||
app.include_router(signing_router, prefix="/api")
|
||||
app.include_router(signing_config_router, prefix="/api")
|
||||
app.include_router(pdf_editor_router, prefix="/api")
|
||||
app.include_router(scan_router, prefix="/api")
|
||||
app.include_router(extraction_router, prefix="/api")
|
||||
|
||||
from app.modules.org.routes.org_routes import router as org_router # noqa: E402
|
||||
|
||||
app.include_router(org_router, prefix="/api")
|
||||
|
||||
from app.modules.billing.routes.billing_routes import router as billing_router # noqa: E402
|
||||
|
||||
app.include_router(billing_router, prefix="/api")
|
||||
app.include_router(security_router, prefix="/api")
|
||||
|
||||
|
||||
@app.post("/api/extract")
|
||||
async def extract(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
On-demand document extraction endpoint.
|
||||
Loads models only when called and unloads after.
|
||||
"""
|
||||
file_id = str(uuid.uuid4())
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
temp_path = os.path.join(TEMP_DIR, f"{file_id}{ext}")
|
||||
|
||||
MAX_FILE_SIZE = 50 * 1024 * 1024
|
||||
|
||||
try:
|
||||
total_size = 0
|
||||
with open(temp_path, "wb") as buffer:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total_size += len(chunk)
|
||||
if total_size > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=413, detail="File too large (Max 50MB)"
|
||||
)
|
||||
buffer.write(chunk)
|
||||
|
||||
# Convert PDF to HTML locally, then derive markdown
|
||||
async with processing_semaphore:
|
||||
from app.modules.editor.converters import process_pdf_to_html
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _convert():
|
||||
html = process_pdf_to_html(temp_path)
|
||||
from bs4 import BeautifulSoup
|
||||
markdown = BeautifulSoup(
|
||||
html, "html.parser").get_text("\n")
|
||||
return {"html": html, "markdown": markdown}
|
||||
|
||||
result = await loop.run_in_executor(None, _convert)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"file_id": file_id,
|
||||
"data": result,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_id = str(uuid.uuid4())
|
||||
logging.error(
|
||||
f"Extraction failed [Ref: {error_id}]: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"An internal error occurred during processing. Please contact support and provide reference: {error_id}",
|
||||
)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except Exception as e:
|
||||
logging.error(f"Cleanup error removing {temp_path}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"main:app", host=settings.HOST, port=settings.PORT, reload=settings.DEBUG
|
||||
)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Middleware module for application-wide processing.
|
||||
"""
|
||||
@@ -0,0 +1,329 @@
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.modules.auth.models.role_model import Role
|
||||
from app.modules.auth.models.role_access_model import RoleAccess
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.core.settings import settings
|
||||
from app.core.tenant_context import set_context
|
||||
from app.core.token_blacklist import TokenBlacklist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
|
||||
|
||||
|
||||
def _validate_saas_subscription(subscription_details: Optional[dict]) -> None:
|
||||
if not subscription_details:
|
||||
return
|
||||
|
||||
status_value = str(subscription_details.get("status") or "").upper()
|
||||
is_active = subscription_details.get("is_active")
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
start_date_raw = subscription_details.get("start_date")
|
||||
end_date_raw = subscription_details.get("end_date")
|
||||
|
||||
try:
|
||||
start_date = (
|
||||
datetime.fromisoformat(start_date_raw).date() if start_date_raw else None
|
||||
)
|
||||
except ValueError:
|
||||
start_date = None
|
||||
|
||||
try:
|
||||
end_date = datetime.fromisoformat(end_date_raw).date() if end_date_raw else None
|
||||
except ValueError:
|
||||
end_date = None
|
||||
|
||||
if is_active is False or status_value in {"INACTIVE", "EXPIRED"}:
|
||||
raise HTTPException(status_code=403, detail="Tenant subscription is inactive")
|
||||
|
||||
if start_date and today < start_date:
|
||||
raise HTTPException(status_code=403, detail="Tenant subscription is not active yet")
|
||||
|
||||
if end_date and today > end_date:
|
||||
raise HTTPException(status_code=403, detail="Tenant subscription has expired")
|
||||
|
||||
|
||||
def _check_missing_subscription_claim(db, user, subscription_details) -> None:
|
||||
"""
|
||||
A platform-managed tenant arriving without a subscription claim.
|
||||
|
||||
`_validate_saas_subscription` only checks a claim that is present. Direct
|
||||
login sets none, so subscription state is enforced for SSO and not for
|
||||
direct logins — which is either deliberate or a hole, depending on how the
|
||||
product is sold.
|
||||
|
||||
`saas_tenant_mappings` is the evidence that settles it per tenant: a mapped
|
||||
tenant is billed on the platform, an unmapped one is not. So this only ever
|
||||
looks at mapped tenants, and it cannot affect a customer who was never on
|
||||
the platform.
|
||||
|
||||
**Logs always, blocks only when configured.** Leave the setting off, watch
|
||||
the warnings, and enable it once the logs show mapped tenants always arrive
|
||||
with a claim. Enforcing a billing rule at the login door is the change that
|
||||
locks real customers out if the assumption is wrong — so the observation
|
||||
comes first and the enforcement is a deliberate second step.
|
||||
|
||||
Only runs when the claim is *absent*, which is the exceptional path, so the
|
||||
extra query is not on the hot path for SSO users.
|
||||
"""
|
||||
if subscription_details:
|
||||
return
|
||||
if not user.tenant_id:
|
||||
return
|
||||
|
||||
from app.modules.auth.models.saas_models import SaaSTenantMapping
|
||||
|
||||
mapped = (
|
||||
db.query(SaaSTenantMapping.id)
|
||||
.filter(SaaSTenantMapping.docqube_tenant_id == user.tenant_id)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
if not mapped:
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
"Tenant %s is managed by the SaaS platform but this token carries no "
|
||||
"subscription claim; subscription state is not being enforced for this "
|
||||
"request (user %s)",
|
||||
user.tenant_id,
|
||||
user.id,
|
||||
)
|
||||
|
||||
if getattr(settings, "SUBSCRIPTION_REQUIRED_FOR_MAPPED_TENANTS", False):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Your subscription could not be verified. Please sign in again.",
|
||||
)
|
||||
|
||||
|
||||
_WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||||
|
||||
|
||||
def _enforce_subscription_access(db, request, user) -> None:
|
||||
"""
|
||||
A lapsed subscription degrades to read-only. It does not lock anybody out.
|
||||
|
||||
`EntitlementService.is_active()` existed, was tested, and enforced nothing —
|
||||
it was read by two API responses and by nothing else, so a cancelled
|
||||
subscription restricted precisely nothing. That is the same failure as the
|
||||
tenant context in B1: machinery built, machinery proven, machinery never
|
||||
connected.
|
||||
|
||||
**Read-only rather than refused**, ported from the base and matching how
|
||||
mature systems behave. An expired customer keeps access to their own
|
||||
documents and loses the ability to create more. Locking them out makes
|
||||
export impossible and is bad for renewals: the customer most likely to come
|
||||
back is the one who can still see what they would be coming back to.
|
||||
|
||||
Off by default, like every other enforcement added in this work. Turning it
|
||||
on is a commercial decision, and it should be made after somebody has looked
|
||||
at how many tenants are currently in a lapsed state.
|
||||
"""
|
||||
if not getattr(settings, "SUBSCRIPTION_ENFORCEMENT_ENABLED", False):
|
||||
return
|
||||
if request.method not in _WRITE_METHODS:
|
||||
return
|
||||
|
||||
tenant = getattr(user, "tenant", None)
|
||||
if tenant is None:
|
||||
return
|
||||
|
||||
from app.modules.billing.services.entitlement_service import EntitlementService
|
||||
|
||||
level = EntitlementService(db).access_level(tenant)
|
||||
if level == "full":
|
||||
return
|
||||
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail=(
|
||||
"Your subscription has lapsed. You can still read and export your "
|
||||
"documents; renewing restores the ability to make changes."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _attach_resolved_access(db, user) -> None:
|
||||
"""
|
||||
Give the user object a way to answer "what may I do" — **lazily**.
|
||||
|
||||
Before this existed there were two answers to that question. The gate
|
||||
(`require_access` -> `PermissionService`) unioned the legacy `users.role_id`
|
||||
with every live `user_roles` grant. The *response* (`UserOut.accesses` ->
|
||||
`User.access_codes`) read the legacy role alone. So granting somebody a
|
||||
second role gave them API authority the interface would not render — no menu
|
||||
entry, no route, no button — and multi-role looked broken while being
|
||||
enforced correctly.
|
||||
|
||||
**Closures, not results.** Resolving eagerly here cost three queries on every
|
||||
authenticated request, including the many that never ask — `/api/storage/usage`
|
||||
neither gates on an access code nor serialises the user, and it went from 20
|
||||
queries to 23 for an answer nobody read. Attaching the resolvers instead
|
||||
means the cost lands only where the question is asked, and
|
||||
`app/core/request_cache.py` makes the second asker free. Net effect on a
|
||||
*gated* endpoint is a reduction: `PermissionService` used to issue three
|
||||
queries per `require_access` dependency with no memo at all.
|
||||
|
||||
**Closures and not the session**, because `User` is a model and
|
||||
`.importlinter` forbids models from importing services — including inside a
|
||||
function body, which import-linter reads. Keeping the import here is what
|
||||
keeps the ORM registry independent of request handling.
|
||||
|
||||
**Runs after `set_context`.** Both `user_roles` and `roles` are tenant-owned,
|
||||
so with `TENANT_FILTER_ENABLED` resolution must happen under the correct
|
||||
tenant context or it silently returns nothing — which would present as "the
|
||||
user lost all permissions" rather than as an error.
|
||||
"""
|
||||
from app.modules.auth.services.permission_service import PermissionService
|
||||
from app.modules.auth.services.user_role_service import UserRoleReader
|
||||
|
||||
setattr(
|
||||
user,
|
||||
"_resolve_access_codes",
|
||||
lambda: PermissionService(db).user_access_codes(user),
|
||||
)
|
||||
setattr(
|
||||
user,
|
||||
"_resolve_role_refs",
|
||||
lambda: UserRoleReader(db).assignments(user),
|
||||
)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
) -> User:
|
||||
token_candidates = []
|
||||
if token:
|
||||
token_candidates.append(token)
|
||||
if request.cookies.get("docqube_access_token"):
|
||||
token_candidates.append(request.cookies.get("docqube_access_token"))
|
||||
if request.cookies.get("access_token"):
|
||||
token_candidates.append(request.cookies.get("access_token"))
|
||||
|
||||
if not token_candidates:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
for tok in token_candidates:
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
tok, settings.APP_SECRET, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
jti = payload.get("jti")
|
||||
if payload.get("type") in ("refresh", "device_management"):
|
||||
logger.warning(f"Authentication candidate has invalid token type: {payload.get('type')}")
|
||||
continue
|
||||
sub = payload.get("sub") or payload.get("id") or payload.get("user_id") or payload.get("email")
|
||||
tenant_id = payload.get("tenant_id")
|
||||
|
||||
if sub is None:
|
||||
logger.warning("Authentication candidate is missing a subject claim")
|
||||
continue
|
||||
|
||||
if jti and TokenBlacklist.is_blacklisted(jti):
|
||||
logger.warning("AUTH DEBUG: Token blacklisted")
|
||||
continue
|
||||
|
||||
session_id = payload.get("session_id")
|
||||
if session_id:
|
||||
from app.modules.auth.repositories.session_repository import SessionRepository
|
||||
session_repo = SessionRepository(db)
|
||||
session = session_repo.get_by_id(session_id)
|
||||
if not session or session.status != "ACTIVE":
|
||||
if session and session.status == "REVOKED":
|
||||
if session.revoked_by == "reauth":
|
||||
raise HTTPException(status_code=401, detail="You have logged in from another tab on this device. Please refresh.")
|
||||
else:
|
||||
raise HTTPException(status_code=401, detail="Your session was remotely logged out from another device.")
|
||||
raise HTTPException(status_code=401, detail="Your session has expired.")
|
||||
|
||||
from datetime import timedelta
|
||||
now = datetime.now(timezone.utc)
|
||||
last_activity = session.last_activity
|
||||
if last_activity and last_activity.tzinfo is None:
|
||||
last_activity = last_activity.replace(tzinfo=timezone.utc)
|
||||
if not last_activity or now - last_activity > timedelta(minutes=5):
|
||||
session_repo.update_last_activity(session)
|
||||
|
||||
user = None
|
||||
try:
|
||||
user_id = int(sub)
|
||||
user = (
|
||||
db.query(User)
|
||||
.options(
|
||||
selectinload(User.role)
|
||||
.selectinload(Role.role_accesses)
|
||||
.selectinload(RoleAccess.access)
|
||||
)
|
||||
.filter(User.id == user_id)
|
||||
.first()
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
user = (
|
||||
db.query(User)
|
||||
.options(
|
||||
selectinload(User.role)
|
||||
.selectinload(Role.role_accesses)
|
||||
.selectinload(RoleAccess.access)
|
||||
)
|
||||
.filter(User.email == str(sub))
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user:
|
||||
logger.warning("AUTH DEBUG: User sub not found in database!")
|
||||
continue
|
||||
|
||||
if getattr(user, "is_deleted", False) or not getattr(user, "is_active", True):
|
||||
logger.warning("AUTH DEBUG: User is deleted or inactive")
|
||||
continue
|
||||
|
||||
if user.tenant_id and tenant_id and str(user.tenant_id) != str(tenant_id):
|
||||
logger.warning("AUTH DEBUG: Tenant mismatch user.tenant_id vs token")
|
||||
continue
|
||||
|
||||
tenant = getattr(user, "tenant", None)
|
||||
if tenant is not None and (
|
||||
not getattr(tenant, "is_active", True)
|
||||
or getattr(tenant, "is_deleted", False)
|
||||
):
|
||||
logger.warning("Tenant %s is not active", user.tenant_id)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="This workspace has been suspended. Contact support.",
|
||||
)
|
||||
|
||||
_enforce_subscription_access(db, request, user)
|
||||
|
||||
saas_permissions = payload.get("saas_permissions", [])
|
||||
setattr(user, "saas_permissions", set(saas_permissions))
|
||||
saas_subscription = payload.get("saas_subscription")
|
||||
_validate_saas_subscription(saas_subscription)
|
||||
_check_missing_subscription_claim(db, user, saas_subscription)
|
||||
setattr(user, "saas_subscription", saas_subscription)
|
||||
|
||||
set_context(
|
||||
user.tenant_id,
|
||||
is_super=bool(getattr(user, "is_superadmin", False)),
|
||||
)
|
||||
|
||||
_attach_resolved_access(db, user)
|
||||
return user
|
||||
|
||||
except JWTError as err:
|
||||
logger.warning("Authentication candidate JWT decoding failed: %s", type(err).__name__)
|
||||
continue
|
||||
|
||||
logger.warning("AUTH DEBUG: All %d token candidates failed authentication!", len(token_candidates))
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
@@ -0,0 +1,58 @@
|
||||
import uuid
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from app.core.settings import settings
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
csrf_cookie = request.cookies.get("csrf_token")
|
||||
csrf_header = request.headers.get("x-csrf-token") or request.headers.get("X-CSRF-Token")
|
||||
|
||||
needs_validation = (
|
||||
request.method in ["POST", "PUT", "PATCH", "DELETE"] and
|
||||
not request.url.path.startswith("/api/auth/") and
|
||||
not request.url.path.startswith("/api/sso/") and
|
||||
not request.url.path == "/api/me/tutorial/finish" and
|
||||
not "/signing/ocr" in request.url.path and
|
||||
not "webhook" in request.url.path
|
||||
)
|
||||
|
||||
if needs_validation and not settings.DISABLE_CSRF:
|
||||
if not csrf_cookie or not csrf_header or csrf_cookie != csrf_header:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(
|
||||
f"CSRF Failure on {request.url.path}: "
|
||||
f"cookie_present={bool(csrf_cookie)}, "
|
||||
f"header_present={bool(csrf_header)}, "
|
||||
f"match={csrf_cookie == csrf_header}"
|
||||
)
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "CSRF verification failed"}
|
||||
)
|
||||
if not csrf_cookie:
|
||||
self._set_csrf_cookie(response)
|
||||
return response
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
if not csrf_cookie:
|
||||
self._set_csrf_cookie(response)
|
||||
|
||||
return response
|
||||
|
||||
def _set_csrf_cookie(self, response):
|
||||
"""Helper to set the CSRF cookie with appropriate security flags."""
|
||||
is_prod = settings.APP_ENV == "production"
|
||||
response.set_cookie(
|
||||
key="csrf_token",
|
||||
value=str(uuid.uuid4()),
|
||||
httponly=False,
|
||||
samesite="none" if is_prod else "lax",
|
||||
secure=is_prod,
|
||||
domain=settings.COOKIE_DOMAIN if is_prod else None,
|
||||
path="/"
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from fastapi import Request
|
||||
from app.core.context import client_ip_ctx_var, user_agent_ctx_var
|
||||
|
||||
class IPMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
x_forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(",")[0].strip()
|
||||
else:
|
||||
ip = request.client.host if request.client else None
|
||||
|
||||
client_ip_ctx_var.set(ip)
|
||||
user_agent_ctx_var.set(request.headers.get("User-Agent"))
|
||||
response = await call_next(request)
|
||||
return response
|
||||
@@ -0,0 +1,54 @@
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.modules.drive.models.drive_model import DriveACL
|
||||
|
||||
|
||||
ROLE_HIERARCHY = {
|
||||
"owner": 4,
|
||||
"editor": 3,
|
||||
"commenter": 2,
|
||||
"viewer": 1,
|
||||
}
|
||||
|
||||
|
||||
def require_role(resource_type: str, minimum_role: str):
|
||||
def checker(
|
||||
resource_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
|
||||
acl = (
|
||||
db.query(DriveACL)
|
||||
.filter(
|
||||
DriveACL.resource_type == resource_type,
|
||||
DriveACL.resource_id == resource_id,
|
||||
DriveACL.subject_type == "user",
|
||||
DriveACL.subject_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not acl:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
user_level = ROLE_HIERARCHY.get(acl.role)
|
||||
min_level = ROLE_HIERARCHY.get(minimum_role)
|
||||
|
||||
if user_level is None or min_level is None:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(
|
||||
"rbac: unknown role encountered — acl.role=%r, minimum_role=%r",
|
||||
acl.role, minimum_role,
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
if user_level < min_level:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permission")
|
||||
|
||||
return True
|
||||
|
||||
return checker
|
||||
@@ -0,0 +1,15 @@
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Content-Security-Policy"] = "default-src 'self'"
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,111 @@
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from fastapi import Request, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.modules.tenant.models.tenant_model import Tenant
|
||||
|
||||
|
||||
def get_tenant_from_header(
|
||||
request: Request, db: Session = Depends(get_db)
|
||||
) -> Optional[Tenant]:
|
||||
"""
|
||||
Extracts X-Tenant-ID from the request headers and validates it.
|
||||
Used for public routes like registration where the user is not authenticated yet.
|
||||
"""
|
||||
tenant_id_str = request.headers.get("X-Tenant-ID")
|
||||
if not tenant_id_str:
|
||||
default_tenant = db.query(Tenant).filter(Tenant.slug == "default").first()
|
||||
return default_tenant
|
||||
|
||||
try:
|
||||
tenant_id = UUID(tenant_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid X-Tenant-ID format"
|
||||
)
|
||||
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
if not tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found"
|
||||
)
|
||||
|
||||
if not tenant.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Tenant is currently inactive"
|
||||
)
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
def get_tenant_id(user: User = Depends(get_current_user)) -> Optional[UUID]:
|
||||
"""
|
||||
Extracts tenant_id from the authenticated user context.
|
||||
Returns None for superadmins.
|
||||
"""
|
||||
return user.tenant_id
|
||||
|
||||
|
||||
def is_superadmin(user: User = Depends(get_current_user)) -> bool:
|
||||
"""
|
||||
Checks whether the user is a superadmin.
|
||||
|
||||
Reads the explicit `is_superadmin` flag, and nothing else.
|
||||
|
||||
It used to also accept `tenant_id IS NULL`. That made the *absence* of
|
||||
tenant context a grant of authority: any bug that dropped the tenant did not
|
||||
deny, it escalated. The fallback was kept for exactly one release so that
|
||||
reverting the B1.0 migration could not lock the operators out mid-deploy;
|
||||
that release has now shipped and this is its removal.
|
||||
|
||||
**Deploy order matters.** This change assumes `users.is_superadmin` exists
|
||||
and has been backfilled — that is migration `b1_0_explicit_superadmin`,
|
||||
which sets the flag for every active user with no tenant. Ship this without
|
||||
that migration and every superadmin loses access. `scripts/b1_preflight.py`
|
||||
reports anyone still relying on the old signal before you find out the hard
|
||||
way.
|
||||
|
||||
A user who loses tenant context now has no tenant and no privilege, which is
|
||||
the whole point.
|
||||
"""
|
||||
return bool(getattr(user, "is_superadmin", False))
|
||||
|
||||
|
||||
def require_superadmin(user: User = Depends(get_current_user)) -> User:
|
||||
"""
|
||||
Dependency that enforces superadmin access.
|
||||
Returns the user if superadmin, raises HTTP 403 otherwise.
|
||||
"""
|
||||
if not is_superadmin(user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Superadmin access required in the Document Vault",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def require_tenant(request: Request, user: User = Depends(get_current_user)) -> UUID:
|
||||
"""
|
||||
Dependency that enforces tenant context.
|
||||
Superadmins can provide X-Tenant-ID to simulate tenant context.
|
||||
"""
|
||||
if user.tenant_id is None:
|
||||
tenant_id_str = request.headers.get("X-Tenant-ID")
|
||||
if not tenant_id_str:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Superadmins must provide X-Tenant-ID header to perform this operation",
|
||||
)
|
||||
try:
|
||||
return UUID(tenant_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid X-Tenant-ID format",
|
||||
)
|
||||
|
||||
return user.tenant_id
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
B1.4 — establish the tenant context for every request.
|
||||
|
||||
This is the wiring the rest of B1 was waiting on, and its absence was the single
|
||||
thing standing between "isolation is built" and "isolation works". The ORM
|
||||
listener (`app/core/tenant_filter.py`) and the RLS session variables
|
||||
(`app/core/rls.py`) both read `current_tenant_id()`. Both were built, tested and
|
||||
correct. Nothing in the application ever set it — the only callers of
|
||||
`scoped_to()` in the repository were the tests, which supply their own context
|
||||
and therefore proved the machinery while never exercising the wiring.
|
||||
|
||||
The consequence was quiet, because the flag defaults off: every request ran with
|
||||
no context, both layers stayed inert, and the application behaved exactly as it
|
||||
always had. Turning `TENANT_FILTER_ENABLED` on would have failed closed on every
|
||||
query in the system.
|
||||
|
||||
**Why middleware and not a dependency.** `get_current_user` is a `def`, so
|
||||
FastAPI runs it in a threadpool, and a `ContextVar` set inside a worker thread
|
||||
does not propagate back to the request's task — the write is simply lost at the
|
||||
thread boundary. Middleware runs in the request's own task, so what it sets is
|
||||
visible to every dependency and handler that follows, including the sync ones
|
||||
(the context is *copied into* the threadpool, which is all a reader needs).
|
||||
|
||||
**Why raw ASGI and not `BaseHTTPMiddleware`.** `BaseHTTPMiddleware` runs the
|
||||
downstream app in a separate task and pumps the response through a queue, which
|
||||
breaks streaming: this application serves file downloads through
|
||||
`StreamingResponse` and has a Server-Sent Events endpoint on
|
||||
`text/event-stream`, and SSE under that middleware stops arriving incrementally.
|
||||
A plain ASGI callable adds no task, no queue and no buffering — it sets a
|
||||
context variable and gets out of the way.
|
||||
|
||||
**Why the token and not the database.** Reading the user here would mean a query
|
||||
per request before any route is chosen, and that query would itself need the
|
||||
context it is trying to establish. The token is signature-verified before its
|
||||
claims are read, and `get_current_user` independently checks the tenant claim
|
||||
against the loaded row, rejecting any mismatch — so a forged claim fails there
|
||||
rather than being trusted here.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from app.core.request_cache import reset_request_cache
|
||||
from app.core.scope import reset_scope_cache
|
||||
from app.core.settings import settings
|
||||
from app.core.tenant_context import reset_context, set_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_COOKIE_NAMES = ("docqube_access_token", "access_token")
|
||||
|
||||
|
||||
def _token_from(headers: Headers) -> str | None:
|
||||
authorization = headers.get("authorization") or ""
|
||||
if authorization.lower().startswith("bearer "):
|
||||
candidate = authorization[7:].strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
cookie_header = headers.get("cookie")
|
||||
if not cookie_header:
|
||||
return None
|
||||
for part in cookie_header.split(";"):
|
||||
name, _, value = part.strip().partition("=")
|
||||
if name in _COOKIE_NAMES and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _context_from_token(token: str) -> tuple[uuid.UUID | None, bool] | None:
|
||||
"""The tenant and privilege a verified token asserts, or None if unusable."""
|
||||
try:
|
||||
claims = jwt.decode(token, settings.APP_SECRET, algorithms=[settings.ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
if claims.get("type") in ("refresh", "device_management"):
|
||||
return None
|
||||
|
||||
tenant_id = None
|
||||
raw = claims.get("tenant_id")
|
||||
if raw:
|
||||
try:
|
||||
tenant_id = uuid.UUID(str(raw))
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
logger.warning("Token carried an unparseable tenant_id")
|
||||
|
||||
is_super = bool(claims.get("is_superadmin", False))
|
||||
|
||||
if tenant_id is None and not is_super:
|
||||
return None
|
||||
return tenant_id, is_super
|
||||
|
||||
|
||||
class TenantContextMiddleware:
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] not in ("http", "websocket"):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
reset_scope_cache()
|
||||
reset_request_cache()
|
||||
|
||||
tokens = None
|
||||
raw_token = _token_from(Headers(scope=scope))
|
||||
if raw_token:
|
||||
resolved = _context_from_token(raw_token)
|
||||
if resolved is not None:
|
||||
tenant_id, is_super = resolved
|
||||
tokens = set_context(tenant_id, is_super=is_super)
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
finally:
|
||||
if tokens is not None:
|
||||
try:
|
||||
reset_context(tokens)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def install(app: ASGIApp) -> None:
|
||||
app.add_middleware(TenantContextMiddleware)
|
||||
|
||||
|
||||
__all__ = ["TenantContextMiddleware", "install"]
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.modules.activity_logs.service import log_drive_event, log_event, log_user_event
|
||||
|
||||
__all__ = ["log_event", "log_drive_event", "log_user_event"]
|
||||
@@ -0,0 +1,67 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ActivityLogModule(str, Enum):
|
||||
DRIVE = "drive"
|
||||
EDITOR = "editor"
|
||||
CONVERSION = "conversion"
|
||||
SIGNING = "signing"
|
||||
COLLABORATION = "collaboration"
|
||||
ACCESS = "access"
|
||||
TENANT = "tenant"
|
||||
|
||||
|
||||
class ActivityLogStatus(str, Enum):
|
||||
SUCCESS = "success"
|
||||
FAILURE = "failure"
|
||||
|
||||
|
||||
class ActivityLogTargetType(str, Enum):
|
||||
DOCUMENT = "document"
|
||||
FOLDER = "folder"
|
||||
VERSION = "version"
|
||||
SESSION = "session"
|
||||
TENANT = "tenant"
|
||||
SIGNING_REQUEST = "signing_request"
|
||||
ROLE = "role"
|
||||
USER = "user"
|
||||
ORG_UNIT = "org_unit"
|
||||
GROUP = "group"
|
||||
|
||||
|
||||
class ActivityLogAction(str, Enum):
|
||||
FOLDER_CREATED = "folder_created"
|
||||
FOLDER_DELETED = "folder_deleted"
|
||||
DOCUMENT_UPLOADED = "document_uploaded"
|
||||
DOCUMENT_DELETED = "document_deleted"
|
||||
DOCUMENT_MOVED = "document_moved"
|
||||
DOCUMENT_SHARED = "document_shared"
|
||||
ACCESS_REVOKED = "access_revoked"
|
||||
DOCUMENT_EDITED = "document_edited"
|
||||
CONVERSION_COMPLETED = "conversion_completed"
|
||||
DOCUMENT_SIGNED = "document_signed"
|
||||
DOCUMENT_EMAILED = "document_emailed"
|
||||
SIGN_REQUEST_SENT = "sign_request_sent"
|
||||
CONFIG_SMTP_UPDATED = "config_smtp_updated"
|
||||
CONFIG_STORAGE_UPDATED = "config_storage_updated"
|
||||
CONFIG_COMPRESSION_UPDATED = "config_compression_updated"
|
||||
CONFIG_SIGNING_UPDATED = "config_signing_updated"
|
||||
CONTACT_ADDED = "contact_added"
|
||||
CONTACT_UPDATED = "contact_updated"
|
||||
CONTACT_DELETED = "contact_deleted"
|
||||
DEVICE_LOGOUT = "device_logout"
|
||||
DEVICE_LOGIN = "device_login"
|
||||
ROLE_CREATED = "role_created"
|
||||
USER_DEACTIVATED = "user_deactivated"
|
||||
SIGN_REMINDER_SENT = "sign_reminder_sent"
|
||||
|
||||
ROLE_GRANTED = "role_granted"
|
||||
ROLE_REVOKED = "role_revoked"
|
||||
ROLE_EXPIRED = "role_expired"
|
||||
ORG_UNIT_CREATED = "org_unit_created"
|
||||
ORG_UNIT_RENAMED = "org_unit_renamed"
|
||||
ORG_UNIT_MOVED = "org_unit_moved"
|
||||
ORG_UNIT_DELETED = "org_unit_deleted"
|
||||
GROUP_CREATED = "group_created"
|
||||
GROUP_DELETED = "group_deleted"
|
||||
DOCUMENT_SIGNATURE_BROKEN = "document_signature_broken"
|
||||
@@ -0,0 +1,50 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class ActivityLog(Base):
|
||||
__tablename__ = "activity_logs"
|
||||
__table_args__ = (
|
||||
Index("ix_activity_logs_tenant_created_at", "tenant_id", "created_at"),
|
||||
Index("ix_activity_logs_user_created_at", "user_id", "created_at"),
|
||||
Index("ix_activity_logs_module_action", "module", "action"),
|
||||
Index("ix_activity_logs_target_id", "target_id"),
|
||||
Index(
|
||||
"ix_activity_logs_access_timeline",
|
||||
"tenant_id",
|
||||
"created_at",
|
||||
postgresql_where=text("module = 'access'"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
|
||||
)
|
||||
user_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
user_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
user_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
module: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
target_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
target_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
target_name: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||
event_metadata: Mapped[dict] = mapped_column("metadata", JSONB, nullable=False)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
request_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
|
||||
tenant = relationship("Tenant")
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
@@ -0,0 +1,112 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.middleware.tenant import require_tenant
|
||||
from app.modules.activity_logs.schemas import ActivityLogUserOption, PaginatedActivityLogsOut
|
||||
from app.modules.activity_logs.service import list_activity_logs, search_activity_log_users
|
||||
from app.modules.auth.dependencies.access_dependency import require_access
|
||||
from app.modules.auth.models.user_model import User
|
||||
|
||||
router = APIRouter(prefix="/api/activity-logs", tags=["Activity Logs"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=PaginatedActivityLogsOut,
|
||||
)
|
||||
def get_my_activity_logs(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
module: Optional[str] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
target_id: Optional[str] = Query(None),
|
||||
target_name: Optional[str] = Query(None),
|
||||
target_type: Optional[str] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return list_activity_logs(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
user_id=current_user.id,
|
||||
module=module,
|
||||
action=action,
|
||||
status=status,
|
||||
target_id=target_id,
|
||||
target_name=target_name,
|
||||
target_type=target_type,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
search=search,
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=PaginatedActivityLogsOut,
|
||||
dependencies=[require_access("admin.logs.read")],
|
||||
)
|
||||
def get_activity_logs(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user_id: Optional[int] = Query(None),
|
||||
module: Optional[str] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
target_id: Optional[str] = Query(None),
|
||||
target_name: Optional[str] = Query(None),
|
||||
target_type: Optional[str] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return list_activity_logs(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
user_id=user_id,
|
||||
module=module,
|
||||
action=action,
|
||||
status=status,
|
||||
target_id=target_id,
|
||||
target_name=target_name,
|
||||
target_type=target_type,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
search=search,
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/users/search",
|
||||
response_model=list[ActivityLogUserOption],
|
||||
dependencies=[require_access("admin.logs.read")],
|
||||
)
|
||||
def search_activity_log_users_route(
|
||||
q: str = Query(..., min_length=1),
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return search_activity_log_users(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
query=q,
|
||||
limit=limit,
|
||||
)
|
||||
@@ -0,0 +1,227 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_serializer, model_validator
|
||||
|
||||
from app.modules.activity_logs.constants import (
|
||||
ActivityLogAction,
|
||||
ActivityLogModule,
|
||||
ActivityLogStatus,
|
||||
ActivityLogTargetType,
|
||||
)
|
||||
|
||||
|
||||
class FolderCreatedDeletedMetadata(BaseModel):
|
||||
parent_folder_id: int | None
|
||||
|
||||
|
||||
class DocumentUploadedMetadata(BaseModel):
|
||||
file_size: int = Field(ge=0)
|
||||
file_type: str
|
||||
folder_id: int
|
||||
|
||||
|
||||
class DocumentDeletedMetadata(BaseModel):
|
||||
folder_id: int
|
||||
|
||||
|
||||
class DocumentMovedMetadata(BaseModel):
|
||||
from_folder_id: int
|
||||
to_folder_id: int
|
||||
from_folder: str
|
||||
to_folder: str
|
||||
|
||||
|
||||
class DocumentSharedMetadata(BaseModel):
|
||||
shared_with: EmailStr
|
||||
permission: str
|
||||
entity_type: str
|
||||
|
||||
|
||||
class AccessRevokedMetadata(BaseModel):
|
||||
revoked_from: EmailStr
|
||||
|
||||
|
||||
class DocumentEditedMetadata(BaseModel):
|
||||
version_number: int = Field(ge=1)
|
||||
user_agent: str | None = None
|
||||
|
||||
|
||||
|
||||
class ConversionCompletedMetadata(BaseModel):
|
||||
from_: str = Field(alias="from")
|
||||
to: str
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class DocumentSignedMetadata(BaseModel):
|
||||
signed_by: EmailStr
|
||||
signed_at: datetime
|
||||
|
||||
|
||||
class DocumentEmailedMetadata(BaseModel):
|
||||
to_emails: list[str]
|
||||
cc_emails: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SignRequestSentMetadata(BaseModel):
|
||||
recipients: list[str]
|
||||
total_signers: int
|
||||
provider: str
|
||||
|
||||
|
||||
class ConfigUpdatedMetadata(BaseModel):
|
||||
updated_by: str | None = None
|
||||
config_type: str
|
||||
|
||||
|
||||
class ContactActionMetadata(BaseModel):
|
||||
contact_email: str
|
||||
contact_name: str | None = None
|
||||
|
||||
|
||||
class DeviceLogoutMetadata(BaseModel):
|
||||
device_name: str
|
||||
ip_address: str | None = None
|
||||
|
||||
|
||||
class DeviceLoginMetadata(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class RoleCreatedMetadata(BaseModel):
|
||||
role_name: str
|
||||
|
||||
|
||||
class UserDeactivatedMetadata(BaseModel):
|
||||
deactivated_user_id: int
|
||||
deactivated_user_email: str
|
||||
|
||||
|
||||
class SignReminderMetadata(BaseModel):
|
||||
recipient_email: str
|
||||
request_id: str
|
||||
|
||||
|
||||
ACTION_METADATA_MODELS = {
|
||||
ActivityLogAction.FOLDER_CREATED: FolderCreatedDeletedMetadata,
|
||||
ActivityLogAction.FOLDER_DELETED: FolderCreatedDeletedMetadata,
|
||||
ActivityLogAction.DOCUMENT_UPLOADED: DocumentUploadedMetadata,
|
||||
ActivityLogAction.DOCUMENT_DELETED: DocumentDeletedMetadata,
|
||||
ActivityLogAction.DOCUMENT_MOVED: DocumentMovedMetadata,
|
||||
ActivityLogAction.DOCUMENT_SHARED: DocumentSharedMetadata,
|
||||
ActivityLogAction.ACCESS_REVOKED: AccessRevokedMetadata,
|
||||
ActivityLogAction.DOCUMENT_EDITED: DocumentEditedMetadata,
|
||||
ActivityLogAction.DOCUMENT_SIGNATURE_BROKEN: DocumentEditedMetadata,
|
||||
ActivityLogAction.CONVERSION_COMPLETED: ConversionCompletedMetadata,
|
||||
ActivityLogAction.DOCUMENT_SIGNED: DocumentSignedMetadata,
|
||||
ActivityLogAction.DOCUMENT_EMAILED: DocumentEmailedMetadata,
|
||||
ActivityLogAction.SIGN_REQUEST_SENT: SignRequestSentMetadata,
|
||||
ActivityLogAction.CONFIG_SMTP_UPDATED: ConfigUpdatedMetadata,
|
||||
ActivityLogAction.CONFIG_STORAGE_UPDATED: ConfigUpdatedMetadata,
|
||||
ActivityLogAction.CONFIG_COMPRESSION_UPDATED: ConfigUpdatedMetadata,
|
||||
ActivityLogAction.CONFIG_SIGNING_UPDATED: ConfigUpdatedMetadata,
|
||||
ActivityLogAction.CONTACT_ADDED: ContactActionMetadata,
|
||||
ActivityLogAction.CONTACT_UPDATED: ContactActionMetadata,
|
||||
ActivityLogAction.CONTACT_DELETED: ContactActionMetadata,
|
||||
ActivityLogAction.DEVICE_LOGOUT: DeviceLogoutMetadata,
|
||||
ActivityLogAction.DEVICE_LOGIN: DeviceLoginMetadata,
|
||||
ActivityLogAction.ROLE_CREATED: RoleCreatedMetadata,
|
||||
ActivityLogAction.USER_DEACTIVATED: UserDeactivatedMetadata,
|
||||
ActivityLogAction.SIGN_REMINDER_SENT: SignReminderMetadata,
|
||||
}
|
||||
|
||||
ACTION_MODULES = {
|
||||
ActivityLogAction.FOLDER_CREATED: ActivityLogModule.DRIVE,
|
||||
ActivityLogAction.FOLDER_DELETED: ActivityLogModule.DRIVE,
|
||||
ActivityLogAction.DOCUMENT_UPLOADED: ActivityLogModule.DRIVE,
|
||||
ActivityLogAction.DOCUMENT_DELETED: ActivityLogModule.DRIVE,
|
||||
ActivityLogAction.DOCUMENT_MOVED: ActivityLogModule.DRIVE,
|
||||
ActivityLogAction.DOCUMENT_SHARED: ActivityLogModule.DRIVE,
|
||||
ActivityLogAction.ACCESS_REVOKED: ActivityLogModule.DRIVE,
|
||||
ActivityLogAction.DOCUMENT_EDITED: ActivityLogModule.EDITOR,
|
||||
ActivityLogAction.DOCUMENT_SIGNATURE_BROKEN: ActivityLogModule.EDITOR,
|
||||
ActivityLogAction.CONVERSION_COMPLETED: ActivityLogModule.CONVERSION,
|
||||
ActivityLogAction.DOCUMENT_SIGNED: ActivityLogModule.SIGNING,
|
||||
ActivityLogAction.DOCUMENT_EMAILED: ActivityLogModule.DRIVE,
|
||||
ActivityLogAction.SIGN_REQUEST_SENT: ActivityLogModule.SIGNING,
|
||||
ActivityLogAction.CONFIG_SMTP_UPDATED: ActivityLogModule.TENANT,
|
||||
ActivityLogAction.CONFIG_STORAGE_UPDATED: ActivityLogModule.TENANT,
|
||||
ActivityLogAction.CONFIG_COMPRESSION_UPDATED: ActivityLogModule.TENANT,
|
||||
ActivityLogAction.CONFIG_SIGNING_UPDATED: ActivityLogModule.TENANT,
|
||||
ActivityLogAction.CONTACT_ADDED: ActivityLogModule.TENANT,
|
||||
ActivityLogAction.CONTACT_UPDATED: ActivityLogModule.TENANT,
|
||||
ActivityLogAction.CONTACT_DELETED: ActivityLogModule.TENANT,
|
||||
ActivityLogAction.DEVICE_LOGOUT: ActivityLogModule.TENANT,
|
||||
ActivityLogAction.DEVICE_LOGIN: ActivityLogModule.TENANT,
|
||||
ActivityLogAction.ROLE_CREATED: ActivityLogModule.TENANT,
|
||||
ActivityLogAction.USER_DEACTIVATED: ActivityLogModule.TENANT,
|
||||
ActivityLogAction.SIGN_REMINDER_SENT: ActivityLogModule.SIGNING,
|
||||
}
|
||||
|
||||
|
||||
class ActivityLogCreate(BaseModel):
|
||||
tenant_id: UUID
|
||||
user_id: int | None = None
|
||||
user_name: str | None = None
|
||||
user_email: EmailStr | None = None
|
||||
module: ActivityLogModule
|
||||
action: ActivityLogAction
|
||||
target_id: str
|
||||
target_type: ActivityLogTargetType
|
||||
target_name: str | None = None
|
||||
metadata: dict[str, Any]
|
||||
ip_address: str | None = None
|
||||
request_id: str | None = None
|
||||
status: ActivityLogStatus
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_action_contract(self) -> "ActivityLogCreate":
|
||||
expected_module = ACTION_MODULES[self.action]
|
||||
if self.module != expected_module:
|
||||
raise ValueError(
|
||||
f"Action {self.action.value} must use module {expected_module.value}"
|
||||
)
|
||||
|
||||
metadata_model = ACTION_METADATA_MODELS[self.action]
|
||||
validated = metadata_model.model_validate(self.metadata)
|
||||
self.metadata = validated.model_dump(mode="json", by_alias=True)
|
||||
return self
|
||||
|
||||
|
||||
class ActivityLogOut(BaseModel):
|
||||
id: int
|
||||
tenant_id: UUID
|
||||
user_id: int | None = None
|
||||
user_name: str | None = None
|
||||
user_email: str | None = None
|
||||
module: str
|
||||
action: str
|
||||
target_id: str
|
||||
target_type: str
|
||||
target_name: str | None = None
|
||||
metadata: dict[str, Any]
|
||||
ip_address: str | None = None
|
||||
created_at: datetime
|
||||
request_id: str | None = None
|
||||
status: str
|
||||
description: str
|
||||
|
||||
@field_serializer("tenant_id")
|
||||
def serialize_tenant_id(self, tenant_id: UUID) -> str:
|
||||
return str(tenant_id)
|
||||
|
||||
|
||||
class PaginatedActivityLogsOut(BaseModel):
|
||||
items: list[ActivityLogOut]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
|
||||
class ActivityLogUserOption(BaseModel):
|
||||
user_id: int
|
||||
user_name: str | None = None
|
||||
user_email: str | None = None
|
||||
@@ -0,0 +1,339 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.context import client_ip_ctx_var
|
||||
from app.db.database import SessionLocal
|
||||
from app.modules.activity_logs.constants import (
|
||||
ActivityLogAction,
|
||||
ActivityLogModule,
|
||||
ActivityLogStatus,
|
||||
ActivityLogTargetType,
|
||||
)
|
||||
from app.modules.activity_logs.models.activity_log import ActivityLog
|
||||
from app.modules.activity_logs.schemas import (
|
||||
ActivityLogCreate,
|
||||
ActivityLogOut,
|
||||
ActivityLogUserOption,
|
||||
PaginatedActivityLogsOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log_event(
|
||||
*,
|
||||
tenant_id,
|
||||
user_id=None,
|
||||
user_name=None,
|
||||
user_email=None,
|
||||
module,
|
||||
action,
|
||||
target_id,
|
||||
target_type,
|
||||
target_name=None,
|
||||
metadata: dict[str, Any],
|
||||
ip_address=None,
|
||||
request_id=None,
|
||||
status,
|
||||
) -> int | None:
|
||||
try:
|
||||
payload = ActivityLogCreate(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
user_email=user_email,
|
||||
module=module,
|
||||
action=action,
|
||||
target_id=str(target_id),
|
||||
target_type=target_type,
|
||||
target_name=target_name,
|
||||
metadata=metadata,
|
||||
ip_address=ip_address,
|
||||
request_id=request_id,
|
||||
status=status,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Activity log validation failed")
|
||||
return None
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
row = ActivityLog(
|
||||
tenant_id=payload.tenant_id,
|
||||
user_id=payload.user_id,
|
||||
user_name=payload.user_name,
|
||||
user_email=payload.user_email,
|
||||
module=payload.module.value,
|
||||
action=payload.action.value,
|
||||
target_id=payload.target_id,
|
||||
target_type=payload.target_type.value,
|
||||
target_name=payload.target_name,
|
||||
event_metadata=payload.metadata,
|
||||
ip_address=payload.ip_address,
|
||||
request_id=payload.request_id,
|
||||
status=payload.status.value,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return row.id
|
||||
except SQLAlchemyError:
|
||||
session.rollback()
|
||||
logger.exception("Activity log insert failed")
|
||||
return None
|
||||
except Exception:
|
||||
session.rollback()
|
||||
logger.exception("Unexpected activity log failure")
|
||||
return None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def log_drive_event(
|
||||
*,
|
||||
user,
|
||||
action: ActivityLogAction,
|
||||
target_id,
|
||||
target_type: ActivityLogTargetType,
|
||||
target_name: str | None,
|
||||
metadata: dict[str, Any],
|
||||
status: ActivityLogStatus = ActivityLogStatus.SUCCESS,
|
||||
request_id: str | None = None,
|
||||
) -> int | None:
|
||||
return log_event(
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
user_name=user.name,
|
||||
user_email=user.email,
|
||||
module=ActivityLogModule.DRIVE,
|
||||
action=action,
|
||||
target_id=target_id,
|
||||
target_type=target_type,
|
||||
target_name=target_name,
|
||||
metadata=metadata,
|
||||
ip_address=client_ip_ctx_var.get(),
|
||||
request_id=request_id,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def log_user_event(
|
||||
*,
|
||||
user,
|
||||
module: ActivityLogModule,
|
||||
action: ActivityLogAction,
|
||||
target_id,
|
||||
target_type: ActivityLogTargetType,
|
||||
target_name: str | None,
|
||||
metadata: dict[str, Any],
|
||||
status: ActivityLogStatus = ActivityLogStatus.SUCCESS,
|
||||
request_id: str | None = None,
|
||||
) -> int | None:
|
||||
return log_event(
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
user_name=user.name,
|
||||
user_email=user.email,
|
||||
module=module,
|
||||
action=action,
|
||||
target_id=target_id,
|
||||
target_type=target_type,
|
||||
target_name=target_name,
|
||||
metadata=metadata,
|
||||
ip_address=client_ip_ctx_var.get(),
|
||||
request_id=request_id,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def build_description(action: str, target_name: str | None, metadata: dict[str, Any]) -> str:
|
||||
quoted_target = f"'{target_name}'" if target_name else "item"
|
||||
|
||||
if action == ActivityLogAction.DOCUMENT_UPLOADED.value:
|
||||
return f"Document {quoted_target} uploaded"
|
||||
if action == ActivityLogAction.DOCUMENT_DELETED.value:
|
||||
return f"Document {quoted_target} deleted"
|
||||
if action == ActivityLogAction.FOLDER_CREATED.value:
|
||||
return f"Folder {quoted_target} created"
|
||||
if action == ActivityLogAction.FOLDER_DELETED.value:
|
||||
return f"Folder {quoted_target} deleted"
|
||||
if action == ActivityLogAction.DOCUMENT_MOVED.value:
|
||||
return (
|
||||
f"Document moved from '{metadata.get('from_folder', '')}' "
|
||||
f"to '{metadata.get('to_folder', '')}'"
|
||||
)
|
||||
if action == ActivityLogAction.DOCUMENT_SHARED.value:
|
||||
return f"Document {quoted_target} shared with {metadata.get('shared_with', 'recipient')}"
|
||||
if action == ActivityLogAction.ACCESS_REVOKED.value:
|
||||
return f"Access revoked from {metadata.get('revoked_from', 'recipient')} for {quoted_target}"
|
||||
if action == ActivityLogAction.DOCUMENT_EDITED.value:
|
||||
return (
|
||||
f"Document {quoted_target} edited"
|
||||
f" (version {metadata.get('version_number', 'unknown')})"
|
||||
)
|
||||
if action == ActivityLogAction.CONVERSION_COMPLETED.value:
|
||||
return (
|
||||
f"Document {quoted_target} converted from "
|
||||
f"{metadata.get('from', 'unknown')} to {metadata.get('to', 'unknown')}"
|
||||
)
|
||||
if action == ActivityLogAction.DOCUMENT_SIGNED.value:
|
||||
return f"Document signed by {metadata.get('signed_by', 'unknown signer')}"
|
||||
if action == ActivityLogAction.DOCUMENT_EMAILED.value:
|
||||
to_emails = metadata.get('to_emails', [])
|
||||
return f"Document {quoted_target} emailed to {', '.join(to_emails)}"
|
||||
if action == ActivityLogAction.SIGN_REQUEST_SENT.value:
|
||||
return f"Sign request sent for {quoted_target} via {metadata.get('provider', 'unknown')}"
|
||||
if action == ActivityLogAction.CONFIG_SMTP_UPDATED.value:
|
||||
return "SMTP configuration updated"
|
||||
if action == ActivityLogAction.CONFIG_STORAGE_UPDATED.value:
|
||||
return "Storage configuration updated"
|
||||
if action == ActivityLogAction.CONFIG_COMPRESSION_UPDATED.value:
|
||||
return "Compression configuration updated"
|
||||
if action == ActivityLogAction.CONFIG_SIGNING_UPDATED.value:
|
||||
return "Signing configuration updated"
|
||||
if action == ActivityLogAction.CONTACT_ADDED.value:
|
||||
return f"Contact {metadata.get('contact_email', '')} added to address book"
|
||||
if action == ActivityLogAction.CONTACT_UPDATED.value:
|
||||
return f"Contact {metadata.get('contact_email', '')} updated in address book"
|
||||
if action == ActivityLogAction.CONTACT_DELETED.value:
|
||||
return f"Contact {metadata.get('contact_email', '')} deleted from address book"
|
||||
if action == ActivityLogAction.DEVICE_LOGOUT.value:
|
||||
return f"Device {metadata.get('device_name', 'unknown')} logged out"
|
||||
if action == ActivityLogAction.ROLE_CREATED.value:
|
||||
return f"Role {metadata.get('role_name', 'unknown')} created"
|
||||
if action == ActivityLogAction.USER_DEACTIVATED.value:
|
||||
return f"User {metadata.get('deactivated_user_email', 'unknown')} deactivated"
|
||||
if action == ActivityLogAction.SIGN_REMINDER_SENT.value:
|
||||
return f"Sign reminder sent to {metadata.get('recipient_email', 'unknown')}"
|
||||
|
||||
return f"{action.replace('_', ' ').capitalize()} for {quoted_target}"
|
||||
|
||||
|
||||
def list_activity_logs(
|
||||
*,
|
||||
db: Session,
|
||||
tenant_id,
|
||||
page: int,
|
||||
page_size: int,
|
||||
user_id: int | None = None,
|
||||
module: str | None = None,
|
||||
action: str | None = None,
|
||||
status: str | None = None,
|
||||
target_id: str | None = None,
|
||||
target_name: str | None = None,
|
||||
target_type: str | None = None,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
search: str | None = None,
|
||||
) -> PaginatedActivityLogsOut:
|
||||
query = db.query(ActivityLog).filter(ActivityLog.tenant_id == tenant_id)
|
||||
|
||||
if user_id is not None:
|
||||
query = query.filter(ActivityLog.user_id == user_id)
|
||||
if module:
|
||||
query = query.filter(ActivityLog.module == module)
|
||||
if action:
|
||||
query = query.filter(ActivityLog.action == action)
|
||||
if status:
|
||||
query = query.filter(ActivityLog.status == status)
|
||||
if target_id:
|
||||
query = query.filter(ActivityLog.target_id == str(target_id))
|
||||
if target_name:
|
||||
query = query.filter(ActivityLog.target_name.ilike(f"%{target_name}%"))
|
||||
if target_type:
|
||||
query = query.filter(ActivityLog.target_type == target_type)
|
||||
if date_from:
|
||||
query = query.filter(ActivityLog.created_at >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(ActivityLog.created_at <= date_to)
|
||||
if search and search.strip():
|
||||
term = f"%{search.strip()}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
ActivityLog.user_name.ilike(term),
|
||||
ActivityLog.target_name.ilike(term),
|
||||
)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
items = (
|
||||
query.order_by(ActivityLog.created_at.desc(), ActivityLog.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
rows = [
|
||||
ActivityLogOut(
|
||||
id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
user_id=row.user_id,
|
||||
user_name=row.user_name,
|
||||
user_email=row.user_email,
|
||||
module=row.module,
|
||||
action=row.action,
|
||||
target_id=row.target_id,
|
||||
target_type=row.target_type,
|
||||
target_name=row.target_name,
|
||||
metadata=row.event_metadata,
|
||||
ip_address=row.ip_address,
|
||||
created_at=row.created_at,
|
||||
request_id=row.request_id,
|
||||
status=row.status,
|
||||
description=build_description(row.action, row.target_name, row.event_metadata),
|
||||
)
|
||||
for row in items
|
||||
]
|
||||
|
||||
return PaginatedActivityLogsOut(
|
||||
items=rows,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=ceil(total / page_size) if total else 0,
|
||||
)
|
||||
|
||||
def search_activity_log_users(
|
||||
*,
|
||||
db: Session,
|
||||
tenant_id,
|
||||
query: str,
|
||||
limit: int,
|
||||
) -> list[ActivityLogUserOption]:
|
||||
term = query.strip()
|
||||
if not term:
|
||||
return []
|
||||
|
||||
rows = (
|
||||
db.query(
|
||||
ActivityLog.user_id,
|
||||
func.max(ActivityLog.user_name).label("user_name"),
|
||||
func.max(ActivityLog.user_email).label("user_email"),
|
||||
)
|
||||
.filter(
|
||||
ActivityLog.tenant_id == tenant_id,
|
||||
ActivityLog.user_id.isnot(None),
|
||||
or_(
|
||||
ActivityLog.user_name.ilike(f"%{term}%"),
|
||||
ActivityLog.user_email.ilike(f"%{term}%"),
|
||||
),
|
||||
)
|
||||
.group_by(ActivityLog.user_id)
|
||||
.order_by(func.max(ActivityLog.created_at).desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
ActivityLogUserOption(
|
||||
user_id=row.user_id,
|
||||
user_name=row.user_name,
|
||||
user_email=row.user_email,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.auth.services.auth_service import AuthService, UserService
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class AuthController:
|
||||
@staticmethod
|
||||
def register_user(payload: Any, tenant: Any, db: Session):
|
||||
auth_service = AuthService(db)
|
||||
return auth_service.register(payload, tenant)
|
||||
|
||||
@staticmethod
|
||||
def login_user(form_data: Any, db: Session, request: Optional[Any] = None):
|
||||
auth_service = AuthService(db)
|
||||
return auth_service.login(form_data.username, form_data.password, request)
|
||||
|
||||
@staticmethod
|
||||
def google_login(payload: Any, db: Session, request: Optional[Any] = None):
|
||||
auth_service = AuthService(db)
|
||||
return auth_service.google_login(payload, request)
|
||||
|
||||
@staticmethod
|
||||
def forgot_password(payload: Any, db: Session):
|
||||
auth_service = AuthService(db)
|
||||
return auth_service.forgot_password(payload)
|
||||
|
||||
@staticmethod
|
||||
def reset_password(payload: Any, db: Session):
|
||||
auth_service = AuthService(db)
|
||||
return auth_service.reset_password(payload)
|
||||
|
||||
|
||||
class UserController:
|
||||
@staticmethod
|
||||
def update_profile(
|
||||
user: User,
|
||||
name: Optional[str],
|
||||
db: Session,
|
||||
designation: Optional[str] = None,
|
||||
preferred_language: Optional[str] = None,
|
||||
):
|
||||
user_service = UserService(db)
|
||||
return user_service.update_profile(
|
||||
user,
|
||||
name=name,
|
||||
designation=designation,
|
||||
preferred_language=preferred_language,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def change_password(
|
||||
user: User, current_password: str, new_password: str, db: Session
|
||||
):
|
||||
user_service = UserService(db)
|
||||
return user_service.change_password(user, current_password, new_password)
|
||||
|
||||
@staticmethod
|
||||
def get_user_stats(user: User, db: Session):
|
||||
user_service = UserService(db)
|
||||
return user_service.get_user_stats(user)
|
||||
|
||||
@staticmethod
|
||||
def delete_account(user: User, db: Session):
|
||||
user_service = UserService(db)
|
||||
return user_service.delete_account(user)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Role Controller - handles role management API logic.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from app.modules.auth.models.role_model import Role
|
||||
from app.modules.auth.schemas.role_schema import (
|
||||
RoleCreate,
|
||||
RoleUpdate,
|
||||
RoleOut,
|
||||
RoleWithAccessesOut,
|
||||
RolePaginatedOut,
|
||||
)
|
||||
from app.modules.auth.services.role_service import RoleService
|
||||
|
||||
|
||||
class RoleController:
|
||||
"""Controller for role management endpoints."""
|
||||
|
||||
@staticmethod
|
||||
def create_role(db: Session, role_data: RoleCreate) -> RoleOut:
|
||||
"""Create a new role."""
|
||||
role = RoleService.create_role(db, role_data)
|
||||
return RoleOut.model_validate(role)
|
||||
|
||||
@staticmethod
|
||||
def get_all_roles(db: Session, tenant_id: Optional[UUID] = None) -> List[RoleOut]:
|
||||
"""Get all roles."""
|
||||
roles = RoleService.get_all_roles(db, tenant_id)
|
||||
return [RoleOut.model_validate(role) for role in roles]
|
||||
|
||||
@staticmethod
|
||||
def get_role_by_id(db: Session, role_id: UUID) -> RoleOut:
|
||||
"""Get a role by ID."""
|
||||
role = RoleService.get_role_by_id(db, role_id)
|
||||
return RoleOut.model_validate(role)
|
||||
|
||||
@staticmethod
|
||||
def get_role_with_accesses(db: Session, role_id: UUID) -> RoleWithAccessesOut:
|
||||
"""Get a role with all its accesses."""
|
||||
role = RoleService.get_role_with_accesses(db, role_id)
|
||||
return RoleWithAccessesOut.model_validate(role)
|
||||
|
||||
@staticmethod
|
||||
def update_role(db: Session, role_id: UUID, role_data: RoleUpdate) -> RoleOut:
|
||||
"""Update a role."""
|
||||
role = RoleService.update_role(db, role_id, role_data)
|
||||
return RoleOut.model_validate(role)
|
||||
|
||||
@staticmethod
|
||||
def delete_role(db: Session, role_id: UUID):
|
||||
"""Delete a role."""
|
||||
return RoleService.delete_role(db, role_id)
|
||||
|
||||
@staticmethod
|
||||
def get_roles_paginated(
|
||||
db: Session,
|
||||
tenant_id: Optional[UUID] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search: Optional[str] = None,
|
||||
) -> RolePaginatedOut:
|
||||
"""Get paginated list of roles."""
|
||||
return RoleService.get_roles_paginated(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def assign_role_to_user(db: Session, user_id: int, role_id: UUID):
|
||||
"""Assign a role to a user."""
|
||||
return RoleService.assign_role_to_user(db, user_id, role_id)
|
||||
@@ -0,0 +1,3 @@
|
||||
from .access_dependency import require_access, require_any_access, require_all_access
|
||||
|
||||
__all__ = ["require_access", "require_any_access", "require_all_access"]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Access control dependencies for FastAPI routes.
|
||||
|
||||
These dependencies are used in route definitions to require specific access codes.
|
||||
|
||||
Example:
|
||||
@router.post("/projects", dependencies=[require_access("project.create")])
|
||||
def create_project(user: User = Depends(get_current_user)):
|
||||
...
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.modules.auth.services.permission_service import PermissionService
|
||||
from app.modules.auth.models.user_model import User
|
||||
|
||||
|
||||
def require_access(code: str):
|
||||
"""
|
||||
Dependency to require a specific access code.
|
||||
|
||||
Usage in routes:
|
||||
@router.post("/path", dependencies=[require_access("some.code")])
|
||||
def endpoint(...):
|
||||
...
|
||||
|
||||
Args:
|
||||
code: The required access code (e.g. "project.create")
|
||||
|
||||
Returns:
|
||||
FastAPI Depends wrapper that checks access
|
||||
"""
|
||||
|
||||
async def check_access(
|
||||
user: User = Depends(get_current_user), db: Session = Depends(get_db)
|
||||
) -> None:
|
||||
PermissionService(db).require_access(user, code)
|
||||
|
||||
return Depends(check_access)
|
||||
|
||||
|
||||
def require_any_access(*codes: str):
|
||||
"""
|
||||
Dependency to require at least one of the provided access codes.
|
||||
|
||||
Usage in routes:
|
||||
@router.post("/path", dependencies=[require_any_access("code1", "code2")])
|
||||
def endpoint(...):
|
||||
...
|
||||
|
||||
Args:
|
||||
*codes: One or more access codes (user needs at least one)
|
||||
|
||||
Returns:
|
||||
FastAPI Depends wrapper that checks access
|
||||
"""
|
||||
|
||||
async def check_access(
|
||||
user: User = Depends(get_current_user), db: Session = Depends(get_db)
|
||||
) -> None:
|
||||
PermissionService(db).require_any_access(user, list(codes))
|
||||
|
||||
return Depends(check_access)
|
||||
|
||||
|
||||
def require_all_access(*codes: str):
|
||||
"""
|
||||
Dependency to require all of the provided access codes.
|
||||
|
||||
Usage in routes:
|
||||
@router.post("/path", dependencies=[require_all_access("code1", "code2")])
|
||||
def endpoint(...):
|
||||
...
|
||||
|
||||
Args:
|
||||
*codes: One or more access codes (user must have all)
|
||||
|
||||
Returns:
|
||||
FastAPI Depends wrapper that checks access
|
||||
"""
|
||||
|
||||
async def check_access(
|
||||
user: User = Depends(get_current_user), db: Session = Depends(get_db)
|
||||
) -> None:
|
||||
PermissionService(db).require_all_access(user, list(codes))
|
||||
|
||||
return Depends(check_access)
|
||||
@@ -0,0 +1,33 @@
|
||||
import hmac
|
||||
import hashlib
|
||||
import logging
|
||||
from fastapi import Request, HTTPException, status
|
||||
from app.core.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def verify_saas_hmac(request: Request):
|
||||
"""
|
||||
Verify HMAC signature from SaaS.
|
||||
|
||||
The signature is calculated as:
|
||||
HMAC-SHA256(secret, canonical_string)
|
||||
|
||||
where canonical_string is usually user_id=...&email=...&tenant_id=...×tamp=...
|
||||
"""
|
||||
signature = request.headers.get("X-Signature")
|
||||
if not signature:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing HMAC signature"
|
||||
)
|
||||
|
||||
if not settings.SAAS_TRUST_SECRET:
|
||||
logger.error("SAAS_TRUST_SECRET is not configured")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Server configuration error"
|
||||
)
|
||||
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,42 @@
|
||||
import uuid
|
||||
from sqlalchemy import String, DateTime, func, ForeignKey, Index
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import Optional
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class Access(Base):
|
||||
__tablename__ = "accesses"
|
||||
__table_args__ = (
|
||||
Index("ix_access_code", "access_code"),
|
||||
Index("ix_category", "category"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
|
||||
access_code: Mapped[str] = mapped_column(
|
||||
String(255), unique=True, nullable=False, index=True
|
||||
)
|
||||
|
||||
category: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
ForeignKey("accesses.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
|
||||
created_at: Mapped[DateTime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
parent = relationship("Access", remote_side=[id], backref="children")
|
||||
role_accesses = relationship(
|
||||
"RoleAccess", back_populates="access", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Access {self.access_code}>"
|
||||
@@ -0,0 +1,43 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Integer, Boolean, Float, func, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import Optional
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class Device(Base):
|
||||
__tablename__ = "devices"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "fingerprint", name="uq_device_user_fingerprint"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
user = relationship("User", backref="devices")
|
||||
|
||||
fingerprint: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
|
||||
|
||||
browser: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
os: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
device_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||
|
||||
ip: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
country: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
state: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
latitude: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
longitude: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
|
||||
first_login: Mapped[DateTime] = mapped_column(DateTime(timezone=True), default=func.now())
|
||||
last_login: Mapped[DateTime] = mapped_column(DateTime(timezone=True), default=func.now())
|
||||
last_ip: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
|
||||
is_blocked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
created_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy import ForeignKey, PrimaryKeyConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.db.database import Base
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
|
||||
class RoleAccess(Base):
|
||||
__tablename__ = "role_accesses"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("role_id", "access_id", name="pk_role_accesses"),
|
||||
)
|
||||
|
||||
role_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("roles.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
access_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("accesses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
role = relationship("Role", back_populates="role_accesses")
|
||||
access = relationship("Access", back_populates="role_accesses")
|
||||
@@ -0,0 +1,45 @@
|
||||
import uuid
|
||||
from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint, func, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import Optional, List
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "name", name="uq_role_tenant_name"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
|
||||
tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
|
||||
description: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
created_at: Mapped[DateTime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
users = relationship("User", back_populates="role")
|
||||
role_accesses = relationship(
|
||||
"RoleAccess", back_populates="role", cascade="all, delete-orphan"
|
||||
)
|
||||
tenant = relationship("Tenant", back_populates="roles")
|
||||
|
||||
@property
|
||||
def accesses(self):
|
||||
"""Return the list of Access objects for this role (used by Pydantic RoleWithAccessesOut)."""
|
||||
return [ra.access for ra in self.role_accesses if ra.access]
|
||||
@@ -0,0 +1,44 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, DateTime, func, ForeignKey, JSON
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.db.database import Base
|
||||
|
||||
class SaaSUserMapping(Base):
|
||||
__tablename__ = "saas_user_mappings"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
saas_user_id = Column(String, nullable=False, unique=True, index=True)
|
||||
docqube_user_id = Column(ForeignKey("users.id"), nullable=False)
|
||||
metadata_ = Column("metadata", JSON, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
|
||||
user = relationship("User", backref="saas_mapping")
|
||||
|
||||
class SaaSTenantMapping(Base):
|
||||
__tablename__ = "saas_tenant_mappings"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
saas_tenant_id = Column(String, unique=True, nullable=False, index=True)
|
||||
docqube_tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False)
|
||||
metadata_ = Column("metadata", JSON, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
class SaaSRoleMapping(Base):
|
||||
__tablename__ = "saas_role_mappings"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
saas_role_id = Column(String, unique=True, nullable=False, index=True)
|
||||
docqube_role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), nullable=False)
|
||||
metadata_ = Column("metadata", JSON, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
role = relationship("Role", backref="saas_mapping")
|
||||
@@ -0,0 +1,35 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Integer, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import Optional
|
||||
from app.db.database import Base
|
||||
from app.modules.auth.models.device_model import Device
|
||||
|
||||
|
||||
class Session(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
user = relationship("User", backref="auth_sessions", foreign_keys=[user_id])
|
||||
|
||||
device_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
device = relationship("Device", backref="sessions")
|
||||
|
||||
refresh_token_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(50), default="ACTIVE", nullable=False)
|
||||
|
||||
created_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
last_activity: Mapped[DateTime] = mapped_column(DateTime(timezone=True), default=func.now())
|
||||
expires_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
revoked_at: Mapped[Optional[DateTime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
revoked_by: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
revoked_by_user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
reason: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
@@ -0,0 +1,226 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, func, ForeignKey, UniqueConstraint, Integer, Boolean, Index, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import Optional
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "email", name="uq_user_tenant_email"),
|
||||
Index(
|
||||
"ix_users_is_superadmin",
|
||||
"is_superadmin",
|
||||
postgresql_where=text("is_superadmin"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
|
||||
)
|
||||
|
||||
tenant = relationship("Tenant", back_populates="users", foreign_keys=[tenant_id])
|
||||
|
||||
is_superadmin: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="false"
|
||||
)
|
||||
|
||||
@property
|
||||
def tenant_envelope_limit(self) -> Optional[int]:
|
||||
if self.tenant:
|
||||
return self.tenant.envelope_limit
|
||||
return None
|
||||
|
||||
@property
|
||||
def tenant_envelopes_used(self) -> Optional[int]:
|
||||
if self.tenant:
|
||||
return self.tenant.envelopes_used
|
||||
return None
|
||||
|
||||
role_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("roles.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
|
||||
role = relationship("Role", back_populates="users")
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
designation: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
preferred_language: Mapped[str] = mapped_column(String(16), nullable=False, default="en")
|
||||
|
||||
email: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
|
||||
|
||||
preferred_language: Mapped[str] = mapped_column(String(16), nullable=False, default="en", server_default="en")
|
||||
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
subscription: Mapped[str] = mapped_column(String(50), default="free")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
terms_accepted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
has_completed_tutorial: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
has_completed_docs_tutorial: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
deleted_by_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
created_at: Mapped[DateTime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
encrypted_ai_api_key: Mapped[Optional[str]] = mapped_column(
|
||||
String(512), nullable=True
|
||||
)
|
||||
|
||||
smtp_user: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
mail_from: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
encrypted_smtp_password: Mapped[Optional[str]] = mapped_column(
|
||||
String(512), nullable=True
|
||||
)
|
||||
|
||||
projects = relationship(
|
||||
"Project", back_populates="owner", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
drive_folders = relationship(
|
||||
"DriveFolder", back_populates="owner", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
drive_files = relationship(
|
||||
"DriveFile", back_populates="owner", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
project_versions = relationship(
|
||||
"ProjectVersion", back_populates="creator", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
drive_file_versions = relationship(
|
||||
"DriveFileVersion", back_populates="creator", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
drive_comments = relationship(
|
||||
"DriveComment", back_populates="author", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
drive_activities = relationship(
|
||||
"DriveActivity", back_populates="actor", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
user_files = relationship(
|
||||
"UserFile", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
storage_usage = relationship(
|
||||
"UserStorageUsage",
|
||||
back_populates="user",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
notifications = relationship(
|
||||
"Notification",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic",
|
||||
)
|
||||
chat_token_quota = relationship(
|
||||
"ChatTokenQuota",
|
||||
back_populates="user",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
deleted_by = relationship("User", remote_side=[id], foreign_keys=[deleted_by_id])
|
||||
|
||||
@property
|
||||
def role_name(self) -> Optional[str]:
|
||||
return self.role.name if self.role else None
|
||||
|
||||
@property
|
||||
def access_codes(self) -> list[str]:
|
||||
"""
|
||||
Every access code this user holds.
|
||||
|
||||
**Prefers what `get_current_user` resolved.** This property used to be an
|
||||
independent second answer to "what may this user do", computed from the
|
||||
single legacy `users.role_id` and nothing else — so a user holding a role
|
||||
through `user_roles` had authority that `require_access` honoured and the
|
||||
product would not show them. `/api/me/profile` reports this list and the
|
||||
frontend drives every route guard, menu entry and button from it, so an
|
||||
incomplete answer here is indistinguishable from having no permission.
|
||||
|
||||
`_resolve_access_codes` is attached by `get_current_user` and calls
|
||||
`PermissionService`, the one correct resolver: legacy role, `user_roles`
|
||||
grants (live and in-tenant only), group grants, SaaS claims, and
|
||||
descendant expansion. It is a **closure rather than a computed list** so
|
||||
that requests which never ask do not pay for the answer; the result is
|
||||
memoised per request, so asking twice is free.
|
||||
|
||||
The fallback below is the old computation, kept for objects that never
|
||||
passed through authentication — fixtures, background tasks, a detached
|
||||
row. It is deliberately not an error: raising here would turn "no session
|
||||
attached" into a 500 on paths that only wanted a display name.
|
||||
"""
|
||||
resolver = getattr(self, "_resolve_access_codes", None)
|
||||
if resolver is not None:
|
||||
return list(resolver())
|
||||
|
||||
codes = set()
|
||||
if self.role:
|
||||
codes.update([ra.access.access_code for ra in self.role.role_accesses if ra.access])
|
||||
|
||||
saas_permissions = getattr(self, "saas_permissions", set())
|
||||
codes.update(saas_permissions)
|
||||
|
||||
return list(codes)
|
||||
|
||||
@property
|
||||
def role_refs(self) -> list:
|
||||
"""
|
||||
Every role this user holds, as resolved assignment dicts.
|
||||
|
||||
Named `role_refs` rather than `roles` on purpose: `roles` would sit one
|
||||
typo away from a relationship name in a class that already has `role`,
|
||||
and a plain attribute shadowing a mapper attribute fails at import time
|
||||
in a way that is tedious to diagnose. `UserOut` exposes it as `roles`.
|
||||
|
||||
Resolved through a closure attached by `get_current_user`, for the same
|
||||
reason as `access_codes`: only the responses that report roles should pay
|
||||
for reading them.
|
||||
|
||||
Off that path the primary role is synthesised, so a detached object still
|
||||
describes itself honestly rather than claiming no roles at all.
|
||||
"""
|
||||
resolver = getattr(self, "_resolve_role_refs", None)
|
||||
if resolver is not None:
|
||||
return list(resolver())
|
||||
|
||||
if self.role is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"grant_id": None,
|
||||
"role_id": self.role.id,
|
||||
"role_name": self.role.name,
|
||||
"source": "primary",
|
||||
"org_unit_id": None,
|
||||
"org_unit_name": None,
|
||||
"group_id": None,
|
||||
"group_name": None,
|
||||
"expires_at": None,
|
||||
"assigned_by_id": None,
|
||||
"created_at": None,
|
||||
"is_expired": False,
|
||||
}
|
||||
]
|
||||
|
||||
@property
|
||||
def subscription_details(self):
|
||||
return getattr(self, "saas_subscription", None)
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
C3 — a role held *somewhere*.
|
||||
|
||||
`User.role_id` gives every user exactly one role, held across the whole tenant.
|
||||
That is the thing scope cannot be built on: until a user can hold the same role
|
||||
in two places, or two roles in one place, "scoped access" has nowhere to live.
|
||||
|
||||
This is the join table the base uses (`docqube_platform_backend/app/models/rbac.py`),
|
||||
with one difference: it points at DocQube's existing `roles`, so the access-code
|
||||
vocabulary is unchanged and all 227 endpoints keep working exactly as they do
|
||||
today.
|
||||
|
||||
org_unit_id IS NULL -> tenant-wide, the grant everyone has today
|
||||
org_unit_id = X -> this role, within X and everything beneath it
|
||||
|
||||
The null case is what makes the migration a no-op: the backfill turns each
|
||||
`User.role_id` into one row with a null unit, which is precisely the authority
|
||||
that user already had.
|
||||
|
||||
**Both mechanisms are honoured for one release.** `User.role_id` is not dropped
|
||||
here. That is the same transition shape as `users.is_superadmin`, for the same
|
||||
reason — a rollback must not be able to remove everyone's permissions — and it
|
||||
ends the same way, with a preflight script and a deliberate removal.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.database import Base
|
||||
|
||||
import app.modules.org.models.group_model # noqa: F401
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
"""
|
||||
The tenant this grant belongs to.
|
||||
|
||||
Present so isolation is **structural** rather than remembered: the ORM
|
||||
listener scopes any model carrying this column, and the RLS policy compares
|
||||
it against the session variable. Without it the table sat outside both
|
||||
layers, guarded only by whatever predicate each call site wrote — the same
|
||||
shape of gap that let any holder of `superadmin.tenant.delete` delete any
|
||||
tenant.
|
||||
|
||||
Nullable, because a superadmin has no tenant and neither do their grants.
|
||||
"""
|
||||
|
||||
user_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
group_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("access_groups.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
"""
|
||||
The principal: exactly one of `user_id` or `group_id`.
|
||||
|
||||
Enforced by a CHECK constraint, not by the application. "Exactly one of two
|
||||
nullable columns" survives in the schema and rots in code — one new call
|
||||
site that sets both, and the resolver counts the grant twice.
|
||||
|
||||
`user_id` became nullable in C5.1. That weakened an invariant every prior
|
||||
query relied on, so all four readers were reviewed before the migration:
|
||||
`core/scope.py`, `auth/services/permission_service.py`,
|
||||
`org/routes/org_routes.py` and `org/services/access_maintenance.py`.
|
||||
"""
|
||||
role_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("roles.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
org_unit_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("org_units.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
assigned_by_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
"""
|
||||
When this grant stops applying. Null means permanent.
|
||||
|
||||
Enforced in the resolution query, not in Python — an expired grant must be
|
||||
invisible to *every* reader, including any future one that queries this
|
||||
table directly. Filtering after the fact is how one caller ends up honouring
|
||||
a dead grant.
|
||||
|
||||
Expired rows are kept rather than swept: they are the history that answers
|
||||
"who had access in March".
|
||||
"""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
role = relationship("Role", foreign_keys=[role_id])
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"user_id", "role_id", "org_unit_id", name="uq_user_roles_scoped"
|
||||
),
|
||||
Index(
|
||||
"uq_user_roles_tenant_wide",
|
||||
"user_id",
|
||||
"role_id",
|
||||
unique=True,
|
||||
postgresql_where=(org_unit_id.is_(None)),
|
||||
),
|
||||
Index("ix_user_roles_lookup", "user_id", "role_id"),
|
||||
Index(
|
||||
"uq_user_roles_group_scoped",
|
||||
"group_id",
|
||||
"role_id",
|
||||
"org_unit_id",
|
||||
unique=True,
|
||||
postgresql_where=(group_id.isnot(None)),
|
||||
),
|
||||
Index(
|
||||
"uq_user_roles_group_tenant_wide",
|
||||
"group_id",
|
||||
"role_id",
|
||||
unique=True,
|
||||
postgresql_where=(org_unit_id.is_(None) & group_id.isnot(None)),
|
||||
),
|
||||
Index(
|
||||
"ix_user_roles_expires_at",
|
||||
"expires_at",
|
||||
postgresql_where=(expires_at.isnot(None)),
|
||||
),
|
||||
Index("ix_user_roles_assigned_by_id", "assigned_by_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||
where = self.org_unit_id or "tenant-wide"
|
||||
return f"<UserRole user={self.user_id} role={self.role_id} in={where}>"
|
||||
@@ -0,0 +1,45 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.modules.auth.models.device_model import Device
|
||||
from datetime import datetime, timezone
|
||||
|
||||
class DeviceRepository:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_by_id(self, device_id: int) -> Optional[Device]:
|
||||
return self.db.query(Device).filter(Device.id == device_id).first()
|
||||
|
||||
def get_by_fingerprint(self, user_id: int, fingerprint: str) -> Optional[Device]:
|
||||
return self.db.query(Device).filter(
|
||||
Device.user_id == user_id,
|
||||
Device.fingerprint == fingerprint
|
||||
).first()
|
||||
|
||||
def get_user_devices(self, user_id: int) -> List[Device]:
|
||||
from app.modules.auth.models.session_model import Session as AuthSession
|
||||
return self.db.query(Device).join(AuthSession, Device.id == AuthSession.device_id).filter(
|
||||
Device.user_id == user_id,
|
||||
AuthSession.status == "ACTIVE",
|
||||
AuthSession.expires_at > datetime.now(timezone.utc)
|
||||
).order_by(Device.last_login.desc()).distinct().all()
|
||||
|
||||
def create(self, device: Device) -> Device:
|
||||
self.db.add(device)
|
||||
self.db.flush()
|
||||
self.db.refresh(device)
|
||||
return device
|
||||
|
||||
def update_login_activity(self, device: Device, ip: str) -> Device:
|
||||
device.last_login = datetime.now(timezone.utc)
|
||||
if ip:
|
||||
device.last_ip = ip
|
||||
self.db.flush()
|
||||
self.db.refresh(device)
|
||||
return device
|
||||
|
||||
def block_device(self, device: Device) -> Device:
|
||||
device.is_blocked = True
|
||||
self.db.flush()
|
||||
self.db.refresh(device)
|
||||
return device
|
||||
@@ -0,0 +1,72 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.modules.auth.models.session_model import Session as AuthSession
|
||||
from datetime import datetime, timezone
|
||||
|
||||
class SessionRepository:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_by_id(self, session_id: int) -> Optional[AuthSession]:
|
||||
return self.db.query(AuthSession).filter(AuthSession.id == session_id).first()
|
||||
|
||||
def get_active_sessions_for_user(self, user_id: int) -> List[AuthSession]:
|
||||
return self.db.query(AuthSession).filter(
|
||||
AuthSession.user_id == user_id,
|
||||
AuthSession.status == "ACTIVE",
|
||||
AuthSession.expires_at > datetime.now(timezone.utc)
|
||||
).all()
|
||||
|
||||
def count_active_sessions(self, user_id: int) -> int:
|
||||
return self.db.query(AuthSession).filter(
|
||||
AuthSession.user_id == user_id,
|
||||
AuthSession.status == "ACTIVE",
|
||||
AuthSession.expires_at > datetime.now(timezone.utc)
|
||||
).count()
|
||||
|
||||
def create(self, session: AuthSession) -> AuthSession:
|
||||
self.db.add(session)
|
||||
self.db.flush()
|
||||
self.db.refresh(session)
|
||||
return session
|
||||
|
||||
def update_last_activity(self, session: AuthSession) -> AuthSession:
|
||||
session.last_activity = datetime.now(timezone.utc)
|
||||
return session
|
||||
|
||||
def revoke(self, session: AuthSession, by: str = "user", revoked_by_user_id: Optional[int] = None, reason: Optional[str] = None) -> AuthSession:
|
||||
session.status = "REVOKED"
|
||||
session.revoked_at = datetime.now(timezone.utc)
|
||||
session.revoked_by = by
|
||||
if revoked_by_user_id:
|
||||
session.revoked_by_user_id = revoked_by_user_id
|
||||
if reason:
|
||||
session.reason = reason
|
||||
self.db.flush()
|
||||
self.db.refresh(session)
|
||||
|
||||
from app.db.redis import redis_cache
|
||||
redis_cache.set(f"session_revoked:{session.id}", "1", ttl=600)
|
||||
import json
|
||||
if redis_cache.client:
|
||||
redis_cache.client.publish(f"user_events:{session.user_id}", json.dumps({"type": "SESSION_REVOKED", "session_id": session.id}))
|
||||
|
||||
return session
|
||||
|
||||
def revoke_all_for_device(self, device_id: int, by: str = "user") -> None:
|
||||
self.db.query(AuthSession).filter(
|
||||
AuthSession.device_id == device_id,
|
||||
AuthSession.status == "ACTIVE"
|
||||
).update({
|
||||
"status": "REVOKED",
|
||||
"revoked_at": datetime.now(timezone.utc),
|
||||
"revoked_by": by
|
||||
})
|
||||
|
||||
from app.modules.auth.models.device_model import Device
|
||||
device = self.db.query(Device).filter(Device.id == device_id).first()
|
||||
if device:
|
||||
from app.db.redis import redis_cache
|
||||
import json
|
||||
if redis_cache.client:
|
||||
redis_cache.client.publish(f"user_events:{device.user_id}", json.dumps({"type": "DEVICE_REVOKED", "device_id": device.id}))
|
||||
@@ -0,0 +1,43 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from app.modules.auth.models.user_model import User
|
||||
|
||||
class UserRepository:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_by_email(self, email: str) -> Optional[User]:
|
||||
return self.db.query(User).filter(User.email == email.lower().strip()).first()
|
||||
|
||||
def get_by_id(self, user_id: int) -> Optional[User]:
|
||||
return self.db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
def create(self, user: User) -> User:
|
||||
self.db.add(user)
|
||||
self.db.flush()
|
||||
self.db.refresh(user)
|
||||
return user
|
||||
|
||||
def search_users(
|
||||
self, query: str, limit: int = 10, tenant_id=None
|
||||
) -> List[User]:
|
||||
"""
|
||||
Search users by name or email.
|
||||
|
||||
`tenant_id` is required in practice: without it this returns users from
|
||||
every tenant, including their email addresses and permission sets. The
|
||||
parameter defaults to None only so a superadmin caller can search across
|
||||
tenants deliberately — callers that serve tenant users must always pass
|
||||
the caller's own tenant.
|
||||
"""
|
||||
q = self.db.query(User).filter(
|
||||
User.is_deleted.is_(False),
|
||||
(User.name.ilike(f"%{query}%")) | (User.email.ilike(f"%{query}%")),
|
||||
)
|
||||
if tenant_id is not None:
|
||||
q = q.filter(User.tenant_id == tenant_id)
|
||||
return q.limit(limit).all()
|
||||
|
||||
def get_users_by_ids(self, ids: List[int]) -> List[User]:
|
||||
return self.db.query(User).filter(User.id.in_(ids), User.is_deleted.is_(False)).all()
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Access control routes.
|
||||
|
||||
Endpoints for retrieving user's access codes and managing permissions.
|
||||
Used by frontend to determine what UI elements to show.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.auth.services.permission_service import PermissionService
|
||||
|
||||
from typing import List
|
||||
|
||||
from app.modules.auth.schemas.access_schema import AccessOut, RoleAccessesOut
|
||||
|
||||
router = APIRouter(prefix="/api/access", tags=["Access"])
|
||||
|
||||
|
||||
@router.get("/get", response_model=RoleAccessesOut)
|
||||
def get_user_accesses(
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get current user's access codes.
|
||||
|
||||
This endpoint is called by the frontend to determine which UI elements
|
||||
to show/hide based on the user's permissions.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"accesses": ["project.create", "drive.file.upload", ...],
|
||||
"role": "superadmin" | "tenant_admin" | null,
|
||||
"roles": ["tenant_admin", "approver"]
|
||||
}
|
||||
|
||||
`role` is the **primary** role and is unchanged. `roles` is every role the
|
||||
caller holds — primary, granted, and through a group — because a user may
|
||||
hold several and a single name cannot say so.
|
||||
"""
|
||||
perm_service = PermissionService(db)
|
||||
accesses = sorted(list(perm_service.user_access_codes(user)))
|
||||
|
||||
seen: list[str] = []
|
||||
for assignment in user.role_refs:
|
||||
name = assignment.get("role_name")
|
||||
if name and name not in seen:
|
||||
seen.append(name)
|
||||
|
||||
return {
|
||||
"accesses": accesses,
|
||||
"role": user.role.name if user.role else None,
|
||||
"roles": seen,
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/all", response_model=List[AccessOut])
|
||||
def get_all_system_accesses(
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get all available access codes in the platform.
|
||||
Typically used for role management UI.
|
||||
"""
|
||||
from app.modules.auth.dependencies.access_dependency import require_access
|
||||
|
||||
perm_service = PermissionService(db)
|
||||
perm_service.require_any_access(user, ["superadmin.role.read", "admin.role.read"])
|
||||
|
||||
return perm_service.get_all_system_accesses()
|
||||
@@ -0,0 +1,43 @@
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.modules.auth.dependencies.access_dependency import require_access
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.auth.schemas.admin_dashboard_schema import (
|
||||
AdminDashboardMetricsOut,
|
||||
MetricTrendOnlyOut,
|
||||
)
|
||||
from app.modules.auth.services.admin_dashboard_service import AdminDashboardService
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["Admin Dashboard"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/dashboard-metrics",
|
||||
response_model=AdminDashboardMetricsOut,
|
||||
dependencies=[require_access("admin.user.read")],
|
||||
)
|
||||
def get_dashboard_metrics(
|
||||
granularity: Literal["weekly", "monthly", "yearly"] = Query("weekly"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return AdminDashboardService(db).get_tenant_metrics(current_user, granularity)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/dashboard-metrics/trends/{metric}",
|
||||
response_model=MetricTrendOnlyOut,
|
||||
dependencies=[require_access("admin.user.read")],
|
||||
)
|
||||
def get_dashboard_metric_trend(
|
||||
metric: Literal["signatures", "uploads"],
|
||||
granularity: Literal["weekly", "monthly", "yearly"] = Query("weekly"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return AdminDashboardService(db).get_trend_data(current_user, metric, granularity)
|
||||
@@ -0,0 +1,655 @@
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from datetime import datetime
|
||||
import re
|
||||
import logging
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.core.scope import ScopeService
|
||||
from app.core.settings import settings
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.auth.models.role_model import Role
|
||||
from app.modules.auth.models.role_access_model import RoleAccess
|
||||
from app.modules.auth.schemas.auth_schema import UserOut
|
||||
from app.modules.auth.schemas.role_schema import (
|
||||
AddUserRoleIn,
|
||||
EffectiveAccessOut,
|
||||
RoleAssignmentOut,
|
||||
)
|
||||
from app.modules.auth.services.permission_service import PermissionService
|
||||
from app.modules.auth.services.user_role_service import UserRoleReader, invalidate_for_user
|
||||
from app.modules.auth.dependencies.access_dependency import require_access
|
||||
from app.modules.auth.services.privilege import holds_superadmin_access
|
||||
from app.modules.configuration.services.system_configuration_service import (
|
||||
SystemConfigurationService,
|
||||
)
|
||||
from app.modules.chat.models.chat_usage_model import ChatTokenQuota
|
||||
|
||||
from app.modules.configuration.schemas.system_configuration_schema import (
|
||||
SystemLimitsOut,
|
||||
)
|
||||
|
||||
from app.core.security import get_password_hash
|
||||
from app.core.schemas import MessageOut
|
||||
|
||||
router = APIRouter(prefix="/api/admin/users", tags=["Admin Users"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_superadmin(user: User) -> bool:
|
||||
"""A user is treated as a superadmin if they hold any `superadmin.*` access."""
|
||||
return holds_superadmin_access(user)
|
||||
|
||||
|
||||
def _resolve_effective_tenant_id(
|
||||
current_user: User, requested_tenant_id: Optional[UUID]
|
||||
) -> Optional[UUID]:
|
||||
"""
|
||||
Tenant admins are always scoped to their own tenant.
|
||||
Superadmins may pass a tenant_id to narrow the scope, or None for all tenants.
|
||||
"""
|
||||
if _is_superadmin(current_user):
|
||||
return requested_tenant_id
|
||||
return current_user.tenant_id
|
||||
|
||||
|
||||
@router.get(
|
||||
"/summary/limits",
|
||||
response_model=SystemLimitsOut,
|
||||
dependencies=[require_access("admin.user.read")],
|
||||
)
|
||||
def get_user_management_limits(db: Session = Depends(get_db)):
|
||||
"""Get global user limits snapshot for administration UI."""
|
||||
return SystemConfigurationService(db).get_limits_snapshot()
|
||||
|
||||
|
||||
class UpdateUserIn(BaseModel):
|
||||
name: Optional[str] = None
|
||||
designation: Optional[str] = Field(None, max_length=100)
|
||||
is_active: Optional[bool] = None
|
||||
role_id: Optional[UUID] = None
|
||||
tenant_id: Optional[UUID] = None
|
||||
password: Optional[str] = Field(None, min_length=12)
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_complexity(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return v
|
||||
if not re.search(r"[A-Z]", v):
|
||||
raise ValueError("Password must contain at least one uppercase letter")
|
||||
if not re.search(r"[0-9]", v):
|
||||
raise ValueError("Password must contain at least one number")
|
||||
if not re.search(r"[^A-Za-z0-9]", v):
|
||||
raise ValueError("Password must contain at least one special character")
|
||||
return v
|
||||
|
||||
|
||||
class UserListOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
designation: Optional[str] = None
|
||||
email: str
|
||||
is_active: bool
|
||||
tenant_id: Optional[UUID] = None
|
||||
tenant_name: Optional[str] = None
|
||||
role_id: Optional[UUID] = None
|
||||
role: Optional[str] = None
|
||||
subscription: Optional[str] = None
|
||||
is_deleted: bool = False
|
||||
deleted_at: Optional[datetime] = None
|
||||
chat_credit_daily_limit: Optional[float] = None
|
||||
chat_credits_used_today: Optional[float] = None
|
||||
tenant_chat_token_limit: Optional[int] = None
|
||||
tenant_total_credits_allocated: Optional[float] = None
|
||||
|
||||
roles: List[RoleAssignmentOut] = []
|
||||
"""
|
||||
Every role this user holds, primary first.
|
||||
|
||||
**Additive.** `role` and `role_id` above are retained and still mean the
|
||||
primary role, so every existing consumer of this shape is unaffected.
|
||||
"""
|
||||
role_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PaginatedUsersOut(BaseModel):
|
||||
items: List[UserListOut]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
from app.modules.chat.repositories.chat_repository import ChatbotRepository
|
||||
|
||||
def _get_single_user_allocated_quota(user: User, db: Session) -> float:
|
||||
if not user.tenant_id:
|
||||
return 0.0
|
||||
repo = ChatbotRepository(db)
|
||||
return repo.get_tenant_total_user_quota(user.tenant_id)
|
||||
|
||||
|
||||
def _to_user_list_out(user: User, tenant_total_credits_allocated: float, assignments: list) -> UserListOut:
|
||||
return UserListOut(
|
||||
id=user.id,
|
||||
name=user.name,
|
||||
designation=user.designation,
|
||||
email=user.email,
|
||||
is_active=getattr(user, "is_active", True),
|
||||
tenant_id=user.tenant_id,
|
||||
tenant_name=user.tenant.name if user.tenant else None,
|
||||
role_id=user.role_id,
|
||||
role=user.role.name if user.role else None,
|
||||
subscription=user.subscription,
|
||||
is_deleted=getattr(user, "is_deleted", False),
|
||||
deleted_at=getattr(user, "deleted_at", None),
|
||||
chat_credit_daily_limit=getattr(user, "chat_token_quota", None).daily_credit_limit if getattr(user, "chat_token_quota", None) else None,
|
||||
chat_credits_used_today=round((getattr(user, "chat_token_quota", None).credits_used_today / 1000.0), 2) if getattr(user, "chat_token_quota", None) else 0,
|
||||
tenant_chat_token_limit=user.tenant.chat_token_daily_limit if user.tenant else None,
|
||||
tenant_total_credits_allocated=tenant_total_credits_allocated,
|
||||
roles=[RoleAssignmentOut(**a) for a in assignments],
|
||||
role_count=len(assignments),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=PaginatedUsersOut,
|
||||
dependencies=[require_access("admin.user.read")],
|
||||
)
|
||||
def list_all_users(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
search: Optional[str] = Query(None),
|
||||
tenant_id: Optional[UUID] = Query(None),
|
||||
include_deleted: bool = Query(False),
|
||||
only_deleted: bool = Query(False),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all users with pagination, search, and optional tenant filter."""
|
||||
query = db.query(User).options(
|
||||
joinedload(User.role),
|
||||
joinedload(User.tenant),
|
||||
joinedload(User.chat_token_quota),
|
||||
)
|
||||
|
||||
if settings.ORG_SCOPE_ENABLED:
|
||||
visible = ScopeService(db).visible_user_ids(current_user, "admin.user.read")
|
||||
if visible is not None:
|
||||
query = query.filter(User.id.in_(visible))
|
||||
|
||||
effective_tenant_id = _resolve_effective_tenant_id(current_user, tenant_id)
|
||||
if effective_tenant_id:
|
||||
query = query.filter(User.tenant_id == effective_tenant_id)
|
||||
|
||||
if only_deleted:
|
||||
query = query.filter(User.is_deleted == True)
|
||||
elif not include_deleted:
|
||||
query = query.filter(User.is_deleted == False)
|
||||
|
||||
if search and search.strip():
|
||||
term = f"%{search.strip()}%"
|
||||
query = query.filter(User.name.ilike(term) | User.email.ilike(term))
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * page_size
|
||||
users = query.offset(offset).limit(page_size).all()
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
||||
|
||||
by_user = UserRoleReader(db).assignments_for_users(users)
|
||||
|
||||
# Batch compute tenant allocated quotas in 1 single SQL query for all returned users
|
||||
tenant_ids = {u.tenant_id for u in users if u.tenant_id}
|
||||
tenant_quota_map = {}
|
||||
if tenant_ids:
|
||||
rows = (
|
||||
db.query(User.tenant_id, func.sum(ChatTokenQuota.daily_credit_limit))
|
||||
.join(ChatTokenQuota, User.id == ChatTokenQuota.user_id)
|
||||
.filter(User.tenant_id.in_(tenant_ids), User.is_deleted == False)
|
||||
.group_by(User.tenant_id)
|
||||
.all()
|
||||
)
|
||||
tenant_quota_map = {r[0]: float(r[1] or 0.0) for r in rows}
|
||||
|
||||
return PaginatedUsersOut(
|
||||
items=[_to_user_list_out(u, tenant_quota_map.get(u.tenant_id, 0.0), by_user.get(u.id, [])) for u in users],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{user_id}",
|
||||
response_model=UserListOut,
|
||||
dependencies=[require_access("admin.user.read")],
|
||||
)
|
||||
def get_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get a single user by ID."""
|
||||
query = (
|
||||
db.query(User)
|
||||
.options(joinedload(User.role), joinedload(User.tenant), joinedload(User.chat_token_quota))
|
||||
.filter(User.id == user_id)
|
||||
)
|
||||
if not _is_superadmin(current_user) and current_user.tenant_id:
|
||||
query = query.filter(User.tenant_id == current_user.tenant_id)
|
||||
user = query.first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if settings.ORG_SCOPE_ENABLED and not ScopeService(db).can_access_user(
|
||||
current_user, user.id, "admin.user.read"
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return _to_user_list_out(
|
||||
user,
|
||||
_get_single_user_allocated_quota(user, db),
|
||||
UserRoleReader(db).assignments(user)
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{user_id}",
|
||||
response_model=UserListOut,
|
||||
dependencies=[require_access("admin.user.update")],
|
||||
)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
payload: UpdateUserIn,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update a user's name, active status, role, tenant, or password."""
|
||||
query = (
|
||||
db.query(User)
|
||||
.options(joinedload(User.role), joinedload(User.tenant), joinedload(User.chat_token_quota))
|
||||
.filter(User.id == user_id)
|
||||
)
|
||||
if not _is_superadmin(current_user) and current_user.tenant_id:
|
||||
query = query.filter(User.tenant_id == current_user.tenant_id)
|
||||
user = query.first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if getattr(user, "is_deleted", False):
|
||||
raise HTTPException(status_code=400, detail="Cannot update a deleted user")
|
||||
|
||||
if payload.name is not None:
|
||||
user.name = payload.name
|
||||
if payload.designation is not None:
|
||||
user.designation = (payload.designation or "").strip() or None
|
||||
was_active = user.is_active
|
||||
if payload.is_active is not None:
|
||||
if payload.is_active == False and user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="You cannot deactivate your own account")
|
||||
user.is_active = payload.is_active
|
||||
previous_role = user.role
|
||||
role_changed = False
|
||||
new_role = None
|
||||
if payload.role_id is not None:
|
||||
role = db.query(Role).filter(Role.id == payload.role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
role_changed = user.role_id != role.id
|
||||
new_role = role
|
||||
user.role_id = role.id
|
||||
if payload.tenant_id is not None:
|
||||
user.tenant_id = payload.tenant_id
|
||||
if payload.password is not None:
|
||||
user.password_hash = get_password_hash(payload.password)
|
||||
|
||||
db.flush()
|
||||
db.refresh(user)
|
||||
db.refresh(user)
|
||||
|
||||
if role_changed:
|
||||
from app.modules.org.services.access_audit import record_primary_role_change
|
||||
|
||||
record_primary_role_change(db, current_user, user, previous_role, new_role)
|
||||
invalidate_for_user(user.id)
|
||||
|
||||
user_out = (
|
||||
db.query(User)
|
||||
.options(joinedload(User.role), joinedload(User.tenant))
|
||||
.filter(User.id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if was_active and payload.is_active is False:
|
||||
try:
|
||||
from app.modules.activity_logs.service import log_event
|
||||
from app.modules.activity_logs.constants import ActivityLogModule, ActivityLogAction, ActivityLogStatus, ActivityLogTargetType
|
||||
if user_out.tenant_id:
|
||||
log_event(
|
||||
tenant_id=user_out.tenant_id,
|
||||
user_id=current_user.id,
|
||||
user_email=current_user.email,
|
||||
module=ActivityLogModule.TENANT,
|
||||
action=ActivityLogAction.USER_DEACTIVATED,
|
||||
target_id=str(user_out.id),
|
||||
target_type=ActivityLogTargetType.USER,
|
||||
metadata={
|
||||
"deactivated_user_id": user_out.id,
|
||||
"deactivated_user_email": user_out.email
|
||||
},
|
||||
status=ActivityLogStatus.SUCCESS
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Failed to log USER_DEACTIVATED: {e}")
|
||||
|
||||
return _to_user_list_out(
|
||||
user_out,
|
||||
_get_single_user_allocated_quota(user_out, db),
|
||||
UserRoleReader(db).assignments(user_out)
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{user_id}",
|
||||
dependencies=[require_access("admin.user.delete")],
|
||||
response_model=MessageOut)
|
||||
def soft_delete_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
query = db.query(User).filter(User.id == user_id)
|
||||
if not _is_superadmin(current_user) and current_user.tenant_id:
|
||||
query = query.filter(User.tenant_id == current_user.tenant_id)
|
||||
user = query.first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||
if user.is_deleted:
|
||||
return {"message": "User already deleted"}
|
||||
|
||||
user.is_deleted = True
|
||||
user.is_active = False
|
||||
user.deleted_at = datetime.utcnow()
|
||||
user.deleted_by_id = current_user.id
|
||||
|
||||
try:
|
||||
from app.modules.activity_logs.service import log_event
|
||||
from app.modules.activity_logs.constants import ActivityLogModule, ActivityLogAction, ActivityLogStatus, ActivityLogTargetType
|
||||
if user.tenant_id:
|
||||
log_event(
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
user_email=current_user.email,
|
||||
module=ActivityLogModule.TENANT,
|
||||
action=ActivityLogAction.USER_DEACTIVATED,
|
||||
target_id=str(user.id),
|
||||
target_type=ActivityLogTargetType.USER,
|
||||
metadata={
|
||||
"deactivated_user_id": user.id,
|
||||
"deactivated_user_email": user.email
|
||||
},
|
||||
status=ActivityLogStatus.SUCCESS
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Failed to log USER_DEACTIVATED: {e}")
|
||||
|
||||
return {"detail": "User deleted successfully"}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{user_id}/restore",
|
||||
response_model=UserListOut,
|
||||
dependencies=[require_access("admin.user.update")],
|
||||
)
|
||||
def restore_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
query = (
|
||||
db.query(User)
|
||||
.options(joinedload(User.role), joinedload(User.tenant))
|
||||
.filter(User.id == user_id)
|
||||
)
|
||||
if not _is_superadmin(current_user) and current_user.tenant_id:
|
||||
query = query.filter(User.tenant_id == current_user.tenant_id)
|
||||
user = query.first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if not user.is_deleted:
|
||||
raise HTTPException(status_code=400, detail="User is not deleted")
|
||||
|
||||
SystemConfigurationService(db).ensure_can_create_user(tenant_id=user.tenant_id)
|
||||
|
||||
user.is_deleted = False
|
||||
user.is_active = True
|
||||
user.deleted_at = None
|
||||
user.deleted_by_id = None
|
||||
db.flush()
|
||||
db.refresh(user)
|
||||
return _to_user_list_out(
|
||||
user,
|
||||
_get_single_user_allocated_quota(user, db),
|
||||
UserRoleReader(db).assignments(user)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/bulk/distribute-credits-equally",
|
||||
dependencies=[require_access("admin.user.update")],
|
||||
)
|
||||
def distribute_credits_equally(
|
||||
tenant_id: Optional[UUID] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Distribute the tenant's total credit limit equally among all active users."""
|
||||
from app.modules.chat.services.chat_service import ChatbotService
|
||||
from app.modules.tenant.models.tenant_model import Tenant
|
||||
|
||||
effective_tenant_id = _resolve_effective_tenant_id(current_user, tenant_id)
|
||||
if not effective_tenant_id:
|
||||
raise HTTPException(status_code=400, detail="Tenant ID is required for bulk distribution.")
|
||||
|
||||
tenant = db.query(Tenant).filter(Tenant.id == effective_tenant_id).first()
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
if tenant.chat_token_daily_limit == -1:
|
||||
raise HTTPException(status_code=400, detail="Cannot distribute equally when Tenant has Unlimited quota.")
|
||||
|
||||
active_users = db.query(User).filter(
|
||||
User.tenant_id == effective_tenant_id,
|
||||
User.is_active == True,
|
||||
User.is_deleted == False
|
||||
).all()
|
||||
|
||||
if not active_users:
|
||||
return {"message": "No active users to distribute credits to."}
|
||||
|
||||
total_tenant_credits = tenant.chat_token_daily_limit / 1000.0
|
||||
equal_quota = total_tenant_credits / len(active_users)
|
||||
|
||||
logger.info(f"Bulk distributing {total_tenant_credits} Cr among {len(active_users)} users. Quota per user: {equal_quota}")
|
||||
|
||||
chat_service = ChatbotService(db)
|
||||
for user in active_users:
|
||||
logger.info(f"Updating user {user.id} ({user.email}) to {equal_quota} Cr")
|
||||
chat_service.repo.update_user_quota(user.id, equal_quota)
|
||||
|
||||
return {
|
||||
"message": f"Successfully distributed {total_tenant_credits} Cr among {len(active_users)} users. Each user now has {equal_quota} Cr.",
|
||||
"quota_per_user": equal_quota
|
||||
}
|
||||
|
||||
|
||||
def _visible_user_or_404(db: Session, current_user: User, user_id: int) -> User:
|
||||
"""
|
||||
The target user, or 404 — never 403.
|
||||
|
||||
A 403 confirms the user exists, which is the leak the cross-tenant probes
|
||||
were written to catch. Applies the tenant predicate and, when
|
||||
`ORG_SCOPE_ENABLED`, the org-scope check, exactly as the read endpoints
|
||||
above do.
|
||||
"""
|
||||
query = (
|
||||
db.query(User)
|
||||
.options(joinedload(User.role), joinedload(User.tenant))
|
||||
.filter(User.id == user_id)
|
||||
)
|
||||
if not _is_superadmin(current_user) and current_user.tenant_id:
|
||||
query = query.filter(User.tenant_id == current_user.tenant_id)
|
||||
user = query.first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if settings.ORG_SCOPE_ENABLED and not ScopeService(db).can_access_user(
|
||||
current_user, user.id, "admin.user.read"
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{user_id}/roles",
|
||||
response_model=List[RoleAssignmentOut],
|
||||
dependencies=[require_access("admin.role.read")],
|
||||
)
|
||||
def list_user_roles(
|
||||
user_id: int,
|
||||
include_expired: bool = Query(
|
||||
False,
|
||||
description=(
|
||||
"Include grants that have lapsed. They confer nothing; they are "
|
||||
"shown so an administrator can see that a temporary grant ended "
|
||||
"rather than wondering where it went."
|
||||
),
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Every role this user holds: primary, granted, and through a group."""
|
||||
user = _visible_user_or_404(db, current_user, user_id)
|
||||
assignments = UserRoleReader(db).assignments(user, include_expired=include_expired)
|
||||
return [RoleAssignmentOut(**a) for a in assignments]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{user_id}/roles",
|
||||
response_model=RoleAssignmentOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[require_access("admin.role.assign")],
|
||||
)
|
||||
def add_user_role(
|
||||
user_id: int,
|
||||
payload: AddUserRoleIn,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Grant this user an additional role.
|
||||
|
||||
`org_unit_id` omitted means **tenant-wide**, which is the authority a
|
||||
primary role already carries and the case the organisation tree could not
|
||||
express.
|
||||
|
||||
Does **not** touch `users.role_id`. The primary role is left exactly as it
|
||||
was, so nothing an administrator has already set is silently replaced.
|
||||
"""
|
||||
from app.modules.org.services.grant_service import GrantService
|
||||
|
||||
user = _visible_user_or_404(db, current_user, user_id)
|
||||
|
||||
row = GrantService(db).grant(
|
||||
current_user,
|
||||
role_id=payload.role_id,
|
||||
user_id=user.id,
|
||||
org_unit_id=payload.org_unit_id,
|
||||
expires_at=payload.expires_at,
|
||||
)
|
||||
|
||||
for assignment in UserRoleReader(db).assignments(user):
|
||||
if assignment["grant_id"] == row.id:
|
||||
return RoleAssignmentOut(**assignment)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=500, detail="The grant was written but did not resolve."
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{user_id}/roles/{grant_id}",
|
||||
response_model=MessageOut,
|
||||
dependencies=[require_access("admin.role.assign")],
|
||||
)
|
||||
def remove_user_role(
|
||||
user_id: int,
|
||||
grant_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Revoke one granted role.
|
||||
|
||||
The **primary** role is not revocable here — it has no `grant_id`, and it is
|
||||
changed through `PATCH /api/admin/users/{id}`. Offering a Remove button that
|
||||
silently did nothing to it would be the worst of both.
|
||||
|
||||
A grant reaching this user **through a group** is likewise not revocable
|
||||
here: it belongs to the group, and removing it from one member would mean
|
||||
editing the group for everybody.
|
||||
"""
|
||||
from app.modules.org.services.grant_service import GrantService
|
||||
|
||||
_visible_user_or_404(db, current_user, user_id)
|
||||
|
||||
row = UserRoleReader(db).grant_row(current_user.tenant_id, grant_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Grant not found")
|
||||
|
||||
GrantService(db).revoke(current_user, grant_id)
|
||||
return {"message": "Role removed"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{user_id}/effective-access",
|
||||
response_model=List[EffectiveAccessOut],
|
||||
dependencies=[require_access("admin.user.read")],
|
||||
)
|
||||
def user_effective_access(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Every access code this user holds, and which role confers each one.
|
||||
|
||||
"Why can this person do that" answered itself while a user had one role.
|
||||
With several it does not, and the alternative is reading the database — so
|
||||
this exists before the interface that makes several roles easy to create.
|
||||
|
||||
Descendant codes are attributed to the role holding their parent, because
|
||||
that is the row an administrator would edit to take the code away.
|
||||
"""
|
||||
user = _visible_user_or_404(db, current_user, user_id)
|
||||
|
||||
attributions = PermissionService(db).granted_by(user)
|
||||
|
||||
return [
|
||||
EffectiveAccessOut(access_code=code, granted_by=sources)
|
||||
for code, sources in sorted(attributions.items())
|
||||
]
|
||||
@@ -0,0 +1,499 @@
|
||||
from fastapi import APIRouter, Depends, Response, Request, HTTPException
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import get_db
|
||||
from app.modules.auth.controllers.auth_controller import AuthController, UserController
|
||||
from app.modules.auth.repositories.user_repository import UserRepository
|
||||
from app.modules.auth.schemas.auth_schema import (
|
||||
RegisterIn,
|
||||
Token,
|
||||
UserOut,
|
||||
GoogleLoginIn,
|
||||
UpdateProfileIn,
|
||||
ChangePasswordIn,
|
||||
RegisterOut,
|
||||
ForgotPasswordIn,
|
||||
ResetPasswordIn,
|
||||
)
|
||||
from app.middleware.auth import get_current_user, oauth2_scheme
|
||||
from app.core.token_blacklist import TokenBlacklist
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.tenant.models.tenant_model import Tenant
|
||||
from app.middleware.tenant import get_tenant_from_header
|
||||
from app.core.settings import settings
|
||||
from jose import jwt
|
||||
from app.modules.auth.schemas.access_schema import EditorTokenOut
|
||||
from app.core.schemas import MessageOut, StatusMessageOut
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||
|
||||
@router.post("/register", response_model=RegisterOut)
|
||||
def register(
|
||||
request: Request,
|
||||
payload: RegisterIn,
|
||||
db: Session = Depends(get_db),
|
||||
tenant: Tenant = Depends(get_tenant_from_header),
|
||||
):
|
||||
return AuthController.register_user(payload, tenant, db)
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
token_data = AuthController.login_user(form_data, db, request)
|
||||
|
||||
response.set_cookie(
|
||||
key="docqube_access_token",
|
||||
value=token_data["access_token"],
|
||||
httponly=True,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
max_age=3600,
|
||||
path="/",
|
||||
)
|
||||
|
||||
response.set_cookie(
|
||||
key="docqube_refresh_token",
|
||||
value=token_data["refresh_token"],
|
||||
httponly=True,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
max_age=7 * 24 * 3600,
|
||||
path="/api/auth/refresh",
|
||||
)
|
||||
|
||||
response.set_cookie(
|
||||
key="docqube_has_session",
|
||||
value="true",
|
||||
httponly=False,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
max_age=7 * 24 * 3600,
|
||||
path="/",
|
||||
)
|
||||
|
||||
if hasattr(request.state, "new_device_id"):
|
||||
response.set_cookie(
|
||||
key="docqube_device_id",
|
||||
value=request.state.new_device_id,
|
||||
httponly=True,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
max_age=365 * 24 * 3600,
|
||||
path="/",
|
||||
)
|
||||
|
||||
return token_data
|
||||
|
||||
@router.post("/google", response_model=Token)
|
||||
def google_login(
|
||||
request: Request, response: Response, payload: GoogleLoginIn, db: Session = Depends(get_db)
|
||||
):
|
||||
token_data = AuthController.google_login(payload, db, request)
|
||||
|
||||
response.set_cookie(
|
||||
key="docqube_access_token",
|
||||
value=token_data["access_token"],
|
||||
httponly=True,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
max_age=3600,
|
||||
path="/",
|
||||
)
|
||||
|
||||
response.set_cookie(
|
||||
key="docqube_refresh_token",
|
||||
value=token_data["refresh_token"],
|
||||
httponly=True,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
max_age=7 * 24 * 3600,
|
||||
path="/api/auth/refresh",
|
||||
)
|
||||
|
||||
response.set_cookie(
|
||||
key="docqube_has_session",
|
||||
value="true",
|
||||
httponly=False,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
max_age=7 * 24 * 3600,
|
||||
path="/",
|
||||
)
|
||||
|
||||
if hasattr(request.state, "new_device_id"):
|
||||
response.set_cookie(
|
||||
key="docqube_device_id",
|
||||
value=request.state.new_device_id,
|
||||
httponly=True,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
max_age=365 * 24 * 3600,
|
||||
path="/",
|
||||
)
|
||||
|
||||
return token_data
|
||||
|
||||
@router.post("/forgot-password", response_model=MessageOut)
|
||||
def forgot_password(
|
||||
request: Request, payload: ForgotPasswordIn, db: Session = Depends(get_db)
|
||||
):
|
||||
return AuthController.forgot_password(payload, db)
|
||||
|
||||
@router.post("/reset-password")
|
||||
def reset_password(
|
||||
request: Request, payload: ResetPasswordIn, db: Session = Depends(get_db)
|
||||
):
|
||||
return AuthController.reset_password(payload, db)
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
@router.get("/verify", response_model=UserOut)
|
||||
def verify_auth(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
|
||||
@router.post("/accept-terms", response_model=StatusMessageOut)
|
||||
def accept_terms(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
if not user.terms_accepted:
|
||||
user.terms_accepted = True
|
||||
return {"status": "success", "message": "Terms accepted successfully"}
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
def refresh_token(request: Request, response: Response, db: Session = Depends(get_db)):
|
||||
refresh_token = request.cookies.get("docqube_refresh_token")
|
||||
if not refresh_token:
|
||||
raise HTTPException(status_code=401, detail="Refresh token missing")
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
refresh_token, settings.APP_SECRET, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
user_id = payload.get("sub")
|
||||
token_type = payload.get("type")
|
||||
|
||||
if not user_id or token_type != "refresh":
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
|
||||
session_id = payload.get("session_id")
|
||||
if session_id:
|
||||
from app.modules.auth.repositories.session_repository import SessionRepository
|
||||
from app.core.security import verify_password
|
||||
session_repo = SessionRepository(db)
|
||||
session = session_repo.get_by_id(session_id)
|
||||
if not session or session.status != "ACTIVE":
|
||||
if session and session.status == "REVOKED":
|
||||
if session.revoked_by == "reauth":
|
||||
raise HTTPException(status_code=401, detail="You have logged in from another tab on this device. Please refresh.")
|
||||
else:
|
||||
raise HTTPException(status_code=401, detail="Your session was remotely logged out from another device.")
|
||||
raise HTTPException(status_code=401, detail="Your session has expired.")
|
||||
|
||||
if not session.refresh_token_hash or session.refresh_token_hash == "pending" or not verify_password(refresh_token, session.refresh_token_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token hash")
|
||||
|
||||
from datetime import timedelta, datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
last_activity = session.last_activity
|
||||
if last_activity and last_activity.tzinfo is None:
|
||||
last_activity = last_activity.replace(tzinfo=timezone.utc)
|
||||
if not last_activity or now - last_activity > timedelta(minutes=5):
|
||||
session_repo.update_last_activity(session)
|
||||
|
||||
user_repo = UserRepository(db)
|
||||
user = user_repo.get_by_id(int(user_id))
|
||||
if not user or user.is_deleted or (hasattr(user, "is_active") and not user.is_active):
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
from app.core.security import create_access_token
|
||||
|
||||
payload_access = {
|
||||
"sub": str(user.id),
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
}
|
||||
if session_id:
|
||||
payload_access["session_id"] = session_id
|
||||
|
||||
new_access_token = create_access_token(payload_access)
|
||||
|
||||
response.set_cookie(
|
||||
key="docqube_access_token",
|
||||
value=new_access_token,
|
||||
httponly=True,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
max_age=3600,
|
||||
path="/",
|
||||
)
|
||||
|
||||
response.set_cookie(
|
||||
key="docqube_has_session",
|
||||
value="true",
|
||||
httponly=False,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
max_age=7 * 24 * 3600,
|
||||
path="/",
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": new_access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
except HTTPException as he:
|
||||
raise he
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
|
||||
@router.get("/editor-token", response_model=EditorTokenOut)
|
||||
def get_editor_token(current_user: User = Depends(get_current_user)):
|
||||
from app.core.security import create_access_token
|
||||
token = create_access_token(
|
||||
{
|
||||
"sub": str(current_user.id),
|
||||
"tenant_id": str(current_user.tenant_id) if current_user.tenant_id else None,
|
||||
}
|
||||
)
|
||||
return {"token": token}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(
|
||||
response: Response,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
request: Request = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
auth_token = token
|
||||
if not auth_token and request:
|
||||
auth_token = request.cookies.get("docqube_access_token")
|
||||
|
||||
if auth_token:
|
||||
try:
|
||||
unverified_payload = jwt.get_unverified_claims(auth_token)
|
||||
jti = unverified_payload.get("jti")
|
||||
exp = unverified_payload.get("exp")
|
||||
|
||||
if jti:
|
||||
ttl = 3600
|
||||
if exp:
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.utcnow().timestamp()
|
||||
ttl = max(1, int(exp - now))
|
||||
|
||||
TokenBlacklist.add(jti, expires_in=ttl)
|
||||
|
||||
session_id = unverified_payload.get("session_id")
|
||||
if session_id:
|
||||
from app.modules.auth.services.auth_service import AuthService
|
||||
AuthService(db).logout(session_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response.delete_cookie(
|
||||
"docqube_access_token",
|
||||
path="/",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
secure=settings.APP_ENV == "production",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
)
|
||||
response.delete_cookie(
|
||||
"docqube_refresh_token",
|
||||
path="/api/auth/refresh",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
secure=settings.APP_ENV == "production",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
)
|
||||
response.delete_cookie(
|
||||
"docqube_has_session",
|
||||
path="/",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
secure=settings.APP_ENV == "production",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
)
|
||||
return {"status": "success", "message": "Logged out"}
|
||||
|
||||
from pydantic import BaseModel
|
||||
class ResolveDeviceLimitIn(BaseModel):
|
||||
logout_device_id: int
|
||||
|
||||
@router.post("/resolve-device-limit")
|
||||
def resolve_device_limit(
|
||||
payload: ResolveDeviceLimitIn,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
|
||||
try:
|
||||
payload_jwt = jwt.decode(token, settings.APP_SECRET, algorithms=[settings.ALGORITHM])
|
||||
jti = payload_jwt.get("jti")
|
||||
if jti and TokenBlacklist.is_blacklisted(jti):
|
||||
raise HTTPException(status_code=401, detail="Token blacklisted")
|
||||
except HTTPException as he:
|
||||
raise he
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
if payload_jwt.get("type") != "device_management":
|
||||
raise HTTPException(status_code=403, detail="Invalid token type")
|
||||
|
||||
user_id = int(payload_jwt.get("sub"))
|
||||
|
||||
from app.modules.auth.repositories.device_repository import DeviceRepository
|
||||
from app.modules.auth.repositories.session_repository import SessionRepository
|
||||
|
||||
device_repo = DeviceRepository(db)
|
||||
device = device_repo.get_by_id(payload.logout_device_id)
|
||||
|
||||
if not device or device.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
|
||||
session_repo = SessionRepository(db)
|
||||
session_repo.revoke_all_for_device(device.id, by="user")
|
||||
|
||||
return {"status": "success", "message": "Device logged out successfully. You can now login."}
|
||||
|
||||
from typing import List
|
||||
from app.modules.auth.schemas.auth_schema import DeviceOut
|
||||
|
||||
@router.get("/devices", response_model=List[DeviceOut])
|
||||
def get_user_devices(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
from app.modules.auth.repositories.device_repository import DeviceRepository
|
||||
from app.modules.auth.utils.device_utils import generate_device_fingerprint
|
||||
|
||||
device_repo = DeviceRepository(db)
|
||||
devices = device_repo.get_user_devices(current_user.id)
|
||||
|
||||
current_fingerprint = generate_device_fingerprint(request)
|
||||
|
||||
results = []
|
||||
for d in devices:
|
||||
results.append({
|
||||
"id": d.id,
|
||||
"browser": d.browser,
|
||||
"os": d.os,
|
||||
"device_type": d.device_type,
|
||||
"city": d.city,
|
||||
"country": d.country,
|
||||
"state": d.state,
|
||||
"last_login": d.last_login,
|
||||
"is_current_device": d.fingerprint == current_fingerprint
|
||||
})
|
||||
results.sort(key=lambda x: not x["is_current_device"])
|
||||
return results
|
||||
|
||||
@router.delete("/devices/{device_id}")
|
||||
def logout_device(
|
||||
device_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
from app.modules.auth.repositories.device_repository import DeviceRepository
|
||||
from app.modules.auth.repositories.session_repository import SessionRepository
|
||||
|
||||
device_repo = DeviceRepository(db)
|
||||
device = device_repo.get_by_id(device_id)
|
||||
|
||||
if not device or device.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
|
||||
session_repo = SessionRepository(db)
|
||||
session_repo.revoke_all_for_device(device.id, by="user")
|
||||
|
||||
try:
|
||||
from app.modules.activity_logs.service import log_event
|
||||
from app.modules.activity_logs.constants import ActivityLogModule, ActivityLogAction, ActivityLogStatus, ActivityLogTargetType
|
||||
log_event(
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
user_email=current_user.email,
|
||||
module=ActivityLogModule.TENANT,
|
||||
action=ActivityLogAction.DEVICE_LOGOUT,
|
||||
target_id=str(device.id),
|
||||
target_type=ActivityLogTargetType.SESSION,
|
||||
metadata={
|
||||
"device_name": device.device_name,
|
||||
"ip_address": device.ip_address,
|
||||
},
|
||||
status=ActivityLogStatus.SUCCESS
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Failed to log device logout: {e}")
|
||||
|
||||
return {"status": "success", "message": "Logged out from device successfully"}
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
import asyncio
|
||||
|
||||
@router.get("/session/events")
|
||||
async def session_events(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
SSE Endpoint for real-time session and device revocation events.
|
||||
Frontend can listen to this to instantly log out when their session is revoked.
|
||||
"""
|
||||
from app.core.redis import redis_pubsub
|
||||
import json
|
||||
|
||||
async def event_generator():
|
||||
if not redis_pubsub._client:
|
||||
await redis_pubsub.connect()
|
||||
|
||||
if not redis_pubsub._client:
|
||||
return
|
||||
|
||||
pubsub = redis_pubsub._client.pubsub()
|
||||
channel = f"user_events:{current_user.id}"
|
||||
await pubsub.subscribe(channel)
|
||||
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
|
||||
if message and message["type"] == "message":
|
||||
data = message["data"]
|
||||
if isinstance(data, bytes):
|
||||
data = data.decode('utf-8')
|
||||
yield f"data: {data}\n\n"
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
await pubsub.unsubscribe(channel)
|
||||
await pubsub.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
@@ -0,0 +1,213 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.modules.auth.schemas.access_schema import (
|
||||
ApiKeyStatusOut,
|
||||
MeStatsOut,
|
||||
StorageNonceOut,
|
||||
)
|
||||
from app.modules.auth.controllers.auth_controller import UserController
|
||||
from app.modules.auth.schemas.auth_schema import (
|
||||
UserOut,
|
||||
UpdateProfileIn,
|
||||
ChangePasswordIn,
|
||||
UserSMTPCredentialsOut,
|
||||
UserSMTPCredentialsPatchIn,
|
||||
)
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.core.settings import settings
|
||||
from app.core.crypto import encrypt_data
|
||||
from app.core.mail import _resolve_smtp_config
|
||||
from pydantic import BaseModel
|
||||
from app.core.schemas import StatusOut
|
||||
|
||||
class TutorialCompleteSchema(BaseModel):
|
||||
key: str = "dashboard"
|
||||
|
||||
router = APIRouter(prefix="/me", tags=["User Profile"])
|
||||
|
||||
@router.get("/profile", response_model=UserOut)
|
||||
def get_profile(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
|
||||
@router.get("/storage-nonce", response_model=StorageNonceOut)
|
||||
def get_storage_nonce(user: User = Depends(get_current_user)):
|
||||
"""
|
||||
Returns a deterministic, user-specific nonce for client-side encryption.
|
||||
Combined with APP_SECRET to ensure it's not guessing-prone.
|
||||
"""
|
||||
nonce = hmac.new(
|
||||
settings.APP_SECRET.encode(),
|
||||
f"user_storage_{user.id}_{user.email}".encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
return {"nonce": nonce}
|
||||
|
||||
@router.patch("/profile", response_model=UserOut)
|
||||
def update_profile(
|
||||
payload: UpdateProfileIn,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return UserController.update_profile(
|
||||
user,
|
||||
payload.name,
|
||||
db,
|
||||
designation=payload.designation,
|
||||
preferred_language=payload.preferred_language,
|
||||
)
|
||||
|
||||
@router.post("/password")
|
||||
def change_password(
|
||||
payload: ChangePasswordIn,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return UserController.change_password(user, payload.current_password, payload.new_password, db)
|
||||
|
||||
@router.get("/stats", response_model=MeStatsOut)
|
||||
def get_stats(
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return UserController.get_user_stats(user, db)
|
||||
|
||||
@router.delete("/account")
|
||||
def delete_account(
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return UserController.delete_account(user, db)
|
||||
|
||||
@router.post("/api-key", response_model=StatusOut)
|
||||
def update_api_key(
|
||||
payload: dict,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
key = payload.get("key")
|
||||
if key and str(key).strip():
|
||||
from app.core.crypto import encrypt_data
|
||||
user.encrypted_ai_api_key = encrypt_data(str(key).strip())
|
||||
else:
|
||||
user.encrypted_ai_api_key = None
|
||||
return {"status": "success"}
|
||||
|
||||
@router.get("/api-key/status", response_model=ApiKeyStatusOut)
|
||||
def get_api_key_status(user: User = Depends(get_current_user)):
|
||||
return {"has_key": bool(user.encrypted_ai_api_key)}
|
||||
|
||||
|
||||
@router.get("/smtp-credentials", response_model=UserSMTPCredentialsOut)
|
||||
def get_smtp_credentials(
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
smtp_config = _resolve_smtp_config(
|
||||
db=db,
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return UserSMTPCredentialsOut(
|
||||
configured=bool(user.smtp_user and user.encrypted_smtp_password),
|
||||
source=smtp_config["source"],
|
||||
smtp_host=smtp_config["smtp_host"],
|
||||
smtp_port=smtp_config["smtp_port"],
|
||||
smtp_user=smtp_config["smtp_user"],
|
||||
mail_from=smtp_config["mail_from"],
|
||||
has_password=bool(user.encrypted_smtp_password),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/smtp-credentials", response_model=UserSMTPCredentialsOut)
|
||||
def update_smtp_credentials(
|
||||
payload: UserSMTPCredentialsPatchIn,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if payload.smtp_user is not None:
|
||||
cleaned_user = payload.smtp_user.strip()
|
||||
user.smtp_user = cleaned_user or None
|
||||
|
||||
if payload.mail_from is not None:
|
||||
cleaned_mail_from = str(payload.mail_from).strip()
|
||||
user.mail_from = cleaned_mail_from or None
|
||||
|
||||
if payload.smtp_password is not None:
|
||||
cleaned_password = payload.smtp_password.strip()
|
||||
user.encrypted_smtp_password = (
|
||||
encrypt_data(cleaned_password) if cleaned_password else None
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.refresh(user)
|
||||
|
||||
smtp_config = _resolve_smtp_config(
|
||||
db=db,
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return UserSMTPCredentialsOut(
|
||||
configured=bool(user.smtp_user and user.encrypted_smtp_password),
|
||||
source=smtp_config["source"],
|
||||
smtp_host=smtp_config["smtp_host"],
|
||||
smtp_port=smtp_config["smtp_port"],
|
||||
smtp_user=smtp_config["smtp_user"],
|
||||
mail_from=smtp_config["mail_from"],
|
||||
has_password=bool(user.encrypted_smtp_password),
|
||||
)
|
||||
|
||||
@router.post("/tutorial-complete", response_model=UserOut)
|
||||
def complete_tutorial(
|
||||
payload: TutorialCompleteSchema,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
tutorial_key = payload.key
|
||||
print(f"DEBUG: Tutorial completion request for user {user.email}, key: {tutorial_key}")
|
||||
|
||||
if tutorial_key == "docs":
|
||||
user.has_completed_docs_tutorial = True
|
||||
print(f"DEBUG: Setting has_completed_docs_tutorial = True")
|
||||
else:
|
||||
user.has_completed_tutorial = True
|
||||
print(f"DEBUG: Setting has_completed_tutorial = True")
|
||||
|
||||
db.add(user)
|
||||
@router.post("/tutorial/finish", response_model=UserOut)
|
||||
def finish_tutorial(
|
||||
payload: Optional[TutorialCompleteSchema] = None,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
A specific, robust endpoint to mark tutorials as finished.
|
||||
Automatically handles both dashboard and docs depending on the key.
|
||||
"""
|
||||
key = payload.key if payload else "dashboard"
|
||||
if key == "docs":
|
||||
user.has_completed_docs_tutorial = True
|
||||
elif key == "dashboard":
|
||||
user.has_completed_tutorial = True
|
||||
elif key == "all":
|
||||
user.has_completed_tutorial = True
|
||||
user.has_completed_docs_tutorial = True
|
||||
|
||||
try:
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.refresh(user)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail="Database persistence failed")
|
||||
raise HTTPException(status_code=500, detail="Database persistence failed")
|
||||
|
||||
return user
|
||||
@@ -0,0 +1,344 @@
|
||||
from app.modules.auth.schemas.access_schema import RoleAssignedOut
|
||||
"""
|
||||
Role Management Routes - RBAC endpoints for creating, updating, deleting roles.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.auth.controllers.role_controller import RoleController
|
||||
from app.modules.auth.schemas.role_schema import (
|
||||
RoleCreate,
|
||||
RoleUpdate,
|
||||
RoleOut,
|
||||
RoleWithAccessesOut,
|
||||
RolePaginatedOut,
|
||||
)
|
||||
from app.modules.auth.dependencies.access_dependency import require_access
|
||||
from app.modules.auth.services.privilege import holds_superadmin_access
|
||||
from app.modules.auth.services.user_role_service import UserRoleReader
|
||||
from app.core.schemas import MessageOut
|
||||
|
||||
|
||||
class AssignUserRoleIn(BaseModel):
|
||||
user_id: int
|
||||
role_id: UUID
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/roles", tags=["Roles"])
|
||||
|
||||
|
||||
def _is_superadmin(current_user: User) -> bool:
|
||||
"""True when the caller holds any superadmin access code."""
|
||||
return holds_superadmin_access(current_user)
|
||||
|
||||
|
||||
def _assert_role_in_callers_tenant(role, current_user: User, db: Session = None, allow_system_roles: bool = False) -> None:
|
||||
"""
|
||||
Refuse to act on a role belonging to another tenant.
|
||||
|
||||
`RoleController.get_role_by_id` looks a role up by primary key alone, so
|
||||
without this every by-id route was reachable across tenants — a tenant
|
||||
administrator could read, rename and delete another tenant's roles by
|
||||
guessing or observing a UUID. Raises 404 rather than 403 so the response
|
||||
does not confirm that the role exists.
|
||||
"""
|
||||
if _is_superadmin(current_user):
|
||||
return
|
||||
if role.tenant_id == current_user.tenant_id:
|
||||
return
|
||||
|
||||
if allow_system_roles and getattr(role, "is_system", False) and current_user.tenant_id and db:
|
||||
from app.modules.billing.models.plan_model import TenantSubscription, PlanRole
|
||||
sub = db.query(TenantSubscription).filter(
|
||||
TenantSubscription.tenant_id == current_user.tenant_id,
|
||||
TenantSubscription.status == 'active'
|
||||
).order_by(TenantSubscription.created_at.desc()).first()
|
||||
if sub and sub.plan_id:
|
||||
has_role = db.query(PlanRole).filter(
|
||||
PlanRole.plan_id == sub.plan_id,
|
||||
PlanRole.role_id == role.id
|
||||
).first()
|
||||
if has_role:
|
||||
return
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/", response_model=RoleOut, dependencies=[require_access("admin.role.create")]
|
||||
)
|
||||
def create_role(
|
||||
payload: RoleCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new role.
|
||||
|
||||
Required access: admin.role.create | superadmin.role.create
|
||||
|
||||
- **role_name**: Name of the role
|
||||
- **description**: Optional description
|
||||
- **access_ids**: List of access IDs to assign
|
||||
- **tenant_id**: Tenant ID (for tenant-specific roles)
|
||||
"""
|
||||
if not payload.tenant_id and current_user.tenant_id:
|
||||
payload.tenant_id = current_user.tenant_id
|
||||
|
||||
result = RoleController.create_role(db, payload)
|
||||
try:
|
||||
from app.modules.activity_logs.service import log_event
|
||||
from app.modules.activity_logs.constants import ActivityLogModule, ActivityLogAction, ActivityLogStatus, ActivityLogTargetType
|
||||
if payload.tenant_id:
|
||||
log_event(
|
||||
tenant_id=payload.tenant_id,
|
||||
user_id=current_user.id,
|
||||
user_email=current_user.email,
|
||||
module=ActivityLogModule.TENANT,
|
||||
action=ActivityLogAction.ROLE_CREATED,
|
||||
target_id=str(result.id),
|
||||
target_type=ActivityLogTargetType.ROLE,
|
||||
metadata={
|
||||
"role_name": payload.role_name
|
||||
},
|
||||
status=ActivityLogStatus.SUCCESS
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Failed to log ROLE_CREATED: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/", response_model=List[RoleOut], dependencies=[require_access("admin.role.read")]
|
||||
)
|
||||
def get_all_roles(
|
||||
tenant_id: Optional[UUID] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all roles.
|
||||
|
||||
Required access: admin.role.read | superadmin.role.read
|
||||
|
||||
Superadmins see roles across all tenants by default, or a single
|
||||
tenant when `?tenant_id=<uuid>` is provided. Tenant admins are
|
||||
always scoped to their own tenant regardless of the query param.
|
||||
"""
|
||||
effective_tenant_id = (
|
||||
tenant_id if _is_superadmin(current_user) else current_user.tenant_id
|
||||
)
|
||||
return RoleController.get_all_roles(db, effective_tenant_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{role_id}",
|
||||
response_model=RoleOut,
|
||||
dependencies=[require_access("admin.role.read")],
|
||||
)
|
||||
def get_role(
|
||||
role_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific role by ID.
|
||||
|
||||
Required access: admin.role.read | superadmin.role.read
|
||||
"""
|
||||
role = RoleController.get_role_by_id(db, role_id)
|
||||
_assert_role_in_callers_tenant(role, current_user, db, allow_system_roles=True)
|
||||
return role
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{role_id}/details",
|
||||
response_model=RoleWithAccessesOut,
|
||||
dependencies=[require_access("admin.role.read")],
|
||||
)
|
||||
def get_role_with_accesses(
|
||||
role_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a role with all assigned access codes.
|
||||
|
||||
Required access: admin.role.read | superadmin.role.read
|
||||
|
||||
Also reports how many principals hold it, so the editing screen can say what
|
||||
a change affects. Counted here and not on the list endpoints, which render
|
||||
up to a hundred roles.
|
||||
"""
|
||||
_assert_role_in_callers_tenant(
|
||||
RoleController.get_role_by_id(db, role_id), current_user, db, allow_system_roles=True
|
||||
)
|
||||
role = RoleController.get_role_with_accesses(db, role_id)
|
||||
out = RoleWithAccessesOut.model_validate(role)
|
||||
out.assigned_user_count = UserRoleReader(db).users_holding_role(role_id)
|
||||
return out
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{role_id}",
|
||||
response_model=RoleOut,
|
||||
dependencies=[require_access("admin.role.update")],
|
||||
)
|
||||
def update_role(
|
||||
role_id: UUID,
|
||||
payload: RoleUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update a role's details and accesses.
|
||||
|
||||
Required access: admin.role.update | superadmin.role.update
|
||||
|
||||
- **role_name**: New role name (optional)
|
||||
- **description**: New description (optional)
|
||||
- **access_ids**: New list of access IDs (optional)
|
||||
"""
|
||||
role = RoleController.get_role_by_id(db, role_id)
|
||||
_assert_role_in_callers_tenant(role, current_user)
|
||||
if role.is_default and current_user.tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Default roles cannot be modified by tenant admins."
|
||||
)
|
||||
return RoleController.update_role(db, role_id, payload)
|
||||
|
||||
|
||||
@router.delete("/{role_id}", dependencies=[require_access("admin.role.update")], response_model=MessageOut)
|
||||
def delete_role(
|
||||
role_id: UUID,
|
||||
force: bool = Query(
|
||||
False,
|
||||
description=(
|
||||
"Delete even though the role is still held. Required once anybody "
|
||||
"holds it, so the removal of their authority is a decision rather "
|
||||
"than a side effect."
|
||||
),
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a role.
|
||||
|
||||
Required access: admin.role.update | superadmin.role.delete
|
||||
|
||||
**Refuses with 409 while the role is still held**, unless `?force=true`.
|
||||
|
||||
Deleting a role has always taken authority away silently: `user_roles.role_id`
|
||||
is `ON DELETE CASCADE`, so every grant of it disappears, and `users.role_id`
|
||||
is `ON DELETE SET NULL`, so every holder loses their primary role. Neither
|
||||
leaves a trace and neither was ever counted for the operator. With one role
|
||||
per user that was survivable; with roles granted in several places it is
|
||||
not, because the operator can no longer hold the affected set in their head.
|
||||
|
||||
The cascade behaviour is unchanged. Only the silence is.
|
||||
"""
|
||||
role = RoleController.get_role_by_id(db, role_id)
|
||||
_assert_role_in_callers_tenant(role, current_user)
|
||||
if role.is_default and current_user.tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Default roles cannot be deleted by tenant admins."
|
||||
)
|
||||
|
||||
if not force:
|
||||
holders = UserRoleReader(db).users_holding_role(role_id)
|
||||
if holders:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
f"This role cannot be deleted because there are currently {holders} "
|
||||
f"active {'assignment' if holders == 1 else 'assignments'} of it. "
|
||||
"Please reassign or remove them first."
|
||||
),
|
||||
)
|
||||
|
||||
return RoleController.delete_role(db, role_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tenant/{tenant_id}",
|
||||
response_model=List[RoleOut],
|
||||
dependencies=[require_access("admin.role.read")],
|
||||
)
|
||||
def get_roles_by_tenant(
|
||||
tenant_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all roles for a specific tenant.
|
||||
|
||||
The tenant is named by the caller, so a tenant administrator must not be
|
||||
able to name somebody else's.
|
||||
"""
|
||||
if not _is_superadmin(current_user) and tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found"
|
||||
)
|
||||
return RoleController.get_all_roles(db, tenant_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/assign-user",
|
||||
dependencies=[require_access("admin.role.assign")],
|
||||
response_model=RoleAssignedOut)
|
||||
def assign_role_to_user(
|
||||
payload: AssignUserRoleIn,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Assign a role to a user."""
|
||||
return RoleController.assign_role_to_user(db, payload.user_id, payload.role_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=RolePaginatedOut,
|
||||
dependencies=[require_access("admin.role.read")],
|
||||
)
|
||||
def list_roles_paginated(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
search: Optional[str] = Query(None),
|
||||
tenant_id: Optional[UUID] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get paginated list of roles with optional search.
|
||||
|
||||
Required access: admin.role.read | superadmin.role.read
|
||||
|
||||
Superadmins see roles across all tenants by default, or a single
|
||||
tenant when `?tenant_id=<uuid>` is provided. Tenant admins are
|
||||
always scoped to their own tenant regardless of the query param.
|
||||
"""
|
||||
effective_tenant_id = (
|
||||
tenant_id if _is_superadmin(current_user) else current_user.tenant_id
|
||||
)
|
||||
|
||||
return RoleController.get_roles_paginated(
|
||||
db=db,
|
||||
tenant_id=effective_tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.auth.repositories.user_repository import UserRepository
|
||||
from app.modules.auth.schemas.auth_schema import UserOut
|
||||
from app.modules.auth.services.user_role_service import UserRoleReader, attach_role_refs
|
||||
from app.middleware.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["Users"])
|
||||
|
||||
@router.get("/search", response_model=List[UserOut])
|
||||
def search_users(
|
||||
q: str = Query(..., min_length=1),
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Search users by name or email, within the caller's own tenant.
|
||||
|
||||
Previously unscoped: any authenticated user could enumerate every user of
|
||||
every tenant, complete with email addresses, roles and access codes. A
|
||||
caller with no tenant is a superadmin under today's model and still
|
||||
searches across tenants.
|
||||
"""
|
||||
user_repo = UserRepository(db)
|
||||
users = user_repo.search_users(q, limit, tenant_id=current_user.tenant_id)
|
||||
|
||||
attach_role_refs(UserRoleReader(db), users)
|
||||
return users
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Response schemas for the access (RBAC catalogue) and self-service endpoints.
|
||||
|
||||
Every shape here was taken from a recorded response in
|
||||
`tests/characterization/snapshots`, not from the ORM models — the drive module
|
||||
showed how far the hand-written schemas had drifted from what the endpoints
|
||||
actually return, and a `response_model` that omits a field silently deletes it
|
||||
from the payload.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AccessOut(BaseModel):
|
||||
"""One row of the access catalogue. 66 exist."""
|
||||
|
||||
id: UUID
|
||||
name: str
|
||||
access_code: str
|
||||
category: str
|
||||
parent_id: Optional[UUID] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RoleAccessesOut(BaseModel):
|
||||
"""`GET /api/access/get` — the caller's roles and the codes they carry."""
|
||||
|
||||
role: Optional[str] = None
|
||||
"""The **primary** role. Retained and unchanged; `roles` is the full set."""
|
||||
|
||||
roles: List[str] = []
|
||||
"""
|
||||
Every role the caller holds, primary first.
|
||||
|
||||
Additive, and a plain list of names rather than the richer
|
||||
`RoleAssignmentOut` used elsewhere: this endpoint exists so the frontend can
|
||||
decide what to render, and scope and expiry do not change that decision.
|
||||
`/api/me/profile` carries the detailed shape.
|
||||
"""
|
||||
|
||||
tenant_id: Optional[str] = None
|
||||
accesses: List[str] = []
|
||||
|
||||
|
||||
class ApiKeyStatusOut(BaseModel):
|
||||
has_key: bool
|
||||
|
||||
|
||||
class StorageNonceOut(BaseModel):
|
||||
nonce: str
|
||||
|
||||
|
||||
class MeStatsOut(BaseModel):
|
||||
"""
|
||||
`GET /api/me/stats`. The camelCase keys are the existing contract — the
|
||||
frontend reads them as they are, so they are pinned rather than tidied.
|
||||
Renaming them is a frontend change, not a schema change.
|
||||
"""
|
||||
|
||||
totalProjects: int = 0
|
||||
totalFiles: int = 0
|
||||
subscription: Optional[str] = None
|
||||
joined: Optional[str] = None
|
||||
|
||||
|
||||
class EditorTokenOut(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class CeleryHealthOut(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class RoleAssignedOut(BaseModel):
|
||||
"""`POST /api/roles/assign-user`."""
|
||||
|
||||
message: str
|
||||
user_id: int
|
||||
role_id: str
|
||||
@@ -0,0 +1,57 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MetricTrendPointOut(BaseModel):
|
||||
date: str
|
||||
value: int
|
||||
|
||||
|
||||
class MetricCountOut(BaseModel):
|
||||
total: int
|
||||
recent: int
|
||||
trend: list[MetricTrendPointOut]
|
||||
|
||||
|
||||
class TotalMetricOut(BaseModel):
|
||||
total: int
|
||||
recent_added: int
|
||||
recent_deleted: int
|
||||
|
||||
|
||||
class ShareMetricOut(BaseModel):
|
||||
internal: int
|
||||
external: int
|
||||
|
||||
|
||||
class StorageMetricOut(BaseModel):
|
||||
used_bytes: int
|
||||
quota_bytes: int
|
||||
usage_percentage: float
|
||||
used_formatted: str
|
||||
quota_formatted: str
|
||||
|
||||
|
||||
class ConversionMetricOut(BaseModel):
|
||||
total: int
|
||||
completed: int
|
||||
processing: int
|
||||
failed: int
|
||||
completion_rate: float
|
||||
|
||||
|
||||
class SimpleMetricOut(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class AdminDashboardMetricsOut(BaseModel):
|
||||
signatures: MetricCountOut | None = None
|
||||
uploads: MetricCountOut
|
||||
users: TotalMetricOut
|
||||
shares: ShareMetricOut
|
||||
storage: StorageMetricOut
|
||||
conversions: ConversionMetricOut | None = None
|
||||
edits: SimpleMetricOut
|
||||
|
||||
|
||||
class MetricTrendOnlyOut(BaseModel):
|
||||
trend: list[MetricTrendPointOut]
|
||||
@@ -0,0 +1,182 @@
|
||||
from pydantic import BaseModel, EmailStr, ConfigDict, Field, field_validator
|
||||
from typing import Optional
|
||||
from typing import Literal
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: Optional[str] = None
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
class RegisterIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=12)
|
||||
name: str = Field(..., min_length=2)
|
||||
designation: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_complexity(cls, v: str) -> str:
|
||||
if not re.search(r"[A-Z]", v):
|
||||
raise ValueError("Password must contain at least one uppercase letter")
|
||||
if not re.search(r"[0-9]", v):
|
||||
raise ValueError("Password must contain at least one number")
|
||||
if not re.search(r"[^A-Za-z0-9]", v):
|
||||
raise ValueError("Password must contain at least one special character")
|
||||
return v
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
username: Optional[str] = None
|
||||
password: str
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.modules.auth.schemas.role_schema import RoleAssignmentOut
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
email: EmailStr
|
||||
name: str
|
||||
designation: Optional[str] = None
|
||||
preferred_language: str = "en"
|
||||
subscription: Optional[str] = "free"
|
||||
created_at: Optional[datetime] = None
|
||||
tenant_id: Optional[UUID] = None
|
||||
is_active: Optional[bool] = True
|
||||
terms_accepted: bool = False
|
||||
has_completed_tutorial: bool = False
|
||||
has_completed_docs_tutorial: bool = False
|
||||
|
||||
role: Optional[str] = Field(None, validation_alias="role_name")
|
||||
role_id: Optional[UUID] = None
|
||||
roles: list[RoleAssignmentOut] = Field(
|
||||
default_factory=list, validation_alias="role_refs"
|
||||
)
|
||||
"""
|
||||
Every role the user holds — primary, granted, and through a group.
|
||||
|
||||
**Additive.** `role` and `role_id` are retained and unchanged, because they
|
||||
are read by the frontend, by the admin screens and by anything else already
|
||||
consuming this shape. `role` remains the *primary* role specifically, which
|
||||
is what the existing dropdown edits.
|
||||
"""
|
||||
accesses: list[str] = Field(default_factory=list, validation_alias="access_codes")
|
||||
subscription_details: Optional[dict] = None
|
||||
tenant_envelope_limit: Optional[int] = None
|
||||
tenant_envelopes_used: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
||||
|
||||
|
||||
class GoogleLoginIn(BaseModel):
|
||||
token: Optional[str] = None
|
||||
credential: Optional[str] = None
|
||||
tenant_id: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateProfileIn(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=2, max_length=255)
|
||||
designation: Optional[str] = Field(None, max_length=100)
|
||||
preferred_language: Optional[str] = Field(None, min_length=2, max_length=16)
|
||||
|
||||
@field_validator("preferred_language")
|
||||
@classmethod
|
||||
def normalize_preferred_language(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("Preferred language cannot be empty")
|
||||
if not re.fullmatch(r"[a-z]{2,3}(?:-[a-z0-9]{2,8})?", normalized):
|
||||
raise ValueError("Preferred language must be a valid language code")
|
||||
return normalized
|
||||
|
||||
|
||||
class ChangePasswordIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(..., min_length=12)
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def password_complexity(cls, v: str) -> str:
|
||||
if not re.search(r"[A-Z]", v):
|
||||
raise ValueError("Password must contain at least one uppercase letter")
|
||||
if not re.search(r"[0-9]", v):
|
||||
raise ValueError("Password must contain at least one number")
|
||||
if not re.search(r"[^A-Za-z0-9]", v):
|
||||
raise ValueError("Password must contain at least one special character")
|
||||
return v
|
||||
|
||||
|
||||
class UserSMTPCredentialsPatchIn(BaseModel):
|
||||
smtp_user: Optional[str] = Field(None, max_length=255)
|
||||
mail_from: Optional[EmailStr] = None
|
||||
smtp_password: Optional[str] = Field(
|
||||
None,
|
||||
description="If omitted during update, existing password is preserved. Send empty string to clear it.",
|
||||
)
|
||||
|
||||
|
||||
class UserSMTPCredentialsOut(BaseModel):
|
||||
configured: bool
|
||||
source: Literal["user", "tenant", "fallback"]
|
||||
smtp_host: Optional[str] = None
|
||||
smtp_port: int = 587
|
||||
smtp_user: Optional[str] = None
|
||||
mail_from: Optional[str] = None
|
||||
has_password: bool = False
|
||||
|
||||
|
||||
class UpgradeIn(BaseModel):
|
||||
plan: str
|
||||
|
||||
|
||||
class RegisterOut(BaseModel):
|
||||
message: str
|
||||
user_id: Optional[int] = None
|
||||
email: Optional[EmailStr] = None
|
||||
tenant_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class ForgotPasswordIn(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordIn(BaseModel):
|
||||
token: str
|
||||
new_password: str = Field(..., min_length=12)
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def password_complexity(cls, v: str) -> str:
|
||||
if not re.search(r"[A-Z]", v):
|
||||
raise ValueError("Password must contain at least one uppercase letter")
|
||||
if not re.search(r"[0-9]", v):
|
||||
raise ValueError("Password must contain at least one number")
|
||||
if not re.search(r"[^A-Za-z0-9]", v):
|
||||
raise ValueError("Password must contain at least one special character")
|
||||
return v
|
||||
|
||||
class DeviceOut(BaseModel):
|
||||
id: int
|
||||
browser: Optional[str] = None
|
||||
os: Optional[str] = None
|
||||
device_type: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
last_login: Optional[datetime] = None
|
||||
is_current_device: bool = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,141 @@
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Literal, Optional, List
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class RoleAccessOut(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
access_code: str
|
||||
category: str
|
||||
parent_id: Optional[UUID] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class RoleCreate(BaseModel):
|
||||
role_name: str = Field(..., max_length=100)
|
||||
description: Optional[str] = None
|
||||
tenant_id: Optional[UUID] = None
|
||||
access_ids: Optional[List[UUID]] = []
|
||||
is_default: Optional[bool] = False
|
||||
|
||||
|
||||
class RoleUpdate(BaseModel):
|
||||
role_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
access_ids: Optional[List[UUID]] = None
|
||||
is_default: Optional[bool] = None
|
||||
|
||||
|
||||
class RoleOut(BaseModel):
|
||||
id: UUID
|
||||
role_name: str = Field(..., alias="name")
|
||||
description: Optional[str] = None
|
||||
tenant_id: Optional[UUID] = None
|
||||
is_default: bool = False
|
||||
is_system: bool = False
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
||||
|
||||
|
||||
class RoleWithAccessesOut(RoleOut):
|
||||
accesses: List[RoleAccessOut] = []
|
||||
assigned_user_count: Optional[int] = None
|
||||
"""
|
||||
How many principals this role reaches, or null when it was not counted.
|
||||
|
||||
Added to the *details* response and deliberately not to `RoleOut`: the list
|
||||
endpoints render up to a hundred roles and counting each one would be an
|
||||
N+1 nobody asked for. It exists so that deleting a role can say what it is
|
||||
about to do — `user_roles.role_id` is `ON DELETE CASCADE` and
|
||||
`users.role_id` is `ON DELETE SET NULL`, so a silent delete removes
|
||||
authority from people the operator was not thinking about.
|
||||
"""
|
||||
|
||||
|
||||
class RoleAssignmentOut(BaseModel):
|
||||
"""
|
||||
One role a user holds, and how they hold it.
|
||||
|
||||
`source` is the field that keeps the interface honest:
|
||||
|
||||
"primary" the legacy `users.role_id`. Changed through
|
||||
`PATCH /api/admin/users/{id}`, **not** revocable as a grant,
|
||||
and while it is set it cannot be narrowed by anything (see
|
||||
`ScopeService._resolve`)
|
||||
"grant" a `user_roles` row naming this user. Added and revoked freely
|
||||
"group" a `user_roles` row naming a group this user belongs to. It
|
||||
confers codes; it is removed by editing the group
|
||||
|
||||
Rendering the three identically would imply that removing any of them is the
|
||||
same action. It is not, and that is exactly where a permissions screen
|
||||
misleads the person using it.
|
||||
"""
|
||||
|
||||
grant_id: Optional[UUID] = None
|
||||
role_id: UUID
|
||||
role_name: str
|
||||
source: Literal["primary", "grant", "group"]
|
||||
org_unit_id: Optional[UUID] = None
|
||||
org_unit_name: Optional[str] = None
|
||||
group_id: Optional[UUID] = None
|
||||
group_name: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
assigned_by_id: Optional[int] = None
|
||||
created_at: Optional[datetime] = None
|
||||
is_expired: bool = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AddUserRoleIn(BaseModel):
|
||||
"""
|
||||
Add one role to one user.
|
||||
|
||||
`org_unit_id` omitted or null means **tenant-wide** — the authority every
|
||||
user already has through their primary role. That default is the point of
|
||||
this endpoint: the organisation tree could only ever grant *scoped to a
|
||||
unit*, so "Alice is an Editor and an Approver, everywhere" was not
|
||||
expressible anywhere in the product.
|
||||
"""
|
||||
|
||||
role_id: UUID
|
||||
org_unit_id: Optional[UUID] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AccessAttributionOut(BaseModel):
|
||||
"""Which role conferred one access code, and at what scope."""
|
||||
|
||||
role_id: Optional[UUID] = None
|
||||
role_name: Optional[str] = None
|
||||
source: str
|
||||
org_unit_id: Optional[UUID] = None
|
||||
org_unit_name: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class EffectiveAccessOut(BaseModel):
|
||||
"""
|
||||
One access code the user holds, with every reason they hold it.
|
||||
|
||||
Answers "why can this person do that". With one role per user the question
|
||||
answered itself. With several it does not, and support cannot read the
|
||||
database.
|
||||
"""
|
||||
|
||||
access_code: str
|
||||
granted_by: List[AccessAttributionOut]
|
||||
|
||||
|
||||
class RolePaginatedOut(BaseModel):
|
||||
items: List[RoleOut]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,557 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.activity_logs.constants import ActivityLogAction, ActivityLogStatus
|
||||
from app.modules.activity_logs.models.activity_log import ActivityLog
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.auth.services.permission_service import PermissionService
|
||||
from app.modules.documents.models.document_model import Project
|
||||
from app.modules.drive.models.drive_model import DriveACL, DriveFile, DriveFolder, DriveShare
|
||||
from app.modules.signing.models.signing_request import SigningRequest
|
||||
from app.modules.storage.services.storage_service import StorageService
|
||||
from app.modules.auth.services.privilege import holds_superadmin_access
|
||||
|
||||
CONVERSION_JOBS_ACCESS_CODE = "metrics:conversion_jobs"
|
||||
SIGNATURES_TREND_ACCESS_CODE = "metrics:signatures_trend"
|
||||
|
||||
|
||||
class AdminDashboardService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def _is_tenant_admin(self, current_user: User) -> bool:
|
||||
return bool(current_user.tenant_id) and not holds_superadmin_access(
|
||||
current_user
|
||||
)
|
||||
|
||||
def get_tenant_metrics(self, current_user: User, granularity: str = "weekly") -> dict:
|
||||
tenant_id = current_user.tenant_id
|
||||
if not tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Tenant-scoped admin metrics are only available for tenant users.",
|
||||
)
|
||||
|
||||
# 1. Try Redis cache lookup (tenant + granularity isolated)
|
||||
cache_key = None
|
||||
try:
|
||||
from app.db.redis import redis_cache
|
||||
from app.modules.drive.constants import CACHE_ADMIN_METRICS, CACHE_ADMIN_METRICS_TTL_SECONDS
|
||||
cache_key = CACHE_ADMIN_METRICS.format(tenant_id=str(tenant_id), granularity=granularity)
|
||||
cached = redis_cache.get(cache_key)
|
||||
if cached:
|
||||
import json
|
||||
return json.loads(cached)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
recent_since = datetime.now(timezone.utc) - timedelta(days=7)
|
||||
trend_config = self._get_trend_config(granularity)
|
||||
permission_service = PermissionService(self.db)
|
||||
is_tenant_admin = self._is_tenant_admin(current_user)
|
||||
can_view_conversions = is_tenant_admin or permission_service.has_access(
|
||||
current_user, CONVERSION_JOBS_ACCESS_CODE
|
||||
)
|
||||
can_view_signatures = is_tenant_admin or permission_service.has_access(
|
||||
current_user, SIGNATURES_TREND_ACCESS_CODE
|
||||
)
|
||||
|
||||
metrics = {
|
||||
"signatures": {
|
||||
"total": self._count_signatures(tenant_id),
|
||||
"recent": self._count_recent_signatures(tenant_id, recent_since),
|
||||
"trend": self._get_signature_trend(tenant_id, trend_config),
|
||||
}
|
||||
if can_view_signatures
|
||||
else None,
|
||||
"uploads": {
|
||||
"total": self._count_uploads(tenant_id),
|
||||
"recent": self._count_recent_uploads(tenant_id, recent_since),
|
||||
"trend": self._get_upload_trend(tenant_id, trend_config),
|
||||
},
|
||||
"users": {
|
||||
"total": self._count_total_users(tenant_id),
|
||||
"recent_added": self._count_recent_users_added(tenant_id, recent_since),
|
||||
"recent_deleted": self._count_recent_users_deleted(tenant_id, recent_since),
|
||||
},
|
||||
"shares": {
|
||||
"internal": self._count_internal_shares(tenant_id),
|
||||
"external": self._count_external_shares(tenant_id),
|
||||
},
|
||||
"storage": self._get_storage_metrics(current_user),
|
||||
"conversions": self._get_conversion_metrics(tenant_id)
|
||||
if can_view_conversions
|
||||
else None,
|
||||
"edits": {
|
||||
"total": self._count_recent_edits(tenant_id, recent_since),
|
||||
},
|
||||
}
|
||||
|
||||
# 2. Store in Redis
|
||||
if cache_key:
|
||||
try:
|
||||
import json
|
||||
redis_cache.set(cache_key, json.dumps(metrics, default=str), ex=CACHE_ADMIN_METRICS_TTL_SECONDS)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return metrics
|
||||
|
||||
def _count_signatures(self, tenant_id: UUID) -> int:
|
||||
return (
|
||||
self.db.query(SigningRequest)
|
||||
.filter(
|
||||
SigningRequest.tenant_id == tenant_id,
|
||||
SigningRequest.status == "completed",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def _count_recent_signatures(self, tenant_id: UUID, recent_since: datetime) -> int:
|
||||
return (
|
||||
self.db.query(SigningRequest)
|
||||
.filter(
|
||||
SigningRequest.tenant_id == tenant_id,
|
||||
SigningRequest.status == "completed",
|
||||
SigningRequest.completed_at >= recent_since,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def _count_uploads(self, tenant_id: UUID) -> int:
|
||||
return self.db.query(DriveFile).filter(DriveFile.tenant_id == tenant_id).count()
|
||||
|
||||
def _count_recent_uploads(self, tenant_id: UUID, recent_since: datetime) -> int:
|
||||
return (
|
||||
self.db.query(DriveFile)
|
||||
.filter(
|
||||
DriveFile.tenant_id == tenant_id,
|
||||
DriveFile.created_at >= recent_since,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def _count_total_users(self, tenant_id: UUID) -> int:
|
||||
return (
|
||||
self.db.query(User)
|
||||
.filter(
|
||||
User.tenant_id == tenant_id,
|
||||
User.is_deleted.is_(False),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def _count_recent_users_added(self, tenant_id: UUID, recent_since: datetime) -> int:
|
||||
return (
|
||||
self.db.query(User)
|
||||
.filter(
|
||||
User.tenant_id == tenant_id,
|
||||
User.created_at >= recent_since,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def _count_recent_users_deleted(self, tenant_id: UUID, recent_since: datetime) -> int:
|
||||
return (
|
||||
self.db.query(User)
|
||||
.filter(
|
||||
User.tenant_id == tenant_id,
|
||||
User.is_deleted.is_(True),
|
||||
User.deleted_at.isnot(None),
|
||||
User.deleted_at >= recent_since,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def _count_recent_edits(self, tenant_id: UUID, recent_since: datetime) -> int:
|
||||
return (
|
||||
self.db.query(ActivityLog)
|
||||
.filter(
|
||||
ActivityLog.tenant_id == tenant_id,
|
||||
ActivityLog.action == ActivityLogAction.DOCUMENT_EDITED.value,
|
||||
ActivityLog.status == ActivityLogStatus.SUCCESS.value,
|
||||
ActivityLog.created_at >= recent_since,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def _get_signature_trend(self, tenant_id: UUID, trend_config: dict) -> list[dict]:
|
||||
if trend_config["mode"] == "rolling_weeks":
|
||||
return self._build_rolling_week_trend(
|
||||
self.db.query(
|
||||
SigningRequest.completed_at.label("event_at"),
|
||||
)
|
||||
.filter(
|
||||
SigningRequest.tenant_id == tenant_id,
|
||||
SigningRequest.status == "completed",
|
||||
SigningRequest.completed_at.isnot(None),
|
||||
),
|
||||
SigningRequest.completed_at,
|
||||
trend_config["periods"],
|
||||
)
|
||||
|
||||
if trend_config["mode"] == "calendar_year_months":
|
||||
period_bucket = func.date_trunc("month", SigningRequest.completed_at)
|
||||
return self._build_current_year_month_trend(
|
||||
self.db.query(
|
||||
period_bucket.label("period"),
|
||||
func.count(SigningRequest.id).label("value"),
|
||||
)
|
||||
.filter(
|
||||
SigningRequest.tenant_id == tenant_id,
|
||||
SigningRequest.status == "completed",
|
||||
SigningRequest.completed_at.isnot(None),
|
||||
),
|
||||
SigningRequest.completed_at,
|
||||
period_bucket,
|
||||
)
|
||||
|
||||
period_bucket = func.date_trunc(trend_config["bucket"], SigningRequest.completed_at)
|
||||
return self._build_period_trend(
|
||||
self.db.query(
|
||||
period_bucket.label("period"),
|
||||
func.count(SigningRequest.id).label("value"),
|
||||
)
|
||||
.filter(
|
||||
SigningRequest.tenant_id == tenant_id,
|
||||
SigningRequest.status == "completed",
|
||||
SigningRequest.completed_at.isnot(None),
|
||||
),
|
||||
SigningRequest.completed_at,
|
||||
period_bucket,
|
||||
trend_config,
|
||||
)
|
||||
|
||||
def _get_upload_trend(self, tenant_id: UUID, trend_config: dict) -> list[dict]:
|
||||
if trend_config["mode"] == "rolling_weeks":
|
||||
return self._build_rolling_week_trend(
|
||||
self.db.query(
|
||||
DriveFile.created_at.label("event_at"),
|
||||
)
|
||||
.filter(DriveFile.tenant_id == tenant_id),
|
||||
DriveFile.created_at,
|
||||
trend_config["periods"],
|
||||
)
|
||||
|
||||
if trend_config["mode"] == "calendar_year_months":
|
||||
period_bucket = func.date_trunc("month", DriveFile.created_at)
|
||||
return self._build_current_year_month_trend(
|
||||
self.db.query(
|
||||
period_bucket.label("period"),
|
||||
func.count(DriveFile.id).label("value"),
|
||||
)
|
||||
.filter(DriveFile.tenant_id == tenant_id),
|
||||
DriveFile.created_at,
|
||||
period_bucket,
|
||||
)
|
||||
|
||||
period_bucket = func.date_trunc(trend_config["bucket"], DriveFile.created_at)
|
||||
return self._build_period_trend(
|
||||
self.db.query(
|
||||
period_bucket.label("period"),
|
||||
func.count(DriveFile.id).label("value"),
|
||||
)
|
||||
.filter(DriveFile.tenant_id == tenant_id),
|
||||
DriveFile.created_at,
|
||||
period_bucket,
|
||||
trend_config,
|
||||
)
|
||||
|
||||
def get_trend_data(self, current_user: User, metric: str, granularity: str = "weekly") -> dict:
|
||||
tenant_id = current_user.tenant_id
|
||||
if not tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Tenant-scoped admin metrics are only available for tenant users.",
|
||||
)
|
||||
|
||||
trend_config = self._get_trend_config(granularity)
|
||||
if metric == "signatures":
|
||||
if not self._is_tenant_admin(current_user):
|
||||
PermissionService(self.db).require_access(
|
||||
current_user, SIGNATURES_TREND_ACCESS_CODE
|
||||
)
|
||||
return {"trend": self._get_signature_trend(tenant_id, trend_config)}
|
||||
if metric == "uploads":
|
||||
return {"trend": self._get_upload_trend(tenant_id, trend_config)}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Unsupported metric trend requested.")
|
||||
|
||||
def _get_trend_config(self, granularity: str) -> dict:
|
||||
normalized = granularity.lower()
|
||||
if normalized == "monthly":
|
||||
return {"mode": "rolling_weeks", "periods": 5}
|
||||
if normalized == "yearly":
|
||||
return {"mode": "calendar_year_months"}
|
||||
return {"mode": "weekly_days", "bucket": "day", "periods": 7}
|
||||
|
||||
def _build_period_trend(self, query, date_column, period_bucket, trend_config: dict) -> list[dict]:
|
||||
current_period_start = self._get_period_start(datetime.now(timezone.utc), trend_config["bucket"])
|
||||
period_starts = []
|
||||
for offset in reversed(range(trend_config["periods"])):
|
||||
period_starts.append(self._shift_period_start(current_period_start, trend_config["bucket"], -offset))
|
||||
|
||||
start_datetime = period_starts[0]
|
||||
end_datetime = self._shift_period_start(current_period_start, trend_config["bucket"], 1)
|
||||
rows = (
|
||||
query.filter(
|
||||
date_column >= start_datetime,
|
||||
date_column < end_datetime,
|
||||
)
|
||||
.group_by(period_bucket)
|
||||
.all()
|
||||
)
|
||||
|
||||
counts_by_period = {
|
||||
row.period.date().isoformat(): row.value
|
||||
for row in rows
|
||||
if row.period is not None
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
"date": period_start.date().isoformat(),
|
||||
"value": counts_by_period.get(period_start.date().isoformat(), 0),
|
||||
}
|
||||
for period_start in period_starts
|
||||
]
|
||||
|
||||
def _build_rolling_week_trend(self, query, date_column, periods: int) -> list[dict]:
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = datetime(now.year, now.month, now.day, tzinfo=timezone.utc)
|
||||
earliest_period_start = today_start - timedelta(days=(periods * 7) - 1)
|
||||
end_datetime = today_start + timedelta(days=1)
|
||||
|
||||
rows = (
|
||||
query.filter(
|
||||
date_column >= earliest_period_start,
|
||||
date_column < end_datetime,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
counts_by_week: dict[int, int] = {}
|
||||
for row in rows:
|
||||
if row.event_at is None:
|
||||
continue
|
||||
week_index = ((row.event_at.date() - earliest_period_start.date()).days // 7) + 1
|
||||
counts_by_week[week_index] = counts_by_week.get(week_index, 0) + 1
|
||||
|
||||
return [
|
||||
{
|
||||
"date": (earliest_period_start + timedelta(days=(week_index - 1) * 7)).date().isoformat(),
|
||||
"value": counts_by_week.get(week_index, 0),
|
||||
}
|
||||
for week_index in range(1, periods + 1)
|
||||
]
|
||||
|
||||
def _build_current_year_month_trend(self, query, date_column, period_bucket) -> list[dict]:
|
||||
now = datetime.now(timezone.utc)
|
||||
year_start = datetime(now.year, 1, 1, tzinfo=timezone.utc)
|
||||
next_year_start = datetime(now.year + 1, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
rows = (
|
||||
query.filter(
|
||||
date_column >= year_start,
|
||||
date_column < next_year_start,
|
||||
)
|
||||
.group_by(period_bucket)
|
||||
.all()
|
||||
)
|
||||
|
||||
counts_by_month = {
|
||||
row.period.date().isoformat(): row.value
|
||||
for row in rows
|
||||
if row.period is not None
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
"date": datetime(now.year, month, 1, tzinfo=timezone.utc).date().isoformat(),
|
||||
"value": counts_by_month.get(
|
||||
datetime(now.year, month, 1, tzinfo=timezone.utc).date().isoformat(),
|
||||
0,
|
||||
),
|
||||
}
|
||||
for month in range(1, 13)
|
||||
]
|
||||
|
||||
def _get_period_start(self, value: datetime, bucket: str) -> datetime:
|
||||
if bucket == "year":
|
||||
return datetime(value.year, 1, 1, tzinfo=timezone.utc)
|
||||
if bucket == "month":
|
||||
return datetime(value.year, value.month, 1, tzinfo=timezone.utc)
|
||||
return datetime(value.year, value.month, value.day, tzinfo=timezone.utc)
|
||||
|
||||
def _shift_period_start(self, value: datetime, bucket: str, step: int) -> datetime:
|
||||
if bucket == "year":
|
||||
return datetime(value.year + step, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
if bucket == "month":
|
||||
month_index = (value.month - 1) + step
|
||||
year = value.year + (month_index // 12)
|
||||
month = (month_index % 12) + 1
|
||||
return datetime(year, month, 1, tzinfo=timezone.utc)
|
||||
|
||||
return value + timedelta(days=step)
|
||||
|
||||
def _get_storage_metrics(self, current_user: User) -> dict:
|
||||
usage = StorageService(self.db).get_storage_usage(current_user)
|
||||
return {
|
||||
"used_bytes": int(usage["total_bytes"]),
|
||||
"quota_bytes": int(usage["quota_bytes"]),
|
||||
"usage_percentage": float(usage["usage_percentage"]),
|
||||
"used_formatted": usage["total_formatted"],
|
||||
"quota_formatted": usage["quota_formatted"],
|
||||
}
|
||||
|
||||
def _get_conversion_metrics(self, tenant_id: UUID) -> dict:
|
||||
total = self.db.query(Project).filter(Project.tenant_id == tenant_id).count()
|
||||
completed = (
|
||||
self.db.query(Project)
|
||||
.filter(
|
||||
Project.tenant_id == tenant_id,
|
||||
Project.status == "completed",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
processing = (
|
||||
self.db.query(Project)
|
||||
.filter(
|
||||
Project.tenant_id == tenant_id,
|
||||
Project.status == "processing",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
failed = (
|
||||
self.db.query(Project)
|
||||
.filter(
|
||||
Project.tenant_id == tenant_id,
|
||||
Project.status == "failed",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
completion_rate = round((completed / total) * 100, 1) if total > 0 else 0.0
|
||||
return {
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"processing": processing,
|
||||
"failed": failed,
|
||||
"completion_rate": completion_rate,
|
||||
}
|
||||
|
||||
def _count_internal_shares(self, tenant_id: UUID) -> int:
|
||||
return (
|
||||
self._count_internal_file_shares(tenant_id)
|
||||
+ self._count_internal_folder_shares(tenant_id)
|
||||
+ self._count_internal_document_shares(tenant_id)
|
||||
)
|
||||
|
||||
def _count_external_shares(self, tenant_id: UUID) -> int:
|
||||
return (
|
||||
self._count_external_file_shares(tenant_id)
|
||||
+ self._count_external_folder_shares(tenant_id)
|
||||
+ self._count_external_document_shares(tenant_id)
|
||||
)
|
||||
|
||||
def _count_internal_file_shares(self, tenant_id: UUID) -> int:
|
||||
return (
|
||||
self.db.query(DriveACL)
|
||||
.join(
|
||||
DriveFile,
|
||||
(DriveACL.resource_type == "file") & (DriveACL.resource_id == DriveFile.id),
|
||||
)
|
||||
.join(User, DriveACL.subject_id == User.id)
|
||||
.filter(
|
||||
DriveFile.tenant_id == tenant_id,
|
||||
User.tenant_id == tenant_id,
|
||||
DriveACL.role != "owner",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def _count_internal_folder_shares(self, tenant_id: UUID) -> int:
|
||||
return (
|
||||
self.db.query(DriveACL)
|
||||
.join(
|
||||
DriveFolder,
|
||||
(DriveACL.resource_type == "folder")
|
||||
& (DriveACL.resource_id == DriveFolder.id),
|
||||
)
|
||||
.join(User, DriveACL.subject_id == User.id)
|
||||
.filter(
|
||||
DriveFolder.tenant_id == tenant_id,
|
||||
User.tenant_id == tenant_id,
|
||||
DriveACL.role != "owner",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def _count_internal_document_shares(self, tenant_id: UUID) -> int:
|
||||
return (
|
||||
self.db.query(DriveACL)
|
||||
.join(
|
||||
Project,
|
||||
(DriveACL.resource_type == "document")
|
||||
& (DriveACL.resource_id == Project.id),
|
||||
)
|
||||
.join(User, DriveACL.subject_id == User.id)
|
||||
.filter(
|
||||
Project.tenant_id == tenant_id,
|
||||
User.tenant_id == tenant_id,
|
||||
DriveACL.role != "owner",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def _count_external_file_shares(self, tenant_id: UUID) -> int:
|
||||
return (
|
||||
self.db.query(DriveShare)
|
||||
.join(
|
||||
DriveFile,
|
||||
(DriveShare.resource_type == "file")
|
||||
& (DriveShare.resource_id == DriveFile.id),
|
||||
)
|
||||
.filter(
|
||||
DriveFile.tenant_id == tenant_id,
|
||||
DriveShare.invited_email.isnot(None),
|
||||
DriveShare.is_active == True,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def _count_external_folder_shares(self, tenant_id: UUID) -> int:
|
||||
return (
|
||||
self.db.query(DriveShare)
|
||||
.join(
|
||||
DriveFolder,
|
||||
(DriveShare.resource_type == "folder")
|
||||
& (DriveShare.resource_id == DriveFolder.id),
|
||||
)
|
||||
.filter(
|
||||
DriveFolder.tenant_id == tenant_id,
|
||||
DriveShare.invited_email.isnot(None),
|
||||
DriveShare.is_active == True,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def _count_external_document_shares(self, tenant_id: UUID) -> int:
|
||||
return (
|
||||
self.db.query(DriveShare)
|
||||
.join(
|
||||
Project,
|
||||
(DriveShare.resource_type == "document")
|
||||
& (DriveShare.resource_id == Project.id),
|
||||
)
|
||||
.filter(
|
||||
Project.tenant_id == tenant_id,
|
||||
DriveShare.invited_email.isnot(None),
|
||||
DriveShare.is_active == True,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
@@ -0,0 +1,528 @@
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from google.oauth2 import id_token
|
||||
from google.auth.transport import requests
|
||||
|
||||
from app.core.settings import settings
|
||||
from app.db.redis import redis_cache
|
||||
from app.core.mail import send_email
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.auth.repositories.user_repository import UserRepository
|
||||
from app.core.security import (
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
)
|
||||
from app.modules.auth.schemas.auth_schema import (
|
||||
RegisterIn,
|
||||
GoogleLoginIn,
|
||||
UpdateProfileIn,
|
||||
ChangePasswordIn,
|
||||
ForgotPasswordIn,
|
||||
ResetPasswordIn,
|
||||
)
|
||||
from app.modules.configuration.services.system_configuration_service import (
|
||||
SystemConfigurationService,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.user_repo = UserRepository(db)
|
||||
|
||||
def register(self, payload: RegisterIn, tenant: Any = None) -> dict:
|
||||
if self.user_repo.get_by_email(payload.email):
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
SystemConfigurationService(self.db).ensure_can_create_user(
|
||||
tenant_id=tenant.id if tenant else None
|
||||
)
|
||||
|
||||
user = User(
|
||||
name=payload.name.strip(),
|
||||
designation=(payload.designation or "").strip() or None,
|
||||
email=payload.email.lower().strip(),
|
||||
password_hash=get_password_hash(payload.password),
|
||||
subscription="free",
|
||||
tenant_id=tenant.id if tenant else None,
|
||||
)
|
||||
new_user = self.user_repo.create(user)
|
||||
|
||||
from app.modules.drive.services.drive_service import DriveService
|
||||
|
||||
drive_service = DriveService(self.db)
|
||||
drive_service.get_or_create_root_folder(new_user)
|
||||
|
||||
from app.modules.storage.models.storage_model import UserStorageUsage
|
||||
|
||||
storage_usage = UserStorageUsage(user_id=new_user.id)
|
||||
self.db.add(storage_usage)
|
||||
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
send_email(
|
||||
subject="Welcome to DocQube!",
|
||||
recipient=new_user.email,
|
||||
template_name="welcome.html",
|
||||
template_context={
|
||||
"name": new_user.name,
|
||||
"login_url": f"{settings.FRONTEND_URL}/login",
|
||||
"year": datetime.utcnow().year,
|
||||
},
|
||||
db=self.db,
|
||||
tenant_id=new_user.tenant_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send welcome email for {new_user.email}: {e}")
|
||||
|
||||
return {
|
||||
"message": "Account created successfully",
|
||||
"user_id": new_user.id,
|
||||
"email": new_user.email,
|
||||
"tenant_id": new_user.tenant_id,
|
||||
}
|
||||
|
||||
def logout(self, session_id: int):
|
||||
from app.modules.auth.repositories.session_repository import SessionRepository
|
||||
session_repo = SessionRepository(self.db)
|
||||
session = session_repo.get_by_id(session_id)
|
||||
if session and session.status == "ACTIVE":
|
||||
session_repo.revoke(session, by="user")
|
||||
|
||||
def _handle_device_login(self, user_id: int, request: Any) -> int:
|
||||
from app.modules.auth.utils.device_utils import get_client_ip, parse_user_agent, generate_device_fingerprint, get_location_from_ip
|
||||
from app.modules.auth.repositories.device_repository import DeviceRepository
|
||||
from app.modules.auth.repositories.session_repository import SessionRepository
|
||||
from app.modules.auth.models.device_model import Device
|
||||
from app.modules.auth.models.session_model import Session as AuthSession
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
device_repo = DeviceRepository(self.db)
|
||||
session_repo = SessionRepository(self.db)
|
||||
|
||||
fingerprint = generate_device_fingerprint(request)
|
||||
ip_addr = get_client_ip(request)
|
||||
user_agent_str = request.headers.get("User-Agent", "")
|
||||
browser, os_name, device_type = parse_user_agent(user_agent_str)
|
||||
|
||||
device = device_repo.get_by_fingerprint(user_id, fingerprint)
|
||||
if device:
|
||||
if device.is_blocked:
|
||||
raise HTTPException(status_code=403, detail="Device is blocked")
|
||||
device_repo.update_login_activity(device, ip_addr)
|
||||
session_repo.revoke_all_for_device(device.id, by="reauth")
|
||||
from sqlalchemy import text
|
||||
self.db.execute(text("SELECT pg_advisory_xact_lock(:lock_id)"), {"lock_id": user_id})
|
||||
|
||||
limit = settings.MAX_ACTIVE_DEVICES
|
||||
active_count = session_repo.count_active_sessions(user_id)
|
||||
if active_count >= limit:
|
||||
active_devices = device_repo.get_user_devices(user_id)
|
||||
devices_data = [{
|
||||
"id": d.id,
|
||||
"os": d.os,
|
||||
"browser": d.browser,
|
||||
"device_type": d.device_type,
|
||||
"city": d.city,
|
||||
"country": d.country,
|
||||
"state": d.state,
|
||||
"last_login": d.last_login.isoformat() if d.last_login else None
|
||||
} for d in active_devices]
|
||||
|
||||
raise HTTPException(status_code=403, detail={
|
||||
"error": "DEVICE_LIMIT_REACHED",
|
||||
"devices": devices_data
|
||||
})
|
||||
|
||||
if not device:
|
||||
country, state, city, lat, lon = get_location_from_ip(ip_addr)
|
||||
|
||||
new_device = Device(
|
||||
user_id=user_id,
|
||||
fingerprint=fingerprint,
|
||||
browser=browser,
|
||||
os=os_name,
|
||||
device_type=device_type,
|
||||
user_agent=user_agent_str,
|
||||
ip=ip_addr,
|
||||
country=country,
|
||||
state=state,
|
||||
city=city,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
last_ip=ip_addr
|
||||
)
|
||||
device = device_repo.create(new_device)
|
||||
|
||||
new_session = AuthSession(
|
||||
user_id=user_id,
|
||||
device_id=device.id,
|
||||
refresh_token_hash="pending",
|
||||
status="ACTIVE",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=7)
|
||||
)
|
||||
new_session = session_repo.create(new_session)
|
||||
return new_session.id
|
||||
|
||||
def login(self, username: str, password: str, request: Optional[Any] = None) -> Dict[str, str]:
|
||||
user = self.user_repo.get_by_email(username)
|
||||
|
||||
generic_error = HTTPException(
|
||||
status_code=401, detail="Invalid email or password"
|
||||
)
|
||||
|
||||
if (
|
||||
not user
|
||||
or user.is_deleted
|
||||
or (hasattr(user, "is_active") and not user.is_active)
|
||||
):
|
||||
raise HTTPException(status_code=401, detail="User not found or account is inactive")
|
||||
|
||||
if user.password_hash == "SAAS_MANAGED_ACCOUNT_DO_NOT_USE_PASSWORD":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="This account is managed by SSO. Please log in through the main application portal."
|
||||
)
|
||||
|
||||
if not verify_password(password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Incorrect email or password")
|
||||
|
||||
if user.tenant_id:
|
||||
from app.modules.tenant.repositories.tenant_repository import TenantRepository
|
||||
tenant_repo = TenantRepository(self.db)
|
||||
tenant = tenant_repo.get_by_id(user.tenant_id)
|
||||
if not tenant or not tenant.is_active or tenant.is_deleted:
|
||||
raise HTTPException(status_code=403, detail="Your organization is currently inactive. Please contact support.")
|
||||
|
||||
try:
|
||||
session_id = None
|
||||
if request:
|
||||
session_id = self._handle_device_login(user.id, request)
|
||||
except HTTPException as e:
|
||||
if isinstance(e.detail, dict) and e.detail.get("error") == "DEVICE_LIMIT_REACHED":
|
||||
temp_payload = {"sub": str(user.id), "type": "device_management"}
|
||||
from datetime import timedelta
|
||||
temp_token = create_access_token(temp_payload, expires_delta=timedelta(minutes=10))
|
||||
e.detail["temp_token"] = temp_token
|
||||
raise e
|
||||
|
||||
payload_access = {
|
||||
"sub": str(user.id),
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
"is_superadmin": bool(getattr(user, "is_superadmin", False)),
|
||||
}
|
||||
if session_id:
|
||||
payload_access["session_id"] = session_id
|
||||
|
||||
payload_refresh = {"sub": str(user.id)}
|
||||
if session_id:
|
||||
payload_refresh["session_id"] = session_id
|
||||
|
||||
token = create_access_token(payload_access)
|
||||
refresh = create_refresh_token(payload_refresh)
|
||||
|
||||
if session_id:
|
||||
from app.core.security import get_password_hash
|
||||
from app.modules.auth.repositories.session_repository import SessionRepository
|
||||
session_repo = SessionRepository(self.db)
|
||||
session = session_repo.get_by_id(session_id)
|
||||
if session:
|
||||
session.refresh_token_hash = get_password_hash(refresh)
|
||||
|
||||
if user.tenant_id:
|
||||
try:
|
||||
from app.modules.activity_logs.service import log_event
|
||||
from app.modules.activity_logs.constants import ActivityLogModule, ActivityLogAction, ActivityLogStatus, ActivityLogTargetType
|
||||
log_event(
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
user_email=user.email,
|
||||
module=ActivityLogModule.TENANT,
|
||||
action=ActivityLogAction.DEVICE_LOGIN,
|
||||
target_id=str(session_id) if session_id else str(user.id),
|
||||
target_type=ActivityLogTargetType.SESSION,
|
||||
metadata={},
|
||||
status=ActivityLogStatus.SUCCESS
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Failed to log login event: {e}")
|
||||
|
||||
return {"access_token": token, "refresh_token": refresh, "token_type": "bearer"}
|
||||
|
||||
|
||||
def google_login(self, payload: GoogleLoginIn, request: Optional[Any] = None) -> Dict[str, str]:
|
||||
client_id = settings.GOOGLE_CLIENT_ID
|
||||
token_to_verify = payload.token or payload.credential
|
||||
|
||||
if not token_to_verify:
|
||||
raise HTTPException(status_code=400, detail="Google token missing")
|
||||
|
||||
try:
|
||||
idinfo = id_token.verify_oauth2_token(
|
||||
token_to_verify, requests.Request(), client_id, clock_skew_in_seconds=60
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Google Auth Error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=401, detail="Google authentication failed")
|
||||
|
||||
email = idinfo["email"]
|
||||
raw_name = idinfo.get("name", email.split("@")[0])
|
||||
name = re.sub(r"[^\w\s-]", "", raw_name).strip()
|
||||
|
||||
user = self.user_repo.get_by_email(email)
|
||||
|
||||
if user and user.tenant_id:
|
||||
from app.modules.tenant.repositories.tenant_repository import TenantRepository
|
||||
tenant_repo = TenantRepository(self.db)
|
||||
tenant = tenant_repo.get_by_id(user.tenant_id)
|
||||
if not tenant or not tenant.is_active or tenant.is_deleted:
|
||||
raise HTTPException(status_code=403, detail="Your organization is currently inactive. Please contact support.")
|
||||
|
||||
if not user:
|
||||
tenant_uuid = None
|
||||
if payload.tenant_id:
|
||||
try:
|
||||
tenant_uuid = uuid.UUID(str(payload.tenant_id))
|
||||
except Exception:
|
||||
tenant_uuid = None
|
||||
|
||||
SystemConfigurationService(self.db).ensure_can_create_user(
|
||||
tenant_id=tenant_uuid
|
||||
)
|
||||
user = User(
|
||||
name=name,
|
||||
email=email,
|
||||
password_hash=get_password_hash(os.urandom(24).hex()),
|
||||
tenant_id=payload.tenant_id,
|
||||
)
|
||||
user = self.user_repo.create(user)
|
||||
|
||||
from app.modules.drive.services.drive_service import DriveService
|
||||
|
||||
drive_service = DriveService(self.db)
|
||||
drive_service.get_or_create_root_folder(user)
|
||||
|
||||
from app.modules.storage.models.storage_model import UserStorageUsage
|
||||
|
||||
storage_usage = UserStorageUsage(user_id=user.id)
|
||||
self.db.add(storage_usage)
|
||||
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
send_email(
|
||||
subject="Welcome to DocQube!",
|
||||
recipient=user.email,
|
||||
template_name="welcome.html",
|
||||
template_context={
|
||||
"name": user.name,
|
||||
"login_url": f"{settings.FRONTEND_URL}/login",
|
||||
"year": datetime.utcnow().year,
|
||||
},
|
||||
db=self.db,
|
||||
tenant_id=user.tenant_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to send welcome email for google user {user.email}: {e}"
|
||||
)
|
||||
|
||||
try:
|
||||
session_id = None
|
||||
if request:
|
||||
session_id = self._handle_device_login(user.id, request)
|
||||
except HTTPException as e:
|
||||
if isinstance(e.detail, dict) and e.detail.get("error") == "DEVICE_LIMIT_REACHED":
|
||||
temp_payload = {"sub": str(user.id), "type": "device_management"}
|
||||
from datetime import timedelta
|
||||
temp_token = create_access_token(temp_payload, expires_delta=timedelta(minutes=10))
|
||||
e.detail["temp_token"] = temp_token
|
||||
raise e
|
||||
|
||||
payload_access = {
|
||||
"sub": str(user.id),
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
"is_superadmin": bool(getattr(user, "is_superadmin", False)),
|
||||
}
|
||||
if session_id:
|
||||
payload_access["session_id"] = session_id
|
||||
|
||||
payload_refresh = {"sub": str(user.id)}
|
||||
if session_id:
|
||||
payload_refresh["session_id"] = session_id
|
||||
|
||||
token = create_access_token(payload_access)
|
||||
refresh = create_refresh_token(payload_refresh)
|
||||
|
||||
if session_id:
|
||||
from app.core.security import get_password_hash
|
||||
from app.modules.auth.repositories.session_repository import SessionRepository
|
||||
session_repo = SessionRepository(self.db)
|
||||
session = session_repo.get_by_id(session_id)
|
||||
if session:
|
||||
session.refresh_token_hash = get_password_hash(refresh)
|
||||
|
||||
if user.tenant_id:
|
||||
try:
|
||||
from app.modules.activity_logs.service import log_event
|
||||
from app.modules.activity_logs.constants import ActivityLogModule, ActivityLogAction, ActivityLogStatus, ActivityLogTargetType
|
||||
log_event(
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
user_email=user.email,
|
||||
module=ActivityLogModule.TENANT,
|
||||
action=ActivityLogAction.DEVICE_LOGIN,
|
||||
target_id=str(session_id) if session_id else str(user.id),
|
||||
target_type=ActivityLogTargetType.SESSION,
|
||||
metadata={},
|
||||
status=ActivityLogStatus.SUCCESS
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Failed to log google login event: {e}")
|
||||
|
||||
return {"access_token": token, "refresh_token": refresh, "token_type": "bearer"}
|
||||
|
||||
def forgot_password(self, payload: ForgotPasswordIn) -> Dict[str, str]:
|
||||
email = payload.email.lower().strip()
|
||||
user = self.user_repo.get_by_email(email)
|
||||
|
||||
res = {
|
||||
"message": "If this email is registered, you will receive a password reset link."
|
||||
}
|
||||
|
||||
if not user:
|
||||
logger.info(f"Forgot password request for non-existent email: {email}")
|
||||
return res
|
||||
if user.is_deleted or (hasattr(user, "is_active") and not user.is_active):
|
||||
logger.info(f"Forgot password requested for inactive/deleted user: {email}")
|
||||
return res
|
||||
|
||||
logger.info(f"Found user {user.id} for forgot password: {email}")
|
||||
|
||||
reset_token = uuid.uuid4().hex
|
||||
redis_cache.set(f"reset_token:{reset_token}", user.id, ttl=3600)
|
||||
|
||||
reset_link = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}"
|
||||
|
||||
subject = "DocQube - Reset Your Password"
|
||||
body = f"Hello {user.name},\n\nYou requested a password reset. Use the link below to set a new password:\n\n{reset_link}\n\nThis link expires in 1 hour."
|
||||
html_body = f"""
|
||||
<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee;">
|
||||
<h2>Reset Your Password</h2>
|
||||
<p>Hello {user.name},</p>
|
||||
<p>You requested a password reset for your DocQube account. Click the button below to continue:</p>
|
||||
<div style="margin: 30px 0;">
|
||||
<a href="{reset_link}" style="background: #e11d48; color: white; padding: 12px 25px; text-decoration: none; border-radius: 5px;">Reset Password</a>
|
||||
</div>
|
||||
<p>Or copy and paste this link: <br> {reset_link}</p>
|
||||
<p style="color: #666; font-size: 13px;">This link will expire in 1 hour. If you did not request this, please ignore this email.</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
logger.info(f"Attempting to send reset email to {email}")
|
||||
send_email(
|
||||
subject,
|
||||
email,
|
||||
body,
|
||||
html_body,
|
||||
db=self.db,
|
||||
tenant_id=user.tenant_id,
|
||||
)
|
||||
logger.info(f"Reset email process completed for {email}")
|
||||
|
||||
return res
|
||||
|
||||
def reset_password(self, payload: ResetPasswordIn) -> Dict[str, str]:
|
||||
user_id = redis_cache.get(f"reset_token:{payload.token}")
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid or expired reset token"
|
||||
)
|
||||
|
||||
user = self.user_repo.get_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user.password_hash = get_password_hash(payload.new_password)
|
||||
|
||||
redis_cache.delete(f"reset_token:{payload.token}")
|
||||
|
||||
return {"message": "Password has been successfully reset."}
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.user_repo = UserRepository(db)
|
||||
|
||||
def update_profile(
|
||||
self,
|
||||
user: User,
|
||||
name: Optional[str] = None,
|
||||
designation: Optional[str] = None,
|
||||
preferred_language: Optional[str] = None,
|
||||
) -> User:
|
||||
if name is None and designation is None and preferred_language is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="At least one profile field must be provided",
|
||||
)
|
||||
if name is not None:
|
||||
user.name = name.strip()
|
||||
if designation is not None:
|
||||
user.designation = designation.strip() or None
|
||||
if preferred_language is not None:
|
||||
user.preferred_language = preferred_language.strip().lower() or "en"
|
||||
self.db.flush()
|
||||
self.db.refresh(user)
|
||||
return user
|
||||
|
||||
def change_password(self, user: User, current_password: str, new_password: str):
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Incorrect current password")
|
||||
|
||||
user.password_hash = get_password_hash(new_password)
|
||||
return {"message": "Password updated successfully"}
|
||||
|
||||
def get_user_stats(self, user: User) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch real-time statistics for the user profile dashboard.
|
||||
"""
|
||||
from app.modules.documents.models.document_model import Project
|
||||
from app.modules.drive.models.drive_model import DriveFile
|
||||
|
||||
total_projects = (
|
||||
self.db.query(Project).filter(Project.user_id == user.id).count()
|
||||
)
|
||||
total_files = (
|
||||
self.db.query(DriveFile).filter(DriveFile.owner_id == user.id).count()
|
||||
)
|
||||
|
||||
return {
|
||||
"totalProjects": total_projects,
|
||||
"totalFiles": total_files,
|
||||
"subscription": user.subscription,
|
||||
"joined": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
|
||||
def delete_account(self, user: User):
|
||||
user.is_deleted = True
|
||||
user.is_active = False
|
||||
from datetime import datetime
|
||||
|
||||
user.deleted_at = datetime.utcnow()
|
||||
return {"message": "Account deleted successfully"}
|
||||
@@ -0,0 +1,386 @@
|
||||
from sqlalchemy import func, or_, select
|
||||
from typing import Set, Optional, List
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.request_cache import request_cached
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.auth.models.access_model import Access
|
||||
|
||||
|
||||
class PermissionService:
|
||||
"""
|
||||
Centralized permission engine for checking access codes.
|
||||
|
||||
Handles:
|
||||
- Retrieving user's access codes from their role
|
||||
- Checking if user has specific access code(s)
|
||||
- Enforcing access restrictions (raises HTTP 403 on denial)
|
||||
|
||||
**This is the only correct answer to "what may this user do".** There used to
|
||||
be a second one — the `User.access_codes` property — which read the single
|
||||
legacy `users.role_id` and nothing else. Because `/api/me/profile` reported
|
||||
that answer while `require_access` used this one, a user holding a role
|
||||
through `user_roles` had authority the product would not show them: no menu
|
||||
entry, no route, no button. `User.access_codes` now defers to whatever
|
||||
`get_current_user` resolved through here, and
|
||||
`tests/probes/test_multi_role.py` asserts the two agree.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def user_access_codes(self, user: Optional[User]) -> Set[str]:
|
||||
"""
|
||||
Get all access codes for a user.
|
||||
Includes both local role-based permissions and SaaS-assigned permissions.
|
||||
|
||||
Memoised for the life of the request: `require_access` asks once per
|
||||
dependency and `get_current_user` asks once more, and the answer cannot
|
||||
change beneath a half-finished operation.
|
||||
"""
|
||||
if not user:
|
||||
return set()
|
||||
|
||||
return request_cached(
|
||||
f"access_codes:{user.id}", lambda: self._resolve_codes(user)
|
||||
)
|
||||
|
||||
def _resolve_codes(self, user: User) -> Set[str]:
|
||||
all_access_codes = set()
|
||||
|
||||
saas_permissions = getattr(user, "saas_permissions", set())
|
||||
all_access_codes.update(saas_permissions)
|
||||
|
||||
direct_ids: Set[object] = set()
|
||||
direct_codes: Set[str] = set()
|
||||
|
||||
if user.role:
|
||||
for ra in user.role.role_accesses:
|
||||
if ra.access:
|
||||
direct_ids.add(ra.access.id)
|
||||
direct_codes.add(ra.access.access_code)
|
||||
|
||||
for access in self._accesses_from_scoped_roles(user):
|
||||
direct_ids.add(access.id)
|
||||
direct_codes.add(access.access_code)
|
||||
|
||||
if direct_ids:
|
||||
all_access_codes.update(
|
||||
self._expand_with_descendants(direct_ids, direct_codes)
|
||||
)
|
||||
|
||||
if any(code.startswith("document.conversion") for code in all_access_codes):
|
||||
all_access_codes.add("document.conversion")
|
||||
|
||||
return all_access_codes
|
||||
|
||||
def _group_ids_subquery(self, user):
|
||||
"""
|
||||
The groups this user belongs to, as a **subquery** rather than a round trip.
|
||||
|
||||
Filtered by tenant on both sides: a membership row naming another
|
||||
tenant's group must confer nothing, and `user_access_groups` carries its
|
||||
own `tenant_id` precisely so a forged or stale row cannot reach across.
|
||||
|
||||
A subquery and not a `list` because this used to be one extra SELECT on
|
||||
every permission check, and permission checks are the hottest path in
|
||||
the application.
|
||||
"""
|
||||
from app.modules.org.models.group_model import AccessGroup, UserAccessGroup
|
||||
|
||||
return (
|
||||
select(UserAccessGroup.group_id)
|
||||
.join(AccessGroup, AccessGroup.id == UserAccessGroup.group_id)
|
||||
.where(
|
||||
UserAccessGroup.user_id == user.id,
|
||||
UserAccessGroup.tenant_id == user.tenant_id,
|
||||
AccessGroup.tenant_id == user.tenant_id,
|
||||
)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
def _group_ids_for(self, user) -> list:
|
||||
"""Groups the user belongs to. Retained for callers that want the ids."""
|
||||
from app.modules.org.models.group_model import AccessGroup, UserAccessGroup
|
||||
|
||||
rows = (
|
||||
self.db.query(UserAccessGroup.group_id)
|
||||
.join(AccessGroup, AccessGroup.id == UserAccessGroup.group_id)
|
||||
.filter(
|
||||
UserAccessGroup.user_id == user.id,
|
||||
UserAccessGroup.tenant_id == user.tenant_id,
|
||||
AccessGroup.tenant_id == user.tenant_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [r[0] for r in rows]
|
||||
|
||||
def _accesses_from_scoped_roles(self, user: User) -> list:
|
||||
"""
|
||||
Every `Access` reachable through this user's `user_roles` rows.
|
||||
|
||||
Deliberately ignores `org_unit_id`: holding a role in one team still
|
||||
means holding its codes. Narrowing to a subtree is `ScopeService`'s job,
|
||||
and conflating the two here would mean an endpoint that has not been
|
||||
converted to ask about scope silently refuses people who legitimately
|
||||
hold the permission somewhere.
|
||||
|
||||
**The three predicates below are not optional.** This query originally
|
||||
filtered on the principal alone, which meant the coarse gate disagreed
|
||||
with `ScopeService` in two ways that both fail open:
|
||||
|
||||
- an **expired** grant still passed `require_access`, so temporary
|
||||
elevation did not expire on any endpoint that had not been converted
|
||||
to ask about scope — which is almost all of them;
|
||||
- a grant naming **another tenant's role** conferred that role's codes.
|
||||
This is the defect the C-series found in `ScopeService._resolve` and
|
||||
fixed there; it lived on here for a release.
|
||||
|
||||
They are expressed in SQL rather than filtered afterwards for the same
|
||||
reason `ScopeService` gives: an expired grant must be invisible to every
|
||||
reader, and a post-filter is how one caller ends up honouring a dead
|
||||
grant.
|
||||
"""
|
||||
from app.modules.auth.models.role_access_model import RoleAccess
|
||||
from app.modules.auth.models.role_model import Role
|
||||
from app.modules.auth.models.user_role_model import UserRole
|
||||
|
||||
return (
|
||||
self.db.query(Access)
|
||||
.join(RoleAccess, RoleAccess.access_id == Access.id)
|
||||
.join(UserRole, UserRole.role_id == RoleAccess.role_id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.filter(
|
||||
or_(
|
||||
UserRole.user_id == user.id,
|
||||
UserRole.group_id.in_(self._group_ids_subquery(user)),
|
||||
),
|
||||
or_(
|
||||
Role.tenant_id == user.tenant_id,
|
||||
Role.tenant_id.is_(None),
|
||||
),
|
||||
or_(
|
||||
UserRole.expires_at.is_(None),
|
||||
UserRole.expires_at > func.now(),
|
||||
),
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
|
||||
def _access_tree(self):
|
||||
"""
|
||||
`(code_by_id, children_by_parent)` for the whole catalogue.
|
||||
|
||||
One query, memoised per request. The catalogue is the same for every
|
||||
user, so resolving a page of twenty users used to read this table twenty
|
||||
times.
|
||||
"""
|
||||
|
||||
def _load():
|
||||
rows = self.db.query(
|
||||
Access.id, Access.parent_id, Access.access_code
|
||||
).all()
|
||||
code_by_id = {row.id: row.access_code for row in rows}
|
||||
children_by_parent: dict = {}
|
||||
for row in rows:
|
||||
if row.parent_id is None:
|
||||
continue
|
||||
children_by_parent.setdefault(row.parent_id, set()).add(row.id)
|
||||
return code_by_id, children_by_parent
|
||||
|
||||
return request_cached("access_tree", _load)
|
||||
|
||||
def _expand_with_descendants(
|
||||
self, direct_ids: Set[object], direct_codes: Set[str]
|
||||
) -> Set[str]:
|
||||
"""
|
||||
Expand assigned access codes with all descendants so parent access grants
|
||||
full module access.
|
||||
"""
|
||||
code_by_id, children_by_parent = self._access_tree()
|
||||
|
||||
visited = set(direct_ids)
|
||||
stack = list(direct_ids)
|
||||
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
for child_id in children_by_parent.get(current, set()):
|
||||
if child_id in visited:
|
||||
continue
|
||||
visited.add(child_id)
|
||||
stack.append(child_id)
|
||||
|
||||
expanded_codes = {code_by_id[access_id] for access_id in visited if access_id in code_by_id}
|
||||
return direct_codes.union(expanded_codes)
|
||||
|
||||
def has_access(self, user: Optional[User], code: str) -> bool:
|
||||
"""
|
||||
Check if user has a specific access code.
|
||||
|
||||
Args:
|
||||
user: Current user
|
||||
code: Access code to check (e.g. "project.create")
|
||||
|
||||
Returns:
|
||||
True if user has the access code, False otherwise
|
||||
"""
|
||||
return code in self.user_access_codes(user)
|
||||
|
||||
def require_access(self, user: Optional[User], code: str) -> None:
|
||||
"""
|
||||
Assert user has a specific access code.
|
||||
Raises HTTPException 403 if not authorized.
|
||||
|
||||
Args:
|
||||
user: Current user
|
||||
code: Required access code
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if user not authenticated or lacks access
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(status_code=403, detail="Authentication required")
|
||||
|
||||
if not self.has_access(user, code):
|
||||
raise HTTPException(
|
||||
status_code=403, detail=f"Access denied: insufficient permissions"
|
||||
)
|
||||
|
||||
def require_any_access(self, user: Optional[User], codes: List[str]) -> None:
|
||||
"""
|
||||
Assert user has at least one of the provided access codes.
|
||||
Raises HTTPException 403 if none match.
|
||||
|
||||
Args:
|
||||
user: Current user
|
||||
codes: List of access codes (user needs at least one)
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if user lacks all codes
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(status_code=403, detail="Authentication required")
|
||||
|
||||
if not any(self.has_access(user, code) for code in codes):
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Access denied: insufficient permissions"
|
||||
)
|
||||
|
||||
def require_all_access(self, user: Optional[User], codes: List[str]) -> None:
|
||||
"""
|
||||
Assert user has ALL of the provided access codes.
|
||||
Raises HTTPException 403 if any are missing.
|
||||
|
||||
Args:
|
||||
user: Current user
|
||||
codes: List of access codes (user must have all)
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if user lacks any code
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(status_code=403, detail="Authentication required")
|
||||
|
||||
missing = [code for code in codes if not self.has_access(user, code)]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Access denied: insufficient permissions"
|
||||
)
|
||||
|
||||
def get_all_system_accesses(self) -> List[dict]:
|
||||
"""
|
||||
Get all defined access codes in the system grouped by category.
|
||||
"""
|
||||
accesses = self.db.query(Access).all()
|
||||
return [
|
||||
{
|
||||
"id": a.id,
|
||||
"access_code": a.access_code,
|
||||
"category": a.category,
|
||||
"name": a.name,
|
||||
"parent_id": a.parent_id,
|
||||
}
|
||||
for a in accesses
|
||||
]
|
||||
|
||||
def granted_by(self, user: Optional[User]) -> dict:
|
||||
"""
|
||||
`{access_code: [attribution, ...]}` — which role conferred each code.
|
||||
|
||||
The answer to "why can this person do that", which
|
||||
`/api/admin/users/{id}/effective-access` exposes. With one role per user
|
||||
the question answered itself; with several it does not, and support
|
||||
cannot read the database.
|
||||
|
||||
Attribution names the role, the scope, and whether it arrived through
|
||||
the primary role or a grant. Descendant expansion is attributed to the
|
||||
role that holds the parent code, because that is the row an
|
||||
administrator would edit to take it away.
|
||||
"""
|
||||
if not user:
|
||||
return {}
|
||||
|
||||
attributions: dict = {}
|
||||
|
||||
def _add(code: str, entry: dict) -> None:
|
||||
existing = attributions.setdefault(code, [])
|
||||
if entry not in existing:
|
||||
existing.append(entry)
|
||||
|
||||
from app.modules.auth.services.user_role_service import UserRoleReader
|
||||
|
||||
for assignment in UserRoleReader(self.db).assignments(user):
|
||||
direct_ids = set()
|
||||
direct_codes = set()
|
||||
for access in self._accesses_of_role(assignment["role_id"]):
|
||||
direct_ids.add(access.id)
|
||||
direct_codes.add(access.access_code)
|
||||
if not direct_ids:
|
||||
continue
|
||||
entry = {
|
||||
"role_id": str(assignment["role_id"]),
|
||||
"role_name": assignment["role_name"],
|
||||
"source": assignment["source"],
|
||||
"org_unit_id": (
|
||||
str(assignment["org_unit_id"])
|
||||
if assignment["org_unit_id"]
|
||||
else None
|
||||
),
|
||||
"org_unit_name": assignment["org_unit_name"],
|
||||
"expires_at": (
|
||||
assignment["expires_at"].isoformat()
|
||||
if assignment["expires_at"]
|
||||
else None
|
||||
),
|
||||
}
|
||||
for code in self._expand_with_descendants(direct_ids, direct_codes):
|
||||
_add(code, entry)
|
||||
|
||||
for code in getattr(user, "saas_permissions", set()) or set():
|
||||
_add(
|
||||
code,
|
||||
{
|
||||
"role_id": None,
|
||||
"role_name": "Subscription platform",
|
||||
"source": "saas",
|
||||
"org_unit_id": None,
|
||||
"org_unit_name": None,
|
||||
"expires_at": None,
|
||||
},
|
||||
)
|
||||
|
||||
return attributions
|
||||
|
||||
def _accesses_of_role(self, role_id) -> list:
|
||||
from app.modules.auth.models.role_access_model import RoleAccess
|
||||
|
||||
def _load():
|
||||
return (
|
||||
self.db.query(Access)
|
||||
.join(RoleAccess, RoleAccess.access_id == Access.id)
|
||||
.filter(RoleAccess.role_id == role_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
return request_cached(f"role_accesses:{role_id}", _load)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
One answer to "is this caller a superadmin", for the routes that branch on it.
|
||||
|
||||
There were five copies of this predicate — in `role_routes`, `admin_user_routes`,
|
||||
`tenant_routes`, `chat_controller` and `admin_dashboard_service` — each written
|
||||
as:
|
||||
|
||||
any(acc.startswith("superadmin.") for acc in user.access_codes)
|
||||
|
||||
Identical, and all five wrong in the same way, because `User.access_codes` read
|
||||
only the legacy `users.role_id`. A user whose superadmin role arrived through a
|
||||
`user_roles` grant therefore **passed `require_access` and was then treated as a
|
||||
tenant administrator by the same request**. Getting through the door and then
|
||||
being scoped as somebody else is the worst failure shape available: it looks like
|
||||
a data bug, not an authorisation one.
|
||||
|
||||
The property is fixed at its source (`get_current_user` resolves through
|
||||
`PermissionService`), so these five call sites are now correct. They are
|
||||
collapsed into one function anyway, because five copies of a security predicate
|
||||
is five chances for the sixth to be written differently.
|
||||
|
||||
**`users.is_superadmin` is deliberately not consulted here, and the name says so.**
|
||||
DocQube has two superadmin predicates because it has two questions:
|
||||
|
||||
app.middleware.tenant.is_superadmin(user) the B1 privilege flag —
|
||||
may this session bypass
|
||||
the tenant filter and RLS
|
||||
holds_superadmin_access(user) does this caller hold a
|
||||
`superadmin.*` code — may
|
||||
they administer across
|
||||
tenants through the API
|
||||
|
||||
Two functions both called `is_superadmin` with different semantics is a trap, so
|
||||
this one is not called that. Folding the flag in here would silently widen five
|
||||
existing authorisation decisions — a tenant-less operator holding no
|
||||
`superadmin.*` code would begin passing `_assert_role_in_callers_tenant`, which
|
||||
today refuses them. That widening may well be desirable; it is a deliberate
|
||||
product change with its own probe, not a side effect of de-duplicating a helper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
SUPERADMIN_PREFIX = "superadmin."
|
||||
|
||||
|
||||
def holds_superadmin_access(user) -> bool:
|
||||
"""True when the caller holds any `superadmin.*` access code."""
|
||||
if user is None:
|
||||
return False
|
||||
codes = getattr(user, "access_codes", None) or []
|
||||
return any(str(code).startswith(SUPERADMIN_PREFIX) for code in codes)
|
||||
|
||||
|
||||
__all__ = ["holds_superadmin_access", "SUPERADMIN_PREFIX"]
|
||||
@@ -0,0 +1,186 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_, cast, String
|
||||
from fastapi import HTTPException, status
|
||||
from app.modules.auth.models.role_model import Role
|
||||
from app.modules.auth.models.role_access_model import RoleAccess
|
||||
from app.modules.auth.models.access_model import Access
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.auth.schemas.role_schema import (
|
||||
RoleCreate,
|
||||
RoleUpdate,
|
||||
RoleOut,
|
||||
RolePaginatedOut,
|
||||
)
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class RoleService:
|
||||
@staticmethod
|
||||
def create_role(db: Session, role_data: RoleCreate) -> Role:
|
||||
existing = (
|
||||
db.query(Role)
|
||||
.filter(
|
||||
Role.name == role_data.role_name, Role.tenant_id == role_data.tenant_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Role name already exists for this tenant",
|
||||
)
|
||||
role = Role(
|
||||
name=role_data.role_name,
|
||||
description=role_data.description,
|
||||
tenant_id=role_data.tenant_id,
|
||||
is_default=role_data.is_default or False,
|
||||
)
|
||||
db.add(role)
|
||||
db.flush()
|
||||
db.refresh(role)
|
||||
|
||||
if role_data.access_ids:
|
||||
RoleService.assign_accesses(db, role.id, role_data.access_ids)
|
||||
|
||||
return role
|
||||
|
||||
@staticmethod
|
||||
def assign_accesses(db: Session, role_id: UUID, access_ids: List[UUID]):
|
||||
db.query(RoleAccess).filter(RoleAccess.role_id == role_id).delete()
|
||||
for aid in access_ids:
|
||||
access = db.query(Access).filter(Access.id == aid).first()
|
||||
if not access:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Access {aid} not found",
|
||||
)
|
||||
ra = RoleAccess(role_id=role_id, access_id=aid)
|
||||
db.add(ra)
|
||||
|
||||
@staticmethod
|
||||
def get_all_roles(db: Session, tenant_id: Optional[UUID] = None):
|
||||
query = db.query(Role)
|
||||
if tenant_id is not None:
|
||||
from app.modules.billing.models.plan_model import TenantSubscription, PlanRole
|
||||
sub = db.query(TenantSubscription).filter(
|
||||
TenantSubscription.tenant_id == tenant_id,
|
||||
TenantSubscription.status == 'active'
|
||||
).order_by(TenantSubscription.created_at.desc()).first()
|
||||
if sub and sub.plan_id:
|
||||
plan_roles = db.query(PlanRole).filter(PlanRole.plan_id == sub.plan_id).all()
|
||||
plan_role_ids = [pr.role_id for pr in plan_roles]
|
||||
query = query.filter(or_(Role.tenant_id == tenant_id, Role.id.in_(plan_role_ids)))
|
||||
else:
|
||||
query = query.filter(Role.tenant_id == tenant_id)
|
||||
return query.all()
|
||||
|
||||
@staticmethod
|
||||
def get_role_by_id(db: Session, role_id: UUID) -> Role:
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
|
||||
)
|
||||
return role
|
||||
|
||||
@staticmethod
|
||||
def update_role(db: Session, role_id: UUID, role_data: RoleUpdate) -> Role:
|
||||
role = RoleService.get_role_by_id(db, role_id)
|
||||
if getattr(role, "is_system", False):
|
||||
raise HTTPException(status_code=400, detail="Cannot edit a system role.")
|
||||
update_dict = role_data.model_dump(exclude_unset=True)
|
||||
if "role_name" in update_dict:
|
||||
name = update_dict.pop("role_name")
|
||||
if name and name != role.name:
|
||||
conflict = (
|
||||
db.query(Role)
|
||||
.filter(Role.name == name, Role.tenant_id == role.tenant_id)
|
||||
.first()
|
||||
)
|
||||
if conflict:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Role name already exists",
|
||||
)
|
||||
role.name = name
|
||||
if "description" in update_dict:
|
||||
role.description = update_dict.get("description")
|
||||
if "is_default" in update_dict:
|
||||
role.is_default = update_dict.get("is_default")
|
||||
if "access_ids" in update_dict:
|
||||
access_ids = update_dict.get("access_ids")
|
||||
if access_ids is not None:
|
||||
RoleService.assign_accesses(db, role_id, access_ids)
|
||||
db.flush()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
@staticmethod
|
||||
def delete_role(db: Session, role_id: UUID):
|
||||
role = RoleService.get_role_by_id(db, role_id)
|
||||
if getattr(role, "is_system", False):
|
||||
raise HTTPException(status_code=400, detail="Cannot delete a system role.")
|
||||
db.delete(role)
|
||||
return {"message": "Role deleted successfully"}
|
||||
|
||||
@staticmethod
|
||||
def get_roles_paginated(
|
||||
db: Session,
|
||||
tenant_id: Optional[UUID] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search: Optional[str] = None,
|
||||
):
|
||||
query = db.query(Role)
|
||||
if tenant_id is not None:
|
||||
from app.modules.billing.models.plan_model import TenantSubscription, PlanRole
|
||||
sub = db.query(TenantSubscription).filter(
|
||||
TenantSubscription.tenant_id == tenant_id,
|
||||
TenantSubscription.status == 'active'
|
||||
).order_by(TenantSubscription.created_at.desc()).first()
|
||||
if sub and sub.plan_id:
|
||||
plan_roles = db.query(PlanRole).filter(PlanRole.plan_id == sub.plan_id).all()
|
||||
plan_role_ids = [pr.role_id for pr in plan_roles]
|
||||
query = query.filter(or_(Role.tenant_id == tenant_id, Role.id.in_(plan_role_ids)))
|
||||
else:
|
||||
query = query.filter(Role.tenant_id == tenant_id)
|
||||
if search and search.strip():
|
||||
search_term = search.strip()
|
||||
query = query.filter(Role.name.ilike(f"%{search_term}%"))
|
||||
total = query.count()
|
||||
offset = (page - 1) * page_size
|
||||
roles = query.offset(offset).limit(page_size).all()
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
||||
return {
|
||||
"items": [r for r in roles],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_role_with_accesses(db: Session, role_id: UUID) -> Role:
|
||||
"""Get a role with all its access assignments."""
|
||||
return RoleService.get_role_by_id(db, role_id)
|
||||
|
||||
@staticmethod
|
||||
def assign_role_to_user(db: Session, user_id: int, role_id: UUID):
|
||||
"""Assign a role to a specific user."""
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
role = RoleService.get_role_by_id(db, role_id)
|
||||
user.role_id = role.id
|
||||
db.flush()
|
||||
db.refresh(user)
|
||||
return {
|
||||
"message": "Role assigned successfully",
|
||||
"user_id": user_id,
|
||||
"role_id": str(role_id),
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
"""
|
||||
Every role a user actually holds, resolved and named.
|
||||
|
||||
`users.role_id` gives a user one role. `user_roles` gives them any number, at a
|
||||
scope, optionally expiring, optionally through a group. Both mechanisms are live
|
||||
and are **unioned, never intersected** — that is the compatibility rule the
|
||||
C-series migration set, and it is why turning any of this on cannot remove
|
||||
somebody's access.
|
||||
|
||||
This module answers "which roles", where `PermissionService` answers "which
|
||||
codes". They are separate because they are asked by different callers for
|
||||
different reasons: the gate needs codes, and the *interface* needs roles, so an
|
||||
administrator can see that Alice is both an Editor and an Approver rather than
|
||||
inferring it from forty-eight access codes.
|
||||
|
||||
**`source` is the field that keeps the UI honest.**
|
||||
|
||||
"primary" the legacy `users.role_id` — changed through PATCH /admin/users,
|
||||
not revocable as a grant, and (see `ScopeService._resolve`) it
|
||||
cannot be narrowed by anything while it is still set
|
||||
"grant" a `user_roles` row — added and revoked freely
|
||||
"group" a `user_roles` row naming a group this user belongs to; it
|
||||
confers codes, and it is revoked by editing the group, not the
|
||||
user
|
||||
|
||||
Rendering all three as one undifferentiated list would imply that removing any
|
||||
of them is the same action. It is not, and the difference is exactly where a
|
||||
permissions UI misleads its operator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.request_cache import request_cached
|
||||
|
||||
|
||||
class UserRoleReader:
|
||||
"""Read-only resolution of role assignments. No writes, no side effects."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
|
||||
def assignments(self, user, include_expired: bool = False) -> list[dict]:
|
||||
"""
|
||||
Primary role first, then grants, then group-derived grants.
|
||||
|
||||
Ordered deliberately: the primary role is the one an operator changes
|
||||
through the existing dropdown, so it belongs at the top of any list that
|
||||
also offers "remove".
|
||||
|
||||
Memoised per request unless `include_expired` is set — the expired view
|
||||
is an administrative read, not a hot path, and caching two shapes under
|
||||
one key is how a stale answer gets served to the wrong caller.
|
||||
"""
|
||||
if user is None:
|
||||
return []
|
||||
if include_expired:
|
||||
return self._resolve(user, include_expired=True)
|
||||
return request_cached(
|
||||
f"role_assignments:{user.id}", lambda: self._resolve(user)
|
||||
)
|
||||
|
||||
def _resolve(self, user, include_expired: bool = False) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
|
||||
primary = getattr(user, "role", None)
|
||||
if primary is not None:
|
||||
out.append(
|
||||
{
|
||||
"grant_id": None,
|
||||
"role_id": primary.id,
|
||||
"role_name": primary.name,
|
||||
"source": "primary",
|
||||
"org_unit_id": None,
|
||||
"org_unit_name": None,
|
||||
"group_id": None,
|
||||
"group_name": None,
|
||||
"expires_at": None,
|
||||
"assigned_by_id": None,
|
||||
"created_at": None,
|
||||
"is_expired": False,
|
||||
}
|
||||
)
|
||||
|
||||
out.extend(self._grants(user, include_expired=include_expired))
|
||||
return out
|
||||
|
||||
def _grants(self, user, include_expired: bool = False) -> list[dict]:
|
||||
from app.modules.auth.models.role_model import Role
|
||||
from app.modules.auth.models.user_role_model import UserRole
|
||||
from app.modules.org.models.group_model import AccessGroup, UserAccessGroup
|
||||
from app.modules.org.models.org_model import OrgUnit
|
||||
|
||||
my_groups = (
|
||||
select(UserAccessGroup.group_id)
|
||||
.join(AccessGroup, AccessGroup.id == UserAccessGroup.group_id)
|
||||
.where(
|
||||
UserAccessGroup.user_id == user.id,
|
||||
UserAccessGroup.tenant_id == user.tenant_id,
|
||||
AccessGroup.tenant_id == user.tenant_id,
|
||||
)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
query = (
|
||||
self.db.query(
|
||||
UserRole.id,
|
||||
UserRole.role_id,
|
||||
Role.name,
|
||||
UserRole.org_unit_id,
|
||||
OrgUnit.name,
|
||||
UserRole.group_id,
|
||||
AccessGroup.name,
|
||||
UserRole.expires_at,
|
||||
UserRole.assigned_by_id,
|
||||
UserRole.created_at,
|
||||
)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.outerjoin(OrgUnit, OrgUnit.id == UserRole.org_unit_id)
|
||||
.outerjoin(AccessGroup, AccessGroup.id == UserRole.group_id)
|
||||
.filter(
|
||||
or_(
|
||||
UserRole.user_id == user.id,
|
||||
UserRole.group_id.in_(my_groups),
|
||||
),
|
||||
or_(Role.tenant_id == user.tenant_id, Role.tenant_id.is_(None)),
|
||||
)
|
||||
)
|
||||
if not include_expired:
|
||||
query = query.filter(
|
||||
or_(UserRole.expires_at.is_(None), UserRole.expires_at > func.now())
|
||||
)
|
||||
|
||||
rows = query.order_by(UserRole.created_at).all()
|
||||
return [self._row_to_assignment(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
def _row_to_assignment(row) -> dict:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
(
|
||||
grant_id,
|
||||
role_id,
|
||||
role_name,
|
||||
org_unit_id,
|
||||
org_unit_name,
|
||||
group_id,
|
||||
group_name,
|
||||
expires_at,
|
||||
assigned_by_id,
|
||||
created_at,
|
||||
) = row
|
||||
|
||||
expired = False
|
||||
if expires_at is not None:
|
||||
reference = expires_at
|
||||
if reference.tzinfo is None:
|
||||
reference = reference.replace(tzinfo=timezone.utc)
|
||||
expired = reference <= datetime.now(timezone.utc)
|
||||
|
||||
return {
|
||||
"grant_id": grant_id,
|
||||
"role_id": role_id,
|
||||
"role_name": role_name,
|
||||
"source": "group" if group_id is not None else "grant",
|
||||
"org_unit_id": org_unit_id,
|
||||
"org_unit_name": org_unit_name,
|
||||
"group_id": group_id,
|
||||
"group_name": group_name,
|
||||
"expires_at": expires_at,
|
||||
"assigned_by_id": assigned_by_id,
|
||||
"created_at": created_at,
|
||||
"is_expired": expired,
|
||||
}
|
||||
|
||||
|
||||
def assignments_for_users(
|
||||
self, users: Iterable[Any]
|
||||
) -> dict[int, list[dict]]:
|
||||
"""
|
||||
The same resolution for a page of users, in **two** queries rather than
|
||||
two per user.
|
||||
|
||||
`/api/admin/users` renders up to a hundred rows. Resolving each one
|
||||
through `assignments()` would be a textbook N+1 that a seeded test
|
||||
database with three users cannot see and a real tenant feels immediately,
|
||||
which is precisely the failure `tests/characterization/test_query_budget.py`
|
||||
exists to catch — so this is written batched from the start.
|
||||
"""
|
||||
from app.modules.auth.models.role_model import Role
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.auth.models.user_role_model import UserRole
|
||||
from app.modules.org.models.group_model import AccessGroup, UserAccessGroup
|
||||
from app.modules.org.models.org_model import OrgUnit
|
||||
|
||||
users = list(users)
|
||||
result: dict[int, list[dict]] = {}
|
||||
for user in users:
|
||||
primary = getattr(user, "role", None)
|
||||
entries = []
|
||||
if primary is not None:
|
||||
entries.append(
|
||||
{
|
||||
"grant_id": None,
|
||||
"role_id": primary.id,
|
||||
"role_name": primary.name,
|
||||
"source": "primary",
|
||||
"org_unit_id": None,
|
||||
"org_unit_name": None,
|
||||
"group_id": None,
|
||||
"group_name": None,
|
||||
"expires_at": None,
|
||||
"assigned_by_id": None,
|
||||
"created_at": None,
|
||||
"is_expired": False,
|
||||
}
|
||||
)
|
||||
result[user.id] = entries
|
||||
|
||||
user_ids = [u.id for u in users]
|
||||
if not user_ids:
|
||||
return result
|
||||
|
||||
rows = (
|
||||
self.db.query(
|
||||
UserRole.user_id,
|
||||
UserRole.id,
|
||||
UserRole.role_id,
|
||||
Role.name,
|
||||
UserRole.org_unit_id,
|
||||
OrgUnit.name,
|
||||
UserRole.expires_at,
|
||||
UserRole.assigned_by_id,
|
||||
UserRole.created_at,
|
||||
)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.join(User, User.id == UserRole.user_id)
|
||||
.outerjoin(OrgUnit, OrgUnit.id == UserRole.org_unit_id)
|
||||
.filter(
|
||||
UserRole.user_id.in_(user_ids),
|
||||
or_(Role.tenant_id == User.tenant_id, Role.tenant_id.is_(None)),
|
||||
or_(UserRole.expires_at.is_(None), UserRole.expires_at > func.now()),
|
||||
)
|
||||
.order_by(UserRole.created_at)
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
user_id = row[0]
|
||||
if user_id not in result:
|
||||
continue
|
||||
result[user_id].append(
|
||||
self._row_to_assignment(
|
||||
(row[1], row[2], row[3], row[4], row[5], None, None, row[6], row[7], row[8])
|
||||
)
|
||||
)
|
||||
|
||||
group_rows = (
|
||||
self.db.query(
|
||||
UserAccessGroup.user_id,
|
||||
UserRole.id,
|
||||
UserRole.role_id,
|
||||
Role.name,
|
||||
UserRole.org_unit_id,
|
||||
OrgUnit.name,
|
||||
UserRole.group_id,
|
||||
AccessGroup.name,
|
||||
UserRole.expires_at,
|
||||
UserRole.assigned_by_id,
|
||||
UserRole.created_at,
|
||||
)
|
||||
.join(UserRole, UserRole.group_id == UserAccessGroup.group_id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.join(AccessGroup, AccessGroup.id == UserRole.group_id)
|
||||
.join(User, User.id == UserAccessGroup.user_id)
|
||||
.outerjoin(OrgUnit, OrgUnit.id == UserRole.org_unit_id)
|
||||
.filter(
|
||||
UserAccessGroup.user_id.in_(user_ids),
|
||||
UserAccessGroup.tenant_id == User.tenant_id,
|
||||
AccessGroup.tenant_id == User.tenant_id,
|
||||
or_(Role.tenant_id == User.tenant_id, Role.tenant_id.is_(None)),
|
||||
or_(UserRole.expires_at.is_(None), UserRole.expires_at > func.now()),
|
||||
)
|
||||
.order_by(UserRole.created_at)
|
||||
.all()
|
||||
)
|
||||
for row in group_rows:
|
||||
user_id = row[0]
|
||||
if user_id not in result:
|
||||
continue
|
||||
result[user_id].append(self._row_to_assignment(tuple(row[1:])))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def grant_row(self, tenant_id, grant_id: uuid.UUID):
|
||||
"""One `user_roles` row, refusing to reach outside the caller's tenant."""
|
||||
from app.modules.auth.models.role_model import Role
|
||||
from app.modules.auth.models.user_role_model import UserRole
|
||||
|
||||
return (
|
||||
self.db.query(UserRole)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.filter(
|
||||
UserRole.id == grant_id,
|
||||
or_(Role.tenant_id == tenant_id, Role.tenant_id.is_(None)),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def users_holding_role(self, role_id) -> int:
|
||||
"""
|
||||
How many users a role reaches, counting both mechanisms.
|
||||
|
||||
Used to make role deletion say what it is about to do. Deleting a role
|
||||
cascades its grants away (`user_roles.role_id ON DELETE CASCADE`) and
|
||||
nulls the primary (`users.role_id ON DELETE SET NULL`), so a silent
|
||||
delete can remove authority from people nobody was thinking about.
|
||||
"""
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.auth.models.user_role_model import UserRole
|
||||
|
||||
primary = (
|
||||
self.db.query(func.count(User.id))
|
||||
.filter(User.role_id == role_id, User.is_deleted.is_(False))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
granted = (
|
||||
self.db.query(func.count(func.distinct(UserRole.user_id)))
|
||||
.filter(UserRole.role_id == role_id, UserRole.user_id.isnot(None))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
groups = (
|
||||
self.db.query(func.count(func.distinct(UserRole.group_id)))
|
||||
.filter(UserRole.role_id == role_id, UserRole.group_id.isnot(None))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
return int(primary) + int(granted) + int(groups)
|
||||
|
||||
|
||||
def attach_role_refs(reader: "UserRoleReader", users: Iterable[Any]) -> None:
|
||||
"""
|
||||
Give a list of user rows their resolved roles, in two queries for the batch.
|
||||
|
||||
`UserOut.roles` is declared as "every role this user holds". Any endpoint
|
||||
that serialises a *different* user than the caller has to fill it in, or the
|
||||
field means one thing on `/api/me/profile` and something narrower elsewhere
|
||||
— which is precisely the split between the gate and the response that this
|
||||
work exists to close, reintroduced one endpoint at a time.
|
||||
|
||||
Sets the same `_resolve_role_refs` closure `get_current_user` attaches, so
|
||||
`User.role_refs` needs no knowledge of where its answer came from.
|
||||
"""
|
||||
users = list(users)
|
||||
if not users:
|
||||
return
|
||||
by_user = reader.assignments_for_users(users)
|
||||
for row in users:
|
||||
assignments = by_user.get(row.id, [])
|
||||
setattr(row, "_resolve_role_refs", lambda captured=assignments: captured)
|
||||
|
||||
|
||||
def invalidate_for_user(user_id: Optional[int]) -> None:
|
||||
"""
|
||||
Forget this request's memoised answers for one user.
|
||||
|
||||
Called after a grant or a revoke. Without it, a POST that adds a role and
|
||||
then returns the user's roles in the same response serves the pre-grant
|
||||
answer — which reads as "the button did nothing".
|
||||
"""
|
||||
from app.core.request_cache import invalidate_request_cache
|
||||
|
||||
if user_id is None:
|
||||
invalidate_request_cache("role_assignments:")
|
||||
invalidate_request_cache("access_codes:")
|
||||
return
|
||||
invalidate_request_cache(f"role_assignments:{user_id}")
|
||||
invalidate_request_cache(f"access_codes:{user_id}")
|
||||
|
||||
|
||||
__all__ = ["UserRoleReader", "attach_role_refs", "invalidate_for_user"]
|
||||
@@ -0,0 +1,356 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
import time
|
||||
import logging
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.core.settings import settings
|
||||
from app.services.saas_service import SaaSService
|
||||
from app.modules.tenant.models.tenant_model import Tenant
|
||||
from app.modules.auth.models.saas_models import SaaSTenantMapping
|
||||
|
||||
router = APIRouter(prefix="/sso", tags=["SSO"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@router.post("/login")
|
||||
async def sso_login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Endpoint for SaaS to initiate a login session for a user.
|
||||
Verified via HMAC signature.
|
||||
"""
|
||||
try:
|
||||
data = await read_json_body(request, what="SSO login body")
|
||||
|
||||
user_id = data.get("user_id")
|
||||
email = data.get("email")
|
||||
tenant_id = data.get("tenant_id")
|
||||
timestamp = data.get("timestamp")
|
||||
signature = request.headers.get("X-Signature")
|
||||
|
||||
if not all([user_id, email, timestamp, signature]):
|
||||
raise HTTPException(status_code=400, detail="Missing required SSO parameters")
|
||||
|
||||
now = int(time.time() * 1000)
|
||||
req_timestamp = int(timestamp)
|
||||
if abs(now - req_timestamp) > 5 * 60 * 1000:
|
||||
raise HTTPException(status_code=401, detail="Invalid or stale timestamp")
|
||||
|
||||
import json
|
||||
canonical_string = json.dumps(data, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
if not SaaSService.verify_signature(signature, canonical_string):
|
||||
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||
|
||||
saas_data = {
|
||||
"id": user_id,
|
||||
"email": email,
|
||||
"name": data.get("first_name") or email.split('@')[0],
|
||||
"company_id": tenant_id,
|
||||
"tenant_name": data.get("tenant_name"),
|
||||
"role": data.get("role"),
|
||||
"role_id": data.get("role_id"),
|
||||
"is_superadmin": data.get("is_superadmin"),
|
||||
"metadata": {
|
||||
"permissions": data.get("permissions", []),
|
||||
"subscription": data.get("subscription"),
|
||||
},
|
||||
}
|
||||
|
||||
user, mapping = SaaSService.ensure_saas_user(db, saas_data)
|
||||
logger.info(f"SSO: User {email} mapped to local user ID {user.id}")
|
||||
|
||||
permissions = data.get("permissions", [])
|
||||
if mapping:
|
||||
mapping.metadata_ = {
|
||||
"permissions": permissions,
|
||||
"subscription": data.get("subscription"),
|
||||
}
|
||||
|
||||
is_user_superadmin = bool(
|
||||
getattr(user, "is_superadmin", False)
|
||||
or data.get("is_superadmin")
|
||||
or data.get("role") == "superadmin"
|
||||
or (isinstance(data.get("permissions"), list) and "superadmin.main.view" in data.get("permissions"))
|
||||
)
|
||||
if is_user_superadmin and not user.is_superadmin:
|
||||
user.is_superadmin = True
|
||||
db.flush()
|
||||
db.refresh(user)
|
||||
|
||||
access_token = create_access_token(
|
||||
{
|
||||
"sub": str(user.id),
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
"is_superadmin": is_user_superadmin,
|
||||
"saas_permissions": permissions,
|
||||
"saas_subscription": data.get("subscription"),
|
||||
}
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
{
|
||||
"sub": str(user.id),
|
||||
}
|
||||
)
|
||||
|
||||
is_prod = settings.APP_ENV == "production"
|
||||
|
||||
response.set_cookie(
|
||||
key="docqube_access_token",
|
||||
value=access_token,
|
||||
httponly=True,
|
||||
secure=is_prod,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
|
||||
response.set_cookie(
|
||||
key="docqube_refresh_token",
|
||||
value=refresh_token,
|
||||
httponly=True,
|
||||
secure=is_prod,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
max_age=7 * 24 * 3600
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "SSO Login successful",
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"permissions": permissions
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException as he:
|
||||
raise he
|
||||
except Exception as e:
|
||||
logger.error("SSO Login Failed", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal Server Error")
|
||||
|
||||
@router.post("/sync-permissions")
|
||||
def sync_permissions(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Return all permission nodes defined in DocQube to the SaaS platform.
|
||||
"""
|
||||
signature = request.headers.get("X-SaaS-Signature") or request.headers.get("X-Signature")
|
||||
if not signature:
|
||||
raise HTTPException(status_code=401, detail="Missing signature")
|
||||
|
||||
payload_body = "{}"
|
||||
expected_signature = hmac.new(
|
||||
settings.SAAS_TRUST_SECRET.encode("utf-8"),
|
||||
payload_body.encode("utf-8"),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(signature, expected_signature):
|
||||
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||
|
||||
try:
|
||||
from app.modules.auth.models.access_model import Access
|
||||
all_nodes = db.query(Access).all()
|
||||
node_map = {str(node.id): node.access_code for node in all_nodes}
|
||||
|
||||
permissions = []
|
||||
for node in all_nodes:
|
||||
parent_code = node_map.get(str(node.parent_id)) if node.parent_id else None
|
||||
permissions.append({
|
||||
"permission_code": node.access_code,
|
||||
"name": node.name,
|
||||
"category": node.category,
|
||||
"parent_code": parent_code
|
||||
})
|
||||
return {"status": "success", "permissions": permissions}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Failed to build the permission list for SaaS")
|
||||
raise HTTPException(status_code=500, detail="Internal Server Error")
|
||||
|
||||
@router.post("/provision")
|
||||
async def provision_tenant(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Receive tenant provisioning/update events from SaaS and sync the mapped
|
||||
DocQube tenant, including max_users_allowed.
|
||||
"""
|
||||
raw_body = await request.body()
|
||||
signature = request.headers.get("X-SaaS-Signature") or request.headers.get("X-Signature")
|
||||
if not signature:
|
||||
raise HTTPException(status_code=401, detail="Missing signature")
|
||||
|
||||
expected_signature = hmac.new(
|
||||
settings.SAAS_TRUST_SECRET.encode("utf-8"),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(signature, expected_signature):
|
||||
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||
|
||||
payload = await read_json_body(request, what="webhook payload")
|
||||
event_type = payload.get("event_type")
|
||||
data = payload.get("data", {}) or {}
|
||||
|
||||
saas_tenant_id = data.get("tenant_id")
|
||||
tenant_name = data.get("tenant_name") or "SaaS Tenant"
|
||||
max_users_allowed = data.get("max_users_allowed")
|
||||
is_active = data.get("is_active")
|
||||
plan_code = data.get("plan_code") or data.get("plan")
|
||||
subscription = data.get("subscription") or {}
|
||||
|
||||
try:
|
||||
if event_type in {"TENANT_PROVISION_REQUESTED", "TENANT_UPDATED", "TENANT_STATUS_CHANGED"}:
|
||||
if not saas_tenant_id:
|
||||
raise HTTPException(status_code=400, detail="Missing tenant_id")
|
||||
tenant = SaaSService.ensure_saas_tenant(
|
||||
db,
|
||||
saas_tenant_id=saas_tenant_id,
|
||||
name=tenant_name,
|
||||
max_users_allowed=max_users_allowed,
|
||||
is_active=is_active,
|
||||
)
|
||||
_sync_subscription(db, tenant, plan_code, subscription)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"{event_type} synced",
|
||||
"tenant_id": str(tenant.id),
|
||||
"max_users_allowed": tenant.max_users_allowed,
|
||||
}
|
||||
|
||||
if event_type == "TENANT_DEPROVISION_REQUESTED":
|
||||
mapping = (
|
||||
db.query(SaaSTenantMapping)
|
||||
.filter(SaaSTenantMapping.saas_tenant_id == str(saas_tenant_id))
|
||||
.first()
|
||||
)
|
||||
if mapping:
|
||||
tenant = db.query(Tenant).filter(Tenant.id == mapping.docqube_tenant_id).first()
|
||||
if tenant:
|
||||
tenant.is_active = False
|
||||
db.flush()
|
||||
db.refresh(tenant)
|
||||
return {"status": "success", "message": "Tenant deprovisioned"}
|
||||
|
||||
if event_type == "USER_PROVISION_REQUESTED":
|
||||
user, _ = SaaSService.provision_user(db, data)
|
||||
return {"status": "success", "message": "User provisioned", "user_id": str(user.id) if user else None}
|
||||
|
||||
if event_type == "USER_DEPROVISION_REQUESTED":
|
||||
SaaSService.deprovision_user(db, data)
|
||||
return {"status": "success", "message": "User deprovisioned"}
|
||||
|
||||
if event_type == "ROLE_PROVISION_REQUESTED":
|
||||
role, _ = SaaSService.provision_role(db, data)
|
||||
return {"status": "success", "message": "Role provisioned", "role_id": str(role.id) if role else None}
|
||||
|
||||
if event_type == "ROLE_DEPROVISION_REQUESTED":
|
||||
SaaSService.deprovision_role(db, data)
|
||||
return {"status": "success", "message": "Role deprovisioned"}
|
||||
|
||||
if event_type == "PLAN_PROVISION_REQUESTED":
|
||||
plan = SaaSService.ensure_saas_plan(db, data)
|
||||
db.commit()
|
||||
return {"status": "success", "message": "Plan provisioned", "plan_id": str(plan.id)}
|
||||
|
||||
if event_type == "PLAN_UPDATED":
|
||||
plan = SaaSService.update_saas_plan(db, data)
|
||||
db.commit()
|
||||
return {"status": "success", "message": "Plan updated", "plan_id": str(plan.id)}
|
||||
|
||||
if event_type == "PLAN_DEPROVISION_REQUESTED":
|
||||
SaaSService.deprovision_plan(db, data)
|
||||
db.commit()
|
||||
return {"status": "success", "message": "Plan deprovisioned"}
|
||||
|
||||
return {"status": "ignored", "message": f"Unhandled event type: {event_type}"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Provisioning webhook failed for event %s", event_type)
|
||||
raise HTTPException(status_code=500, detail=f"Provisioning error: {str(e)}")
|
||||
|
||||
from app.core.security import create_access_token, create_refresh_token
|
||||
from app.core.request_body import read_json_body
|
||||
|
||||
|
||||
def _sync_subscription(db, tenant, plan_code, subscription: dict) -> None:
|
||||
"""
|
||||
Record what the platform says about this tenant's plan.
|
||||
|
||||
**Unknown plan codes are logged and ignored, not created.** Auto-creating a
|
||||
plan from a webhook would let the other system invent products here — with
|
||||
no price, no limits, and no one having decided what it includes. A tenant
|
||||
keeps its current plan until somebody adds the new one deliberately.
|
||||
|
||||
Nothing here is fatal. A provisioning webhook that fails because the plan
|
||||
name changed would block the tenant from being created at all, which is a
|
||||
worse outcome than a tenant whose plan is briefly stale.
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.modules.billing.models.plan_model import Plan, TenantSubscription
|
||||
|
||||
if not plan_code and not subscription:
|
||||
return
|
||||
|
||||
row = (
|
||||
db.query(TenantSubscription)
|
||||
.filter(TenantSubscription.tenant_id == tenant.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
plan = None
|
||||
if plan_code:
|
||||
plan = db.query(Plan).filter(Plan.code == str(plan_code)).first()
|
||||
if plan is None:
|
||||
logger.warning(
|
||||
"SaaS webhook named plan %r, which does not exist here; leaving "
|
||||
"tenant %s on its current plan",
|
||||
plan_code,
|
||||
tenant.id,
|
||||
)
|
||||
|
||||
if row is None:
|
||||
if plan is None:
|
||||
return
|
||||
row = TenantSubscription(tenant_id=tenant.id, plan_id=plan.id)
|
||||
db.add(row)
|
||||
elif plan is not None:
|
||||
row.plan_id = plan.id
|
||||
|
||||
status = subscription.get("status")
|
||||
if status:
|
||||
mapped = {
|
||||
"ACTIVE": "active",
|
||||
"TRIAL": "trialing",
|
||||
"TRIALING": "trialing",
|
||||
"PAST_DUE": "past_due",
|
||||
"CANCELLED": "cancelled",
|
||||
"CANCELED": "cancelled",
|
||||
"EXPIRED": "expired",
|
||||
}.get(str(status).upper())
|
||||
if mapped:
|
||||
row.status = mapped
|
||||
else:
|
||||
logger.warning("SaaS webhook sent unknown status %r; leaving as-is", status)
|
||||
|
||||
external_ref = subscription.get("id") or subscription.get("external_ref")
|
||||
if external_ref:
|
||||
row.external_ref = str(external_ref)
|
||||
|
||||
db.flush()
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import hashlib
|
||||
import uuid
|
||||
from fastapi import Request
|
||||
import re
|
||||
import requests
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
client_host = request.client.host if request.client else "0.0.0.0"
|
||||
|
||||
is_trusted = client_host.startswith("10.") or client_host.startswith("172.") or client_host.startswith("192.168.") or client_host == "127.0.0.1"
|
||||
|
||||
if is_trusted:
|
||||
x_forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if x_forwarded_for:
|
||||
return x_forwarded_for.split(",")[0].strip()
|
||||
|
||||
return client_host
|
||||
|
||||
def parse_user_agent(user_agent: str):
|
||||
try:
|
||||
from user_agents import parse
|
||||
user_agent_parsed = parse(user_agent)
|
||||
|
||||
browser = user_agent_parsed.browser.family
|
||||
os = user_agent_parsed.os.family
|
||||
|
||||
device_type = "Desktop"
|
||||
if user_agent_parsed.is_mobile:
|
||||
device_type = "Mobile"
|
||||
elif user_agent_parsed.is_tablet:
|
||||
device_type = "Tablet"
|
||||
|
||||
return browser, os, device_type
|
||||
except ImportError:
|
||||
browser = "Unknown"
|
||||
os = "Unknown"
|
||||
device_type = "Desktop"
|
||||
|
||||
ua_lower = user_agent.lower()
|
||||
|
||||
if "mobile" in ua_lower or "android" in ua_lower or "iphone" in ua_lower:
|
||||
device_type = "Mobile"
|
||||
if "tablet" in ua_lower or "ipad" in ua_lower:
|
||||
device_type = "Tablet"
|
||||
|
||||
if "windows" in ua_lower:
|
||||
os = "Windows"
|
||||
elif "mac os" in ua_lower or "macos" in ua_lower:
|
||||
os = "macOS"
|
||||
elif "android" in ua_lower:
|
||||
os = "Android"
|
||||
elif "iphone" in ua_lower or "ipad" in ua_lower:
|
||||
os = "iOS"
|
||||
elif "linux" in ua_lower:
|
||||
os = "Linux"
|
||||
|
||||
if "edg" in ua_lower:
|
||||
browser = "Edge"
|
||||
elif "opr" in ua_lower or "opera" in ua_lower:
|
||||
browser = "Opera"
|
||||
elif "chrome" in ua_lower:
|
||||
browser = "Chrome"
|
||||
elif "safari" in ua_lower:
|
||||
browser = "Safari"
|
||||
elif "firefox" in ua_lower:
|
||||
browser = "Firefox"
|
||||
|
||||
return browser, os, device_type
|
||||
|
||||
def generate_device_fingerprint(request: Request) -> str:
|
||||
device_id = request.cookies.get("docqube_device_id")
|
||||
if device_id:
|
||||
return device_id
|
||||
|
||||
new_device_id = str(uuid.uuid4())
|
||||
request.state.new_device_id = new_device_id
|
||||
return new_device_id
|
||||
|
||||
def get_location_from_ip(ip: str):
|
||||
"""
|
||||
Fetch approximate location from IP using free ip-api.com.
|
||||
Returns (country, state, city, lat, lon)
|
||||
"""
|
||||
if not ip or ip in ("127.0.0.1", "0.0.0.0", "localhost"):
|
||||
return "Unknown", "Unknown", "Unknown", None, None
|
||||
|
||||
try:
|
||||
response = requests.get(f"http://ip-api.com/json/{ip}", timeout=3)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("status") == "success":
|
||||
return (
|
||||
data.get("country", "Unknown"),
|
||||
data.get("regionName", "Unknown"),
|
||||
data.get("city", "Unknown"),
|
||||
data.get("lat"),
|
||||
data.get("lon")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch location for IP")
|
||||
|
||||
return "Unknown", "Unknown", "Unknown", None, None
|
||||
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
D1 — plans, and what a tenant is entitled to.
|
||||
|
||||
The gap this closes: `src/features/Landing/Pricing/index.tsx` advertises four
|
||||
priced plans — Starter, Professional, Elite, Custom — and the backend had never
|
||||
heard of a plan. Meanwhile `tenants` carried four limit columns set per tenant
|
||||
by hand, so two customers on the same advertised plan could silently have
|
||||
different limits and nothing would notice.
|
||||
|
||||
So this is not "add subscriptions". It is: **give the limits that already exist
|
||||
something to be derived from.**
|
||||
|
||||
Four tables:
|
||||
|
||||
- `Plan` what is sold
|
||||
- `PlanLimit` what it includes
|
||||
- `TenantSubscription` who is on what
|
||||
- `TenantLimitOverride` the negotiated exception
|
||||
|
||||
**DocQube does not compute a price.** `price_display` is a string for the
|
||||
pricing page, and `external_ref` on the subscription is whatever the system that
|
||||
takes the money calls this. Two systems that both own the money is a
|
||||
reconciliation problem you cannot test your way out of; one owns it, and this
|
||||
one owns *entitlement*.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Plan(Base):
|
||||
"""
|
||||
Something a customer can be on.
|
||||
|
||||
Not tenant-scoped: a plan is the catalogue, shared by everyone, the same way
|
||||
`accesses` is. That also keeps it out of `TENANT_TABLES` — it has no
|
||||
`tenant_id`, so neither the query listener nor RLS applies, which is correct
|
||||
for a catalogue and would be wrong for anything else here.
|
||||
"""
|
||||
|
||||
__tablename__ = "plans"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
price_amount: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
currency: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
||||
interval: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, default="monthly")
|
||||
|
||||
grace_period_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
notify_days_before_expiry: Mapped[int] = mapped_column(Integer, nullable=False, default=7)
|
||||
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
"""Whether it appears on the pricing page. A bespoke plan is not public."""
|
||||
|
||||
is_popular: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
"""Whether this is marked as the 'Popular' plan on the pricing page. Only one can be popular."""
|
||||
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
saas_plan_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(255), nullable=True, index=True, unique=True
|
||||
)
|
||||
"""The UUID of the corresponding SubscriptionPlan in the SaaS platform.
|
||||
NULL for plans that were created directly in DocQube.
|
||||
Used to upsert plan data when a webhook event arrives from SaaS.
|
||||
"""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=_utcnow, nullable=False
|
||||
)
|
||||
|
||||
limits: Mapped[list["PlanLimit"]] = relationship(
|
||||
"PlanLimit", back_populates="plan", cascade="all, delete-orphan"
|
||||
)
|
||||
roles: Mapped[list["PlanRole"]] = relationship(
|
||||
"PlanRole", back_populates="plan", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (UniqueConstraint("code", name="plans_code_key"),)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||
return f"<Plan {self.code!r}>"
|
||||
|
||||
|
||||
class PlanRole(Base):
|
||||
"""
|
||||
Mapping between a Plan and a System Role.
|
||||
"""
|
||||
__tablename__ = "plan_roles"
|
||||
|
||||
plan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("plans.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
role_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
|
||||
plan = relationship("Plan", back_populates="roles")
|
||||
|
||||
|
||||
class PlanLimit(Base):
|
||||
"""
|
||||
One limit a plan includes.
|
||||
|
||||
A row, not a column. The four that exist today became four columns on
|
||||
`tenants`, and a fifth would have been a fifth — which is how a schema turns
|
||||
into a changelog. Adding a limit is now a seed row.
|
||||
|
||||
`value` is a signed 64-bit integer and **-1 means unlimited**, matching the
|
||||
convention `tenants.envelope_limit` already uses. Null would have been the
|
||||
tidier spelling and would collide with "not set", which is a different
|
||||
thing.
|
||||
"""
|
||||
|
||||
__tablename__ = "plan_limits"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
plan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("plans.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
key: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
value: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
|
||||
plan: Mapped["Plan"] = relationship("Plan", back_populates="limits")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("plan_id", "key", name="uq_plan_limits_plan_key"),
|
||||
)
|
||||
|
||||
|
||||
class TenantSubscription(Base):
|
||||
"""
|
||||
Which plan a tenant is on, and what state that is in.
|
||||
|
||||
**`status` is not `tenant.is_active`.** One is commercial state, set by
|
||||
billing; the other is suspension, set by an operator. Merging them tells a
|
||||
customer the wrong thing about why they cannot get in, which is the worst
|
||||
kind of support call.
|
||||
"""
|
||||
|
||||
__tablename__ = "tenant_subscriptions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
plan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("plans.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||
"""active | trialing | past_due | cancelled | expired."""
|
||||
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
current_period_end: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
cancel_at_period_end: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
|
||||
external_ref: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
|
||||
"""
|
||||
Whatever the system that bills calls this — a Stripe id, a platform
|
||||
subscription id. DocQube never interprets it. It exists so that when the two
|
||||
systems disagree, support has something to compare.
|
||||
"""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=_utcnow, nullable=False
|
||||
)
|
||||
|
||||
plan: Mapped["Plan"] = relationship("Plan")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", name="uq_tenant_subscriptions_tenant"),
|
||||
Index("ix_tenant_subscriptions_status", "status"),
|
||||
)
|
||||
|
||||
|
||||
class TenantLimitOverride(Base):
|
||||
"""
|
||||
The negotiated exception: "Elite, but 500 GB, because the contract says so".
|
||||
|
||||
Deliberately a separate row rather than an edit to the tenant's copy of a
|
||||
plan limit. Editing loses the fact that it *was* an exception, and that is
|
||||
exactly what somebody needs to know at renewal.
|
||||
"""
|
||||
|
||||
__tablename__ = "tenant_limit_overrides"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
key: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
value: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
"""Why. An exception with no reason recorded becomes folklore."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "key", name="uq_tenant_limit_overrides"),
|
||||
)
|
||||
|
||||
|
||||
class PlanPriceHistory(Base):
|
||||
"""
|
||||
Tracks pricing changes over time for a plan.
|
||||
"""
|
||||
__tablename__ = "plan_price_history"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
plan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("plans.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
old_price: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
new_price: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
currency: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
||||
|
||||
changed_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
"""User ID who made the change."""
|
||||
|
||||
changed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
plan: Mapped["Plan"] = relationship("Plan")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PlanPriceHistory plan={self.plan_id} new_price={self.new_price}>"
|
||||
|
||||
|
||||
__all__ = ["Plan", "PlanLimit", "TenantSubscription", "TenantLimitOverride", "PlanPriceHistory"]
|
||||
@@ -0,0 +1,535 @@
|
||||
"""
|
||||
D4 — the plans, and what a tenant is entitled to.
|
||||
|
||||
Three audiences, three levels of access:
|
||||
|
||||
- **anyone**, including a signed-out visitor on the pricing page — the public
|
||||
catalogue. This is the endpoint that lets the landing page stop hardcoding
|
||||
what it sells;
|
||||
- **any signed-in user** — their own plan and their own limits. Not privileged
|
||||
information: it is what they bought;
|
||||
- **a superadmin** — change a tenant's plan, set an override.
|
||||
|
||||
Changing a plan is `superadmin.tenant.update`, not a tenant-level code. A
|
||||
customer moving themselves onto Elite would be a pricing decision made by the
|
||||
person who benefits from it.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.middleware.tenant import require_superadmin
|
||||
from app.modules.auth.dependencies.access_dependency import require_access
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.billing.schemas.billing_schema import (
|
||||
EntitlementOut,
|
||||
PlanOut,
|
||||
CreatePlanIn,
|
||||
UpdatePlanIn,
|
||||
PlanPriceHistoryOut,
|
||||
SetOverrideIn,
|
||||
SetPlanIn,
|
||||
SubscriptionOut,
|
||||
)
|
||||
from app.modules.billing.services.entitlement_service import EntitlementService
|
||||
|
||||
router = APIRouter(prefix="/billing", tags=["Billing"])
|
||||
|
||||
|
||||
def _plan_out(db: Session, plan) -> dict:
|
||||
from app.modules.billing.models.plan_model import PlanLimit, PlanRole
|
||||
from app.modules.auth.models.role_model import Role
|
||||
from app.modules.auth.models.role_access_model import RoleAccess
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
limits = {
|
||||
row.key: row.value
|
||||
for row in db.query(PlanLimit).filter(PlanLimit.plan_id == plan.id)
|
||||
}
|
||||
|
||||
roles_out = []
|
||||
plan_roles = db.query(PlanRole).filter(PlanRole.plan_id == plan.id).all()
|
||||
if plan_roles:
|
||||
role_ids = [pr.role_id for pr in plan_roles]
|
||||
roles = db.query(Role).options(selectinload(Role.role_accesses).selectinload(RoleAccess.access)).filter(Role.id.in_(role_ids)).all()
|
||||
for r in roles:
|
||||
roles_out.append({
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"access_codes": [ra.access.access_code for ra in r.role_accesses if ra.access]
|
||||
})
|
||||
|
||||
return {
|
||||
"id": plan.id,
|
||||
"code": plan.code,
|
||||
"name": plan.name,
|
||||
"description": plan.description,
|
||||
"price_amount": float(plan.price_amount) if plan.price_amount is not None else None,
|
||||
"currency": plan.currency,
|
||||
"interval": plan.interval,
|
||||
"grace_period_days": plan.grace_period_days,
|
||||
"notify_days_before_expiry": plan.notify_days_before_expiry,
|
||||
"is_public": plan.is_public,
|
||||
"is_popular": plan.is_popular,
|
||||
"sort_order": plan.sort_order,
|
||||
"limits": limits,
|
||||
"roles": roles_out,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/plans", response_model=List[PlanOut])
|
||||
def list_plans(db: Session = Depends(get_db)):
|
||||
"""
|
||||
The public catalogue.
|
||||
|
||||
**Deliberately unauthenticated.** It is what the pricing page shows to a
|
||||
visitor who has not signed up yet, and requiring a token would defeat the
|
||||
purpose. It exposes only what is already printed on that page — names,
|
||||
prices and limits — and no tenant is named.
|
||||
|
||||
This is the endpoint that closes the original gap: the page advertised four
|
||||
priced plans and the backend had never heard of a plan.
|
||||
"""
|
||||
from app.modules.billing.models.plan_model import Plan
|
||||
|
||||
plans = (
|
||||
db.query(Plan)
|
||||
.filter(Plan.is_public.is_(True))
|
||||
.order_by(Plan.sort_order, Plan.name)
|
||||
.all()
|
||||
)
|
||||
return [_plan_out(db, plan) for plan in plans]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/plans/admin",
|
||||
response_model=List[PlanOut],
|
||||
dependencies=[require_access("superadmin.tenant.read"), Depends(require_superadmin)]
|
||||
)
|
||||
def list_plans_admin(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Returns all plans, including inactive (is_public=False) ones.
|
||||
Only accessible by superadmins.
|
||||
"""
|
||||
from app.modules.billing.models.plan_model import Plan
|
||||
|
||||
plans = (
|
||||
db.query(Plan)
|
||||
.order_by(Plan.sort_order, Plan.name)
|
||||
.all()
|
||||
)
|
||||
return [_plan_out(db, plan) for plan in plans]
|
||||
|
||||
|
||||
@router.get("/me", response_model=EntitlementOut)
|
||||
def my_entitlement(
|
||||
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
What this caller's workspace is entitled to.
|
||||
|
||||
Not gated on an admin code: a user asking what their own workspace includes
|
||||
is asking what they bought.
|
||||
"""
|
||||
service = EntitlementService(db)
|
||||
tenant = getattr(user, "tenant", None)
|
||||
subscription = service.subscription(tenant)
|
||||
plan = subscription.plan if subscription is not None else None
|
||||
|
||||
return {
|
||||
"plan": _plan_out(db, plan) if plan is not None else None,
|
||||
"status": subscription.status if subscription is not None else None,
|
||||
"current_period_end": (
|
||||
subscription.current_period_end if subscription is not None else None
|
||||
),
|
||||
"cancel_at_period_end": (
|
||||
subscription.cancel_at_period_end if subscription is not None else False
|
||||
),
|
||||
"subscription_active": service.is_active(tenant),
|
||||
"access_level": service.access_level(tenant),
|
||||
"limits": service.all_limits(tenant),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tenants/{tenant_id}/subscription",
|
||||
response_model=SubscriptionOut,
|
||||
dependencies=[require_access("superadmin.tenant.read"), Depends(require_superadmin)],
|
||||
)
|
||||
def get_tenant_subscription(
|
||||
tenant_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
from app.modules.billing.models.plan_model import TenantSubscription
|
||||
|
||||
row = (
|
||||
db.query(TenantSubscription)
|
||||
.filter(TenantSubscription.tenant_id == tenant_id)
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="No subscription for that tenant")
|
||||
return row
|
||||
|
||||
|
||||
@router.put(
|
||||
"/tenants/{tenant_id}/subscription",
|
||||
response_model=SubscriptionOut,
|
||||
dependencies=[require_access("superadmin.tenant.update"), Depends(require_superadmin)],
|
||||
)
|
||||
def set_tenant_plan(
|
||||
tenant_id: uuid.UUID,
|
||||
payload: SetPlanIn,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Move a tenant onto a plan.
|
||||
|
||||
A superadmin decision. A tenant admin moving themselves onto Elite would be
|
||||
a pricing decision made by the person who benefits from it.
|
||||
"""
|
||||
from app.modules.billing.models.plan_model import Plan, TenantSubscription
|
||||
|
||||
plan = db.query(Plan).filter(Plan.code == payload.plan_code).first()
|
||||
if plan is None:
|
||||
raise HTTPException(status_code=404, detail="No such plan")
|
||||
|
||||
row = (
|
||||
db.query(TenantSubscription)
|
||||
.filter(TenantSubscription.tenant_id == tenant_id)
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
row = TenantSubscription(tenant_id=tenant_id, plan_id=plan.id)
|
||||
db.add(row)
|
||||
else:
|
||||
row.plan_id = plan.id
|
||||
|
||||
if payload.status is not None:
|
||||
row.status = payload.status
|
||||
if payload.external_ref is not None:
|
||||
row.external_ref = payload.external_ref
|
||||
if payload.current_period_end is not None:
|
||||
row.current_period_end = payload.current_period_end
|
||||
|
||||
# Auto-assign the first plan role to the tenant's first user
|
||||
from app.modules.billing.models.plan_model import PlanRole
|
||||
from app.modules.auth.models.user_model import User
|
||||
|
||||
plan_roles = db.query(PlanRole).filter(PlanRole.plan_id == plan.id).all()
|
||||
if plan_roles:
|
||||
first_plan_role_id = plan_roles[0].role_id
|
||||
# Find the first user in this tenant (usually the owner)
|
||||
tenant_user = db.query(User).filter(User.tenant_id == tenant_id).order_by(User.id.asc()).first()
|
||||
if tenant_user:
|
||||
tenant_user.role_id = first_plan_role_id
|
||||
|
||||
db.flush()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
@router.put(
|
||||
"/tenants/{tenant_id}/overrides",
|
||||
response_model=EntitlementOut,
|
||||
dependencies=[require_access("superadmin.tenant.update"), Depends(require_superadmin)],
|
||||
)
|
||||
def set_override(
|
||||
tenant_id: uuid.UUID,
|
||||
payload: SetOverrideIn,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
The negotiated exception — "Elite, but 500 GB, because the contract says so".
|
||||
|
||||
Stored as its own row rather than by editing the plan, so the plan stays
|
||||
honest and the exception stays visible at renewal. `note` is not optional in
|
||||
spirit: an exception with no reason recorded becomes folklore.
|
||||
"""
|
||||
from app.modules.billing.models.plan_model import TenantLimitOverride
|
||||
from app.modules.billing.services.entitlement_service import DEFAULTS
|
||||
from app.modules.tenant.models.tenant_model import Tenant
|
||||
|
||||
if payload.key not in DEFAULTS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown limit. Known limits: {sorted(DEFAULTS)}.",
|
||||
)
|
||||
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
row = (
|
||||
db.query(TenantLimitOverride)
|
||||
.filter(
|
||||
TenantLimitOverride.tenant_id == tenant_id,
|
||||
TenantLimitOverride.key == payload.key,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
row = TenantLimitOverride(
|
||||
tenant_id=tenant_id, key=payload.key, value=payload.value, note=payload.note
|
||||
)
|
||||
db.add(row)
|
||||
else:
|
||||
row.value = payload.value
|
||||
row.note = payload.note
|
||||
db.flush()
|
||||
|
||||
service = EntitlementService(db)
|
||||
subscription = service.subscription(tenant)
|
||||
plan = subscription.plan if subscription is not None else None
|
||||
return {
|
||||
"plan": _plan_out(db, plan) if plan is not None else None,
|
||||
"status": subscription.status if subscription is not None else None,
|
||||
"current_period_end": (
|
||||
subscription.current_period_end if subscription is not None else None
|
||||
),
|
||||
"cancel_at_period_end": (
|
||||
subscription.cancel_at_period_end if subscription is not None else False
|
||||
),
|
||||
"subscription_active": service.is_active(tenant),
|
||||
"access_level": service.access_level(tenant),
|
||||
"limits": service.all_limits(tenant),
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/plans",
|
||||
response_model=PlanOut,
|
||||
dependencies=[require_access("superadmin.tenant.update"), Depends(require_superadmin)],
|
||||
)
|
||||
def create_plan(
|
||||
payload: CreatePlanIn,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
from app.modules.billing.models.plan_model import Plan, PlanLimit, PlanPriceHistory
|
||||
|
||||
plan = db.query(Plan).filter(Plan.code == payload.code).first()
|
||||
if plan is not None:
|
||||
raise HTTPException(status_code=400, detail="Plan code already exists")
|
||||
|
||||
plan = Plan(
|
||||
code=payload.code,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
price_amount=payload.price_amount,
|
||||
currency=payload.currency,
|
||||
interval=payload.interval,
|
||||
grace_period_days=payload.grace_period_days,
|
||||
notify_days_before_expiry=payload.notify_days_before_expiry,
|
||||
is_public=payload.is_public,
|
||||
is_popular=payload.is_popular,
|
||||
sort_order=payload.sort_order,
|
||||
)
|
||||
db.add(plan)
|
||||
|
||||
if payload.is_popular:
|
||||
db.query(Plan).filter(Plan.is_popular == True).update({"is_popular": False})
|
||||
|
||||
db.flush()
|
||||
|
||||
if payload.roles:
|
||||
from app.modules.auth.models.role_model import Role
|
||||
from app.modules.auth.models.role_access_model import RoleAccess
|
||||
from app.modules.auth.models.access_model import Access
|
||||
from app.modules.billing.models.plan_model import PlanRole
|
||||
for role_data in payload.roles:
|
||||
new_role = Role(
|
||||
name=role_data.name,
|
||||
description=role_data.description,
|
||||
is_system=True,
|
||||
)
|
||||
db.add(new_role)
|
||||
db.flush()
|
||||
db.add(PlanRole(plan_id=plan.id, role_id=new_role.id))
|
||||
for access_code in role_data.access_codes:
|
||||
acc = db.query(Access).filter(Access.access_code == access_code).first()
|
||||
if acc:
|
||||
db.add(RoleAccess(role_id=new_role.id, access_id=acc.id))
|
||||
|
||||
for key, value in payload.limits.items():
|
||||
db.add(PlanLimit(plan_id=plan.id, key=key, value=value))
|
||||
|
||||
# Log to price history
|
||||
if payload.price_amount is not None:
|
||||
history = PlanPriceHistory(
|
||||
plan_id=plan.id,
|
||||
old_price=None,
|
||||
new_price=payload.price_amount,
|
||||
currency=payload.currency,
|
||||
changed_by=user.id
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return _plan_out(db, plan)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/plans/{plan_id}",
|
||||
response_model=PlanOut,
|
||||
dependencies=[require_access("superadmin.tenant.update"), Depends(require_superadmin)],
|
||||
)
|
||||
def update_plan(
|
||||
plan_id: uuid.UUID,
|
||||
payload: UpdatePlanIn,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
from app.modules.billing.models.plan_model import Plan, PlanLimit, PlanPriceHistory
|
||||
|
||||
plan = db.query(Plan).filter(Plan.id == plan_id).first()
|
||||
if plan is None:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
|
||||
old_price = plan.price_amount
|
||||
old_currency = plan.currency
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if field in ["limits", "roles"]:
|
||||
continue
|
||||
setattr(plan, field, value)
|
||||
|
||||
if payload.is_popular is True:
|
||||
db.query(Plan).filter(Plan.id != plan.id, Plan.is_popular == True).update({"is_popular": False})
|
||||
|
||||
|
||||
if payload.limits is not None:
|
||||
db.query(PlanLimit).filter(PlanLimit.plan_id == plan.id).delete()
|
||||
for key, value in payload.limits.items():
|
||||
db.add(PlanLimit(plan_id=plan.id, key=key, value=value))
|
||||
|
||||
if payload.roles is not None:
|
||||
from app.modules.auth.models.role_model import Role
|
||||
from app.modules.auth.models.role_access_model import RoleAccess
|
||||
from app.modules.auth.models.access_model import Access
|
||||
from app.modules.billing.models.plan_model import PlanRole
|
||||
|
||||
existing_plan_roles = db.query(PlanRole).filter(PlanRole.plan_id == plan.id).all()
|
||||
existing_roles = {
|
||||
r.name: r
|
||||
for r in db.query(Role).filter(Role.id.in_([pr.role_id for pr in existing_plan_roles])).all()
|
||||
} if existing_plan_roles else {}
|
||||
|
||||
processed_role_names = set()
|
||||
|
||||
for role_data in payload.roles:
|
||||
processed_role_names.add(role_data.name)
|
||||
if role_data.name in existing_roles:
|
||||
existing_role = existing_roles[role_data.name]
|
||||
existing_role.description = role_data.description
|
||||
|
||||
db.query(RoleAccess).filter(RoleAccess.role_id == existing_role.id).delete()
|
||||
for access_code in role_data.access_codes:
|
||||
acc = db.query(Access).filter(Access.access_code == access_code).first()
|
||||
if acc:
|
||||
db.add(RoleAccess(role_id=existing_role.id, access_id=acc.id))
|
||||
else:
|
||||
new_role = Role(
|
||||
name=role_data.name,
|
||||
description=role_data.description,
|
||||
is_system=True,
|
||||
)
|
||||
db.add(new_role)
|
||||
db.flush()
|
||||
db.add(PlanRole(plan_id=plan.id, role_id=new_role.id))
|
||||
for access_code in role_data.access_codes:
|
||||
acc = db.query(Access).filter(Access.access_code == access_code).first()
|
||||
if acc:
|
||||
db.add(RoleAccess(role_id=new_role.id, access_id=acc.id))
|
||||
|
||||
for name, role in existing_roles.items():
|
||||
if name not in processed_role_names:
|
||||
db.query(PlanRole).filter(PlanRole.plan_id == plan.id, PlanRole.role_id == role.id).delete()
|
||||
db.delete(role)
|
||||
|
||||
# Log to price history if price or currency changed
|
||||
if plan.price_amount != old_price or plan.currency != old_currency:
|
||||
history = PlanPriceHistory(
|
||||
plan_id=plan.id,
|
||||
old_price=old_price,
|
||||
new_price=plan.price_amount,
|
||||
currency=plan.currency,
|
||||
changed_by=user.id
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return _plan_out(db, plan)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/plans/{plan_id}",
|
||||
dependencies=[require_access("superadmin.tenant.update"), Depends(require_superadmin)],
|
||||
)
|
||||
def delete_plan(
|
||||
plan_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
from app.modules.billing.models.plan_model import Plan
|
||||
|
||||
plan = db.query(Plan).filter(Plan.id == plan_id).first()
|
||||
if plan is None:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
|
||||
try:
|
||||
db.delete(plan)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail="Cannot delete plan in use by tenants")
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/plans/{plan_id}/price-history",
|
||||
response_model=List[PlanPriceHistoryOut],
|
||||
dependencies=[require_access("superadmin.tenant.read"), Depends(require_superadmin)],
|
||||
)
|
||||
def get_plan_price_history(
|
||||
plan_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
from app.modules.billing.models.plan_model import PlanPriceHistory
|
||||
|
||||
history = (
|
||||
db.query(PlanPriceHistory)
|
||||
.filter(PlanPriceHistory.plan_id == plan_id)
|
||||
.order_by(PlanPriceHistory.changed_at.desc())
|
||||
.all()
|
||||
)
|
||||
return history
|
||||
|
||||
|
||||
@router.post(
|
||||
"/plans/reorder",
|
||||
dependencies=[require_access("superadmin.tenant.update"), Depends(require_superadmin)],
|
||||
)
|
||||
def reorder_plans(
|
||||
plan_ids: List[uuid.UUID],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.modules.billing.models.plan_model import Plan
|
||||
for index, plan_id in enumerate(plan_ids):
|
||||
db.query(Plan).filter(Plan.id == plan_id).update({"sort_order": index + 1})
|
||||
db.commit()
|
||||
return {"message": "Plans reordered successfully"}
|
||||
@@ -0,0 +1,146 @@
|
||||
"""D4 — the billing contract."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class PlanRoleIn(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
access_codes: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PlanRoleOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
access_codes: List[str]
|
||||
|
||||
|
||||
class PlanOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
code: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
currency: Optional[str] = None
|
||||
is_public: bool
|
||||
is_popular: bool
|
||||
sort_order: int
|
||||
interval: Optional[str] = "monthly"
|
||||
price_amount: Optional[float] = None
|
||||
grace_period_days: int = 0
|
||||
notify_days_before_expiry: int = 7
|
||||
limits: Dict[str, int]
|
||||
"""
|
||||
Every limit the plan includes, keyed by name. A dict rather than named
|
||||
fields, so adding a limit is a seed row rather than a schema change and a
|
||||
frontend deploy.
|
||||
"""
|
||||
roles: List[PlanRoleOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CreatePlanIn(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
price_amount: Optional[float] = None
|
||||
currency: Optional[str] = None
|
||||
interval: Optional[str] = "monthly"
|
||||
is_public: bool = True
|
||||
is_popular: bool = False
|
||||
sort_order: int = 0
|
||||
grace_period_days: int = 0
|
||||
notify_days_before_expiry: int = 7
|
||||
limits: Dict[str, int] = {}
|
||||
roles: List[PlanRoleIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdatePlanIn(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
price_amount: Optional[float] = None
|
||||
currency: Optional[str] = None
|
||||
interval: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
is_popular: Optional[bool] = None
|
||||
sort_order: Optional[int] = None
|
||||
grace_period_days: Optional[int] = None
|
||||
notify_days_before_expiry: Optional[int] = None
|
||||
limits: Optional[Dict[str, int]] = None
|
||||
roles: Optional[List[PlanRoleIn]] = None
|
||||
|
||||
|
||||
class PlanPriceHistoryOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
plan_id: uuid.UUID
|
||||
old_price: Optional[float] = None
|
||||
new_price: Optional[float] = None
|
||||
currency: Optional[str] = None
|
||||
changed_by: Optional[int] = None
|
||||
changed_at: datetime
|
||||
|
||||
|
||||
class SubscriptionOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
plan_id: uuid.UUID
|
||||
status: str
|
||||
started_at: datetime
|
||||
current_period_end: Optional[datetime] = None
|
||||
cancel_at_period_end: bool
|
||||
external_ref: Optional[str] = None
|
||||
|
||||
|
||||
class EntitlementOut(BaseModel):
|
||||
"""What a workspace is entitled to, resolved."""
|
||||
|
||||
plan: Optional[PlanOut] = None
|
||||
status: Optional[str] = None
|
||||
current_period_end: Optional[datetime] = None
|
||||
cancel_at_period_end: bool = False
|
||||
|
||||
subscription_active: bool
|
||||
"""
|
||||
Commercial state only. Deliberately says nothing about whether the tenant is
|
||||
suspended — that is an operator's act, and merging the two tells a customer
|
||||
the wrong thing about why they cannot get in.
|
||||
"""
|
||||
|
||||
access_level: str
|
||||
"""
|
||||
`full` or `read_only` — the same value the middleware enforces on.
|
||||
|
||||
Reported rather than left to be inferred. Without it a client has to
|
||||
reconstruct the rule from `status` and `subscription_active`, which means
|
||||
the decision about what a lapsed customer may do exists twice, in two
|
||||
languages, and drifts the first time the rule gains a case. The middleware
|
||||
already computes this to decide whether to answer 402; this is that answer.
|
||||
"""
|
||||
|
||||
limits: Dict[str, int]
|
||||
"""Resolved: override, else plan, else default. `-1` means unlimited."""
|
||||
|
||||
|
||||
class SetPlanIn(BaseModel):
|
||||
plan_code: str
|
||||
status: Optional[str] = None
|
||||
external_ref: Optional[str] = None
|
||||
current_period_end: Optional[datetime] = None
|
||||
|
||||
|
||||
class SetOverrideIn(BaseModel):
|
||||
key: str
|
||||
value: int = Field(description="-1 means unlimited")
|
||||
note: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Why. An exception with no reason recorded becomes folklore.",
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import SessionLocal
|
||||
from app.modules.billing.models.plan_model import TenantSubscription, Plan
|
||||
# Assuming you have a notification service, e.g. from app.modules.notifications...
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def evaluate_subscriptions():
|
||||
"""
|
||||
Evaluates all active subscriptions and updates their statuses or sends notifications
|
||||
based on expiry and grace periods.
|
||||
This should be run daily via a cron job or Celery beat.
|
||||
"""
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
subscriptions = db.query(TenantSubscription).filter(
|
||||
TenantSubscription.status.in_(["active", "trialing", "grace", "past_due"])
|
||||
).all()
|
||||
|
||||
for sub in subscriptions:
|
||||
plan = sub.plan
|
||||
if not sub.current_period_end:
|
||||
continue
|
||||
|
||||
days_until_expiry = (sub.current_period_end - now).days
|
||||
|
||||
if sub.status in ["active", "trialing"]:
|
||||
if 0 < days_until_expiry <= plan.notify_days_before_expiry:
|
||||
# TODO: Trigger SUBSCRIPTION_EXPIRING_SOON notification
|
||||
logger.info(f"Subscription {sub.id} expiring in {days_until_expiry} days. Sending notification.")
|
||||
|
||||
elif days_until_expiry <= 0:
|
||||
# Expired. Move to grace period or past_due
|
||||
if plan.grace_period_days > 0:
|
||||
sub.status = "grace"
|
||||
logger.info(f"Subscription {sub.id} expired. Moving to grace period.")
|
||||
# TODO: Trigger SUBSCRIPTION_GRACE_PERIOD notification
|
||||
else:
|
||||
sub.status = "past_due"
|
||||
logger.info(f"Subscription {sub.id} expired. Moving to past_due.")
|
||||
# TODO: Trigger SUBSCRIPTION_EXPIRED notification
|
||||
|
||||
elif sub.status == "grace":
|
||||
days_since_expiry = -days_until_expiry
|
||||
if days_since_expiry > plan.grace_period_days:
|
||||
sub.status = "past_due"
|
||||
logger.info(f"Subscription {sub.id} grace period ended. Moving to past_due.")
|
||||
# TODO: Trigger SUBSCRIPTION_EXPIRED notification
|
||||
else:
|
||||
# Still in grace period, maybe send another reminder?
|
||||
pass
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Error evaluating subscriptions: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
D2 — what is this tenant entitled to?
|
||||
|
||||
One function that every enforcement point asks, instead of four columns that
|
||||
four places read.
|
||||
|
||||
limit(tenant, key) =
|
||||
TenantLimitOverride the negotiated exception
|
||||
else PlanLimit what was actually sold
|
||||
else the tenant column the transition fallback, one release only
|
||||
else DEFAULTS the built-in floor
|
||||
|
||||
**It fails open, and that is deliberate.** Everywhere else in this codebase the
|
||||
answer is fail closed — the tenant filter denies without context, the scope
|
||||
resolver returns an empty set, `require_access` refuses. Those are about
|
||||
*authority*, where the risk is showing somebody another tenant's data.
|
||||
|
||||
This is about *capacity*, where the risk is refusing a customer their own. A
|
||||
tenant with no subscription row is an operational mistake — a failed backfill, a
|
||||
half-finished signup — and locking a paying customer out of their own documents
|
||||
is a worse answer to that mistake than serving them. Different question,
|
||||
different default.
|
||||
|
||||
The one thing it will not do is guess at a key it does not know. An unknown key
|
||||
raises rather than returning zero, because "limit of zero" and "I have never
|
||||
heard of that limit" are different, and only one of them should silently switch a
|
||||
feature off.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
CAPACITY_DEFAULTS: dict[str, int] = {
|
||||
"storage_bytes": 1024 ** 3,
|
||||
"seats": -1,
|
||||
"chat_tokens_daily": 1_000_000,
|
||||
"envelopes": -1,
|
||||
}
|
||||
"""
|
||||
How much of something. **Fails open**: a resolution failure serves the default
|
||||
rather than zero, because the risk here is refusing a customer their own data.
|
||||
"""
|
||||
|
||||
FEATURE_DEFAULTS: dict[str, int] = {
|
||||
}
|
||||
"""
|
||||
Whether something is switched on at all. **Fails closed**: 0 unless a plan says
|
||||
otherwise.
|
||||
|
||||
Separated from capacity because the two want opposite defaults and sharing a
|
||||
dict would silently give features the capacity behaviour. On a resolution
|
||||
failure, "serve them a gigabyte" is generous; "grant them the Elite-only feature"
|
||||
is giving the product away. Splitting now costs nothing; splitting after the
|
||||
first feature flag ships means auditing every call site.
|
||||
"""
|
||||
|
||||
DEFAULTS: dict[str, int] = {**CAPACITY_DEFAULTS, **FEATURE_DEFAULTS}
|
||||
|
||||
LEGACY_COLUMNS: dict[str, str] = {
|
||||
"storage_bytes": "storage_quota_bytes",
|
||||
"seats": "max_users_allowed",
|
||||
"chat_tokens_daily": "chat_token_daily_limit",
|
||||
"envelopes": "envelope_limit",
|
||||
}
|
||||
|
||||
UNLIMITED = -1
|
||||
|
||||
_cache: ContextVar[Optional[dict]] = ContextVar("entitlement_cache", default=None)
|
||||
|
||||
|
||||
def reset_entitlement_cache() -> None:
|
||||
_cache.set({})
|
||||
|
||||
|
||||
def _cached(key: str, compute):
|
||||
store = _cache.get()
|
||||
if store is None:
|
||||
return compute()
|
||||
if key not in store:
|
||||
store[key] = compute()
|
||||
return store[key]
|
||||
|
||||
|
||||
class EntitlementService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
|
||||
def limit(self, tenant, key: str) -> int:
|
||||
"""The effective limit for *key*. `-1` means unlimited."""
|
||||
if key not in DEFAULTS:
|
||||
raise KeyError(
|
||||
f"Unknown entitlement key {key!r}. Known keys: "
|
||||
f"{sorted(DEFAULTS)}. A typo must not read as a limit of zero."
|
||||
)
|
||||
if tenant is None:
|
||||
return DEFAULTS[key]
|
||||
|
||||
return _cached(f"limit:{tenant.id}:{key}", lambda: self._resolve(tenant, key))
|
||||
|
||||
def is_unlimited(self, tenant, key: str) -> bool:
|
||||
return self.limit(tenant, key) == UNLIMITED
|
||||
|
||||
def within(self, tenant, key: str, used: int) -> bool:
|
||||
"""Whether *used* is inside the limit. Unlimited is always inside."""
|
||||
allowed = self.limit(tenant, key)
|
||||
return allowed == UNLIMITED or used <= allowed
|
||||
|
||||
def all_limits(self, tenant) -> dict[str, int]:
|
||||
"""Every known limit, for a settings screen or an API response."""
|
||||
return {key: self.limit(tenant, key) for key in DEFAULTS}
|
||||
|
||||
|
||||
def subscription(self, tenant):
|
||||
from app.modules.billing.models.plan_model import TenantSubscription
|
||||
|
||||
if tenant is None:
|
||||
return None
|
||||
return (
|
||||
self.db.query(TenantSubscription)
|
||||
.filter(TenantSubscription.tenant_id == tenant.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def is_active(self, tenant) -> bool:
|
||||
"""
|
||||
Whether the *subscription* is in good standing.
|
||||
|
||||
Deliberately says nothing about `tenant.is_active`, which is suspension —
|
||||
an operator's act, not a commercial state. Merging them tells a customer
|
||||
the wrong thing about why they cannot get in, and "we paid, why are we
|
||||
locked out?" is the support call that follows.
|
||||
|
||||
A tenant with no subscription row is treated as active, for the same
|
||||
reason the limits fall back rather than failing closed.
|
||||
"""
|
||||
row = self.subscription(tenant)
|
||||
if row is None:
|
||||
return True
|
||||
return row.status in {"active", "trialing"}
|
||||
|
||||
def plan(self, tenant):
|
||||
row = self.subscription(tenant)
|
||||
return row.plan if row is not None else None
|
||||
|
||||
def access_level(self, tenant) -> str:
|
||||
"""
|
||||
What a lapsed subscription still permits: `full`, `read_only` or `none`.
|
||||
|
||||
Ported from the base, which is also how mature systems behave: an
|
||||
expired customer is not locked out of their own documents, they are
|
||||
stopped from creating more. Locking them out is hostile, makes export
|
||||
impossible, and is bad for renewals — the customer most likely to come
|
||||
back is the one who can still see what they would be coming back to.
|
||||
|
||||
A tenant with no subscription row gets `full`, for the same reason the
|
||||
limits fall back rather than failing closed.
|
||||
"""
|
||||
row = self.subscription(tenant)
|
||||
if row is None:
|
||||
return "full"
|
||||
if row.status in {"active", "trialing", "grace"}:
|
||||
return "full"
|
||||
if row.status in {"past_due"}:
|
||||
return "read_only"
|
||||
return "read_only"
|
||||
|
||||
|
||||
def _resolve(self, tenant, key: str) -> int:
|
||||
from app.modules.billing.models.plan_model import (
|
||||
PlanLimit,
|
||||
TenantLimitOverride,
|
||||
TenantSubscription,
|
||||
)
|
||||
|
||||
override = (
|
||||
self.db.query(TenantLimitOverride.value)
|
||||
.filter(
|
||||
TenantLimitOverride.tenant_id == tenant.id,
|
||||
TenantLimitOverride.key == key,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if override is not None:
|
||||
return int(override[0])
|
||||
|
||||
row = (
|
||||
self.db.query(PlanLimit.value)
|
||||
.join(TenantSubscription, TenantSubscription.plan_id == PlanLimit.plan_id)
|
||||
.filter(
|
||||
TenantSubscription.tenant_id == tenant.id,
|
||||
PlanLimit.key == key,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if row is not None:
|
||||
return int(row[0])
|
||||
|
||||
column = LEGACY_COLUMNS.get(key)
|
||||
if column is not None:
|
||||
value = getattr(tenant, column, None)
|
||||
if value is not None:
|
||||
return int(value)
|
||||
|
||||
return DEFAULTS[key]
|
||||
|
||||
def has_feature(self, tenant, key: str) -> bool:
|
||||
"""
|
||||
Whether a feature is switched on for this tenant.
|
||||
|
||||
Separate from `limit()` so the caller cannot accidentally read a
|
||||
feature with capacity semantics — `limit(tenant, 'feature_sso') > 0`
|
||||
would work today and would quietly grant the feature the moment
|
||||
resolution failed.
|
||||
"""
|
||||
if key not in FEATURE_DEFAULTS:
|
||||
raise KeyError(
|
||||
f"Unknown feature {key!r}. Known features: {sorted(FEATURE_DEFAULTS)}."
|
||||
)
|
||||
return self.limit(tenant, key) != 0
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EntitlementService",
|
||||
"DEFAULTS",
|
||||
"CAPACITY_DEFAULTS",
|
||||
"FEATURE_DEFAULTS",
|
||||
"LEGACY_COLUMNS",
|
||||
"UNLIMITED",
|
||||
"reset_entitlement_cache",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user