"""add tenant-bound one-time OnlyOffice sessions Revision ID: 20260717_0027 Revises: 20260717_0026 Create Date: 2026-07-17 10:00:00 """ from __future__ import annotations from collections.abc import Sequence import sqlalchemy as sa from alembic import op revision: str = "20260717_0027" down_revision: str | None = "20260717_0026" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def _require_postgresql() -> None: dialect_name = op.get_bind().dialect.name if dialect_name != "postgresql": raise RuntimeError( "20260717_0027 only supports PostgreSQL; " f"refusing to mutate {dialect_name} without transactional constraint DDL" ) def _require_empty_for_downgrade() -> None: count = int( op.get_bind().scalar( sa.text("SELECT COUNT(*) FROM knowledge_onlyoffice_sessions") ) or 0 ) if count: raise RuntimeError( "cannot downgrade knowledge tenant security: OnlyOffice session evidence exists " f"(knowledge_onlyoffice_sessions={count})" ) def upgrade() -> None: _require_postgresql() op.create_table( "knowledge_onlyoffice_sessions", sa.Column("jti", sa.String(length=36), nullable=False), sa.Column("tenant_id", sa.String(length=64), nullable=False), sa.Column("resource_scope", sa.String(length=16), nullable=False), sa.Column("document_id", sa.String(length=64), nullable=False), sa.Column("document_key", sa.String(length=160), nullable=False), sa.Column("document_version", sa.Integer(), nullable=False), sa.Column("audience", sa.String(length=80), nullable=False), sa.Column("editable", sa.Boolean(), nullable=False), sa.Column("status", sa.String(length=16), nullable=False), sa.Column("created_by", sa.String(length=100), nullable=False), sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True), sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True), sa.Column("failure_reason", sa.Text(), nullable=False), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.CheckConstraint( "resource_scope IN ('tenant', 'platform')", name="ck_knowledge_onlyoffice_sessions_scope", ), sa.CheckConstraint( "status IN ('active', 'processing', 'consumed', 'failed', 'revoked')", name="ck_knowledge_onlyoffice_sessions_status", ), sa.CheckConstraint( "tenant_id IS NOT NULL AND " "(resource_scope = 'tenant' OR " "(resource_scope = 'platform' AND editable = false))", name="ck_knowledge_onlyoffice_sessions_scope_tenant", ), sa.CheckConstraint( "(status = 'active' AND claimed_at IS NULL AND consumed_at IS NULL) OR " "(status IN ('processing', 'failed') AND claimed_at IS NOT NULL " "AND consumed_at IS NULL) OR " "(status = 'consumed' AND claimed_at IS NOT NULL AND consumed_at IS NOT NULL) OR " "(status = 'revoked' AND consumed_at IS NULL)", name="ck_knowledge_onlyoffice_sessions_lifecycle", ), sa.ForeignKeyConstraint( ["tenant_id"], ["tenants.tenant_id"], name="fk_knowledge_onlyoffice_sessions_tenant", ondelete="CASCADE", ), sa.PrimaryKeyConstraint("jti"), ) op.create_index( "ix_knowledge_onlyoffice_sessions_tenant_document", "knowledge_onlyoffice_sessions", ["tenant_id", "document_id", "created_at"], ) op.create_index( "ix_knowledge_onlyoffice_sessions_status_expiry", "knowledge_onlyoffice_sessions", ["status", "expires_at"], ) def downgrade() -> None: _require_postgresql() _require_empty_for_downgrade() op.drop_index( "ix_knowledge_onlyoffice_sessions_status_expiry", table_name="knowledge_onlyoffice_sessions", ) op.drop_index( "ix_knowledge_onlyoffice_sessions_tenant_document", table_name="knowledge_onlyoffice_sessions", ) op.drop_table("knowledge_onlyoffice_sessions")