Files
kite/backend/db/models.py
T
curo1305 718fb2c2b5 feat(12-01): durable owner-scoped cloud metadata schema (migration 0006 + models)
- Migration 0006: cloud_items, cloud_item_topics, cloud_folder_states tables
- cloud_connections: add display_name_override column for same-provider disambiguation
- CloudItem, CloudItemTopic, CloudFolderState ORM models with owner/connection indexes
- Unique (connection_id, provider_item_id) boundary; no MinIO object_key field
- Root folder state representable as parent_ref='' without CloudItem parent row
- services/cloud_items.py: resolve_owned_connection, upsert, list, reconcile, folder state
- 17 unit/integration tests covering model fields, isolation, quota invariant, idempotency
2026-06-18 22:37:28 +02:00

516 lines
19 KiB
Python

"""
Full v1 SQLAlchemy 2.0 ORM schema for DocuVault.
All 11 tables declared here: users, quotas, refresh_tokens, folders, documents,
topics, document_topics, shares, audit_log, cloud_connections, groups.
Key decisions:
D-01: Full v1 skeleton in Phase 1 migration
D-02: groups table stub (v2 feature, seeded for schema completeness per PROJECT.md)
D-03: documents.user_id is nullable in Phase 1 (no auth yet); Phase 2 adds NOT NULL
AuditLog note: The metadata column is declared as `metadata_` (ORM attribute name)
with `name="metadata"` (DB column name). This is required because `metadata` is a
reserved attribute on SQLAlchemy's DeclarativeBase and would cause silent conflicts
if used as an attribute name directly.
Python compat note: `Optional[X]` is used instead of `X | None` union syntax
because the host environment may be Python < 3.10. Both are equivalent.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Boolean,
BigInteger,
ForeignKey,
Index,
String,
Text,
TIMESTAMP,
UniqueConstraint,
Integer,
)
from sqlalchemy.dialects.postgresql import UUID, INET, JSONB
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.sql import func
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
handle: Mapped[str] = mapped_column(String, unique=True, nullable=False)
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
totp_secret: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
role: Mapped[str] = mapped_column(String, nullable=False, default="user")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
password_must_change: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
ai_provider: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
ai_model: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
default_storage_backend: Mapped[str] = mapped_column(
String, nullable=False, default="minio"
)
pdf_open_mode: Mapped[str] = mapped_column(
String, nullable=False, server_default="in_app"
)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
class Quota(Base):
__tablename__ = "quotas"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
# 100 MB default free-tier quota (STORE-01); admin can override limit_bytes per user
limit_bytes: Mapped[int] = mapped_column(
BigInteger, nullable=False, default=104857600
)
used_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False
)
revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (Index("ix_refresh_tokens_user_revoked", "user_id", "revoked"),)
class BackupCode(Base):
"""Single-use backup codes for TOTP recovery (AUTH-02).
code_hash stores the Argon2 hash of the original code (never plaintext).
used_at is None when the code is unused; set to now() on first use.
Verification iterates ALL codes to prevent timing-based enumeration (SEC-06).
"""
__tablename__ = "backup_codes"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
code_hash: Mapped[str] = mapped_column(Text, nullable=False)
used_at: Mapped[Optional[datetime]] = mapped_column(
TIMESTAMP(timezone=True), nullable=True
)
__table_args__ = (Index("ix_backup_codes_user_id", "user_id"),)
class Folder(Base):
__tablename__ = "folders"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("folders.id", ondelete="CASCADE"),
nullable=True,
)
name: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint("user_id", "parent_id", "name", name="uq_folders_user_parent_name"),
)
class Document(Base):
__tablename__ = "documents"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
# D-03: user_id is NULLABLE in Phase 1 — no auth system yet.
# Phase 2 migration adds NOT NULL constraint after users/auth are live.
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
)
folder_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("folders.id", ondelete="SET NULL"),
nullable=True,
)
# original human-readable filename — stored in DB only, never in the MinIO key
filename: Mapped[str] = mapped_column(Text, nullable=False)
# MinIO object key: {user_id}/{document_id}/{uuid4()}{ext}
object_key: Mapped[str] = mapped_column(Text, nullable=False)
content_type: Mapped[str] = mapped_column(Text, nullable=False)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
storage_backend: Mapped[str] = mapped_column(String, nullable=False, default="minio")
extracted_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("ix_documents_user_folder", "user_id", "folder_id"),
Index("ix_documents_user_created", "user_id", "created_at"),
)
class Topic(Base):
__tablename__ = "topics"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
)
name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
color: Mapped[str] = mapped_column(String(7), nullable=False, default="#6366f1")
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_topics_user_name"),)
class DocumentTopic(Base):
__tablename__ = "document_topics"
document_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("documents.id", ondelete="CASCADE"),
primary_key=True,
)
topic_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("topics.id", ondelete="CASCADE"),
primary_key=True,
)
class Share(Base):
__tablename__ = "shares"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
document_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("documents.id", ondelete="CASCADE"),
nullable=False,
)
owner_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
recipient_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
permission: Mapped[str] = mapped_column(String, nullable=False, default="view")
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint("document_id", "recipient_id", name="uq_shares_document_recipient"),
Index("ix_shares_recipient", "recipient_id"),
)
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
actor_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
event_type: Mapped[str] = mapped_column(Text, nullable=False)
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), nullable=True
)
ip_address: Mapped[Optional[str]] = mapped_column(INET, nullable=True)
# ORM attribute is `metadata_` to avoid collision with DeclarativeBase.metadata.
# The DB column is named "metadata" via the mapped_column name= kwarg.
metadata_: Mapped[Optional[dict]] = mapped_column("metadata", JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("ix_audit_user_created", "user_id", "created_at"),
Index("ix_audit_event_created", "event_type", "created_at"),
)
class CloudConnection(Base):
__tablename__ = "cloud_connections"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
provider: Mapped[str] = mapped_column(String, nullable=False)
display_name: Mapped[str] = mapped_column(Text, nullable=False)
credentials_enc: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String, nullable=False, default="ACTIVE")
connected_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (Index("ix_cloud_connections_user", "user_id"),)
class CloudItem(Base):
"""Durable per-item cloud metadata indexed by (connection_id, provider_item_id).
Phase 12: metadata browsing only — no MinIO object_key field.
Phase 14: extracted_text, analysis_status, semantic_index_status, semantic_index_data
are reserved for byte analysis and semantic search.
D-18: provider_size never flows to quotas.used_bytes.
"""
__tablename__ = "cloud_items"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
connection_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("cloud_connections.id", ondelete="CASCADE"),
nullable=False,
)
provider_item_id: Mapped[str] = mapped_column(Text, nullable=False)
parent_ref: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
path_snapshot: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
name: Mapped[str] = mapped_column(Text, nullable=False)
kind: Mapped[str] = mapped_column(String(8), nullable=False) # "file" | "folder"
content_type: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Provider-reported size — never used to update quotas.used_bytes (D-18)
provider_size: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
etag: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
version: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
modified_at: Mapped[Optional[datetime]] = mapped_column(
TIMESTAMP(timezone=True), nullable=True
)
last_seen_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
deleted_at: Mapped[Optional[datetime]] = mapped_column(
TIMESTAMP(timezone=True), nullable=True
)
# Phase 14 reserved fields
extracted_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
analysis_status: Mapped[str] = mapped_column(
String(16), nullable=False, default="pending"
)
semantic_index_status: Mapped[str] = mapped_column(
String(16), nullable=False, default="none"
)
semantic_index_data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint(
"connection_id",
"provider_item_id",
name="uq_cloud_items_connection_provider_item",
),
Index("ix_cloud_items_user_id", "user_id"),
Index("ix_cloud_items_connection_id", "connection_id"),
Index("ix_cloud_items_connection_parent", "connection_id", "parent_ref"),
)
class CloudItemTopic(Base):
"""Association between CloudItem and Topic — no Document row required."""
__tablename__ = "cloud_item_topics"
cloud_item_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("cloud_items.id", ondelete="CASCADE"),
primary_key=True,
)
topic_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("topics.id", ondelete="CASCADE"),
primary_key=True,
)
class CloudFolderState(Base):
"""Per-connection folder freshness row for cached-first navigation.
parent_ref = '' represents the connection root (allows unique constraint).
refresh_state: "refreshing" | "fresh" | "warning"
"""
__tablename__ = "cloud_folder_states"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
connection_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("cloud_connections.id", ondelete="CASCADE"),
nullable=False,
)
# '' for root, provider ref for non-root
parent_ref: Mapped[str] = mapped_column(Text, nullable=False, default="")
refresh_state: Mapped[str] = mapped_column(
String(16), nullable=False, default="fresh"
)
last_refreshed_at: Mapped[Optional[datetime]] = mapped_column(
TIMESTAMP(timezone=True), nullable=True
)
error_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint(
"connection_id",
"parent_ref",
name="uq_cloud_folder_states_connection_parent",
),
Index("ix_cloud_folder_states_connection", "connection_id"),
)
class Group(Base):
"""v2 stub — empty table, seeded for schema completeness (PROJECT.md D-02).
Groups are a v2 feature; the table is created in Phase 1 so the schema is
complete and future migrations don't need to alter the dependency ordering.
No rows will be inserted until Phase 2 or later.
"""
__tablename__ = "groups"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
class SystemSettings(Base):
"""AI provider configuration table — one row per provider.
Stores the active AI provider's API key (Fernet-encrypted), base URL,
model name, and context window size. Only one row has is_active=TRUE
at any given time; the admin panel flips all rows atomically.
Encryption (D-05):
api_key_enc is encrypted with HKDF/Fernet using info=b"ai-provider-settings"
for domain separation from cloud credentials (info=b"cloud-credentials").
Master key is settings.cloud_creds_key (env var CLOUD_CREDS_KEY).
See services/ai_config.py for the encryption helpers.
Design reference: 07-RESEARCH.md Pattern 1.
Migration: 0005_system_settings.py.
"""
__tablename__ = "system_settings"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
provider_id: Mapped[str] = mapped_column(String, nullable=False, unique=True)
api_key_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
base_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
model_name: Mapped[str] = mapped_column(Text, nullable=False, default="")
context_chars: Mapped[int] = mapped_column(Integer, nullable=False, default=8000)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint("provider_id", name="uq_system_settings_provider_id"),
)