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>
408 lines
14 KiB
Python
408 lines
14 KiB
Python
"""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)
|