transcription/backend/auth/database.py
keboss-m 8df14e3102 Add multi-tenant auth with org projects, roles, and personal workspaces.
JWT login, org-scoped storage and RAG, admin/director/user roles, user-owned projects, login UI, and legacy data migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 18:54:25 +03:00

408 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""SQLite persistence for organizations, users, projects."""
import os
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from backend.auth.security import hash_password
DB_PATH = Path("data/transcriba.db")
_SCHEMA = """
CREATE TABLE IF NOT EXISTS organizations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER NOT NULL,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
UNIQUE(org_id, username),
FOREIGN KEY (org_id) REFERENCES organizations(id)
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER NOT NULL,
slug TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(org_id, slug),
FOREIGN KEY (org_id) REFERENCES organizations(id)
);
CREATE TABLE IF NOT EXISTS user_projects (
user_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
PRIMARY KEY (user_id, project_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
"""
def _utcnow() -> str:
return datetime.now(timezone.utc).isoformat()
def get_db_path(config: Optional[dict] = None) -> Path:
env_path = os.getenv("AUTH_DATABASE_PATH")
if env_path:
return Path(env_path)
if config:
custom = config.get("auth", {}).get("database_path")
if custom:
return Path(custom)
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
return DB_PATH
@contextmanager
def get_connection(config: Optional[dict] = None):
db_path = get_db_path(config)
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init_db(config: Optional[dict] = None) -> None:
with get_connection(config) as conn:
conn.executescript(_SCHEMA)
migrate_schema(config)
def migrate_schema(config: Optional[dict] = None) -> None:
"""Добавляет owner_user_id для личных проектов пользователей."""
with get_connection(config) as conn:
cols = {row[1] for row in conn.execute("PRAGMA table_info(projects)").fetchall()}
if "owner_user_id" not in cols:
conn.execute(
"ALTER TABLE projects ADD COLUMN owner_user_id INTEGER REFERENCES users(id)"
)
print("[Auth] Migration: projects.owner_user_id added")
def count_users(config: Optional[dict] = None) -> int:
with get_connection(config) as conn:
row = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone()
return int(row["c"])
def get_org_by_slug(slug: str, config: Optional[dict] = None) -> Optional[Dict[str, Any]]:
with get_connection(config) as conn:
row = conn.execute(
"SELECT id, slug, name, created_at FROM organizations WHERE slug = ?",
(slug,),
).fetchone()
return dict(row) if row else None
def create_organization(slug: str, name: str, config: Optional[dict] = None) -> Dict[str, Any]:
with get_connection(config) as conn:
cur = conn.execute(
"INSERT INTO organizations (slug, name, created_at) VALUES (?, ?, ?)",
(slug, name, _utcnow()),
)
org_id = cur.lastrowid
row = conn.execute(
"SELECT id, slug, name, created_at FROM organizations WHERE id = ?",
(org_id,),
).fetchone()
return dict(row)
def create_user(
org_id: int,
username: str,
password: str,
role: str = "user",
config: Optional[dict] = None,
) -> Dict[str, Any]:
with get_connection(config) as conn:
cur = conn.execute(
"""
INSERT INTO users (org_id, username, password_hash, role, is_active, created_at)
VALUES (?, ?, ?, ?, 1, ?)
""",
(org_id, username, hash_password(password), role, _utcnow()),
)
user_id = cur.lastrowid
row = conn.execute(
"SELECT id, org_id, username, role, is_active, created_at FROM users WHERE id = ?",
(user_id,),
).fetchone()
return dict(row)
def get_user_by_username(org_id: int, username: str, config: Optional[dict] = None) -> Optional[Dict[str, Any]]:
with get_connection(config) as conn:
row = conn.execute(
"""
SELECT u.id, u.org_id, u.username, u.password_hash, u.role, u.is_active,
o.slug AS org_slug, o.name AS org_name
FROM users u
JOIN organizations o ON o.id = u.org_id
WHERE u.org_id = ? AND u.username = ?
""",
(org_id, username),
).fetchone()
return dict(row) if row else None
def get_user_by_id(user_id: int, config: Optional[dict] = None) -> Optional[Dict[str, Any]]:
with get_connection(config) as conn:
row = conn.execute(
"""
SELECT u.id, u.org_id, u.username, u.password_hash, u.role, u.is_active,
o.slug AS org_slug, o.name AS org_name
FROM users u
JOIN organizations o ON o.id = u.org_id
WHERE u.id = ?
""",
(user_id,),
).fetchone()
return dict(row) if row else None
def list_users(org_id: int, config: Optional[dict] = None) -> List[Dict[str, Any]]:
with get_connection(config) as conn:
rows = conn.execute(
"""
SELECT id, org_id, username, role, is_active, created_at
FROM users WHERE org_id = ? ORDER BY username
""",
(org_id,),
).fetchall()
users = [dict(r) for r in rows]
for user in users:
user["projects"] = get_user_project_slugs(user["id"], config=config, conn=conn)
return users
def get_user_project_slugs(
user_id: int,
config: Optional[dict] = None,
conn: Optional[sqlite3.Connection] = None,
) -> List[str]:
"""Org-wide projects assigned to user by admin (not personal)."""
query = """
SELECT p.slug FROM projects p
JOIN user_projects up ON up.project_id = p.id
WHERE up.user_id = ? AND p.owner_user_id IS NULL
ORDER BY p.slug
"""
def _fetch(connection: sqlite3.Connection) -> List[str]:
rows = connection.execute(query, (user_id,)).fetchall()
return [row["slug"] for row in rows]
if conn is not None:
return _fetch(conn)
with get_connection(config) as connection:
return _fetch(connection)
def get_owned_project_slugs(
user_id: int,
config: Optional[dict] = None,
) -> List[str]:
with get_connection(config) as conn:
rows = conn.execute(
"""
SELECT slug FROM projects
WHERE owner_user_id = ?
ORDER BY slug
""",
(user_id,),
).fetchall()
return [row["slug"] for row in rows]
def _row_to_project(row: sqlite3.Row) -> Dict[str, Any]:
data = dict(row)
data["scope"] = "personal" if data.get("owner_user_id") else "org"
return data
def create_project(
org_id: int,
slug: str,
name: str,
owner_user_id: Optional[int] = None,
config: Optional[dict] = None,
) -> Dict[str, Any]:
slug = slug.strip().lower()
with get_connection(config) as conn:
cur = conn.execute(
"""
INSERT INTO projects (org_id, slug, name, owner_user_id, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(org_id, slug, name, owner_user_id, _utcnow()),
)
project_id = cur.lastrowid
row = conn.execute(
"""
SELECT id, org_id, slug, name, owner_user_id, created_at
FROM projects WHERE id = ?
""",
(project_id,),
).fetchone()
return _row_to_project(row)
def list_projects(org_id: int, config: Optional[dict] = None) -> List[Dict[str, Any]]:
with get_connection(config) as conn:
rows = conn.execute(
"""
SELECT id, org_id, slug, name, owner_user_id, created_at
FROM projects WHERE org_id = ? ORDER BY slug
""",
(org_id,),
).fetchall()
return [_row_to_project(r) for r in rows]
def list_org_projects(org_id: int, config: Optional[dict] = None) -> List[Dict[str, Any]]:
"""Только общие проекты организации (без личных)."""
with get_connection(config) as conn:
rows = conn.execute(
"""
SELECT id, org_id, slug, name, owner_user_id, created_at
FROM projects WHERE org_id = ? AND owner_user_id IS NULL
ORDER BY slug
""",
(org_id,),
).fetchall()
return [_row_to_project(r) for r in rows]
def set_user_projects(user_id: int, project_ids: List[int], config: Optional[dict] = None) -> None:
with get_connection(config) as conn:
conn.execute("DELETE FROM user_projects WHERE user_id = ?", (user_id,))
for project_id in project_ids:
conn.execute(
"INSERT INTO user_projects (user_id, project_id) VALUES (?, ?)",
(user_id, project_id),
)
def get_project_by_slug(org_id: int, slug: str, config: Optional[dict] = None) -> Optional[Dict[str, Any]]:
with get_connection(config) as conn:
row = conn.execute(
"""
SELECT id, org_id, slug, name, owner_user_id, created_at
FROM projects WHERE org_id = ? AND slug = ?
""",
(org_id, slug.strip().lower()),
).fetchone()
return _row_to_project(row) if row else None
def delete_project(project_id: int, config: Optional[dict] = None) -> None:
with get_connection(config) as conn:
conn.execute("DELETE FROM user_projects WHERE project_id = ?", (project_id,))
conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
def bootstrap_from_config(config: dict) -> None:
init_db(config)
if count_users(config) > 0:
return
auth_cfg = config.get("auth", {})
bootstrap = auth_cfg.get("bootstrap", {})
org_slug = bootstrap.get("org_slug", "merakom")
org_name = bootstrap.get("org_name", "МЕРАКОМ")
admin_username = bootstrap.get("admin_username", "admin")
admin_password = (
os.getenv("AUTH_ADMIN_PASSWORD")
or bootstrap.get("admin_password")
or auth_cfg.get("admin_password", "admin123")
)
org = get_org_by_slug(org_slug, config)
if not org:
org = create_organization(org_slug, org_name, config)
create_user(org["id"], admin_username, admin_password, role="admin", config=config)
default_projects = bootstrap.get("default_projects") or [
{"slug": "2026", "name": "2026"},
{"slug": "gp-merakom", "name": "ГП МЕРАКОМ"},
]
for proj in default_projects:
slug = str(proj.get("slug", "")).strip().lower()
name = str(proj.get("name", slug))
if slug and not get_project_by_slug(org["id"], slug, config):
create_project(org["id"], slug, name, owner_user_id=None, config=config)
print(f"[Auth] Bootstrap: org={org_slug}, admin={admin_username}")
def migrate_legacy_data(org_slug: str) -> None:
"""Move flat processed/* into org-scoped layout (one-time)."""
from backend.paths import MEETINGS_DIRNAME, RAG_CACHE_DIRNAME, PROCESSED_ROOT, write_folder_project_meta
from src.rag.parser import parse_project_from_filename
import shutil
legacy_rag = PROCESSED_ROOT / "lightrag_caches"
legacy_rag_test = PROCESSED_ROOT / "lightrag_caches_test"
org_root = PROCESSED_ROOT / org_slug
target_meetings = org_root / MEETINGS_DIRNAME
target_rag = org_root / RAG_CACHE_DIRNAME
target_meetings.mkdir(parents=True, exist_ok=True)
target_rag.mkdir(parents=True, exist_ok=True)
skip = {org_slug, "lightrag_caches", "lightrag_caches_test", MEETINGS_DIRNAME, RAG_CACHE_DIRNAME}
if PROCESSED_ROOT.exists():
for item in PROCESSED_ROOT.iterdir():
if not item.is_dir() or item.name in skip:
continue
dest = target_meetings / item.name
if not dest.exists():
shutil.move(str(item), str(dest))
print(f"[Auth] Migrated meeting folder: {item.name} -> {dest}")
for folder in target_meetings.iterdir():
if not folder.is_dir():
continue
meta_path = folder / ".project.json"
if not meta_path.exists():
project_slug = parse_project_from_filename(folder.name)
write_folder_project_meta(folder, project_slug)
print(f"[Auth] Assigned project {project_slug} -> {folder.name}")
if legacy_rag.exists() and legacy_rag != target_rag:
for item in legacy_rag.iterdir():
dest = target_rag / item.name
if item.is_dir() and not dest.exists():
shutil.move(str(item), str(dest))
if not any(legacy_rag.iterdir()):
legacy_rag.rmdir()
print(f"[Auth] Migrated RAG cache -> {target_rag}")
if legacy_rag_test.exists():
shutil.rmtree(legacy_rag_test, ignore_errors=True)