Files
saas_backend/alembic/versions/c8f1b4e7a03d_documents.py
T
2026-08-31 20:39:41 -04:00

106 lines
4.6 KiB
Python

"""Files attached to things.
## What is assumed here, because it is a product decision and not a technical one
**A document attaches to an entity by type and id**, loosely — `("user",
<uuid>)`, `("invoice", <uuid>)` — rather than by a foreign key per attachable
table. A key per table is stricter and needs a migration every time something new
becomes attachable, which in a platform meant to host modules is every time a
module ships. The looseness is paid for by the API refusing an unknown
`entity_type` rather than storing whatever it is handed.
If the answer turns out to be "documents belong to one specific thing", this
becomes a narrower table with a real foreign key, and the migration is small.
**Storage is a local directory behind an interface.** There is no object-store
credential in this deployment and inventing one would be inventing an
infrastructure decision. The interface is narrow enough that S3 is one class
rather than a rewrite.
## The columns that are load-bearing
**`storage_key` is random and unrelated to the filename.** Deriving a path from
what somebody typed is how `../../etc/passwd` gets written, how two people
uploading `report.pdf` overwrite each other, and how a URL becomes guessable. The
original name is kept in a column, for display only.
**`content_type` is what *we* determined, not what the client claimed.** A
browser will happily execute an uploaded `.html` served back as `text/html` from
the console's own origin, which is a stored cross-site scripting hole with a file
picker attached.
**`checksum`** so a re-upload of the identical file is recognisable, and so
corruption is detectable rather than silent.
**`deleted_at`, with the blob removed.** Storage costs money and a deleted file
should stop costing it; the row stays because "who deleted that attachment, and
when" is exactly what gets asked afterwards.
Revision ID: c8f1b4e7a03d
Revises: b6e3a1d9f42c
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision: str = "c8f1b4e7a03d"
down_revision: Union[str, Sequence[str], None] = "b6e3a1d9f42c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"documents",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("uploaded_by_id", UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("entity_type", sa.String(60), nullable=True),
sa.Column("entity_id", sa.String(64), nullable=True),
sa.Column("filename", sa.String(255), nullable=False),
sa.Column("content_type", sa.String(120), nullable=False),
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
sa.Column("checksum", sa.String(64), nullable=False),
sa.Column("storage_key", sa.String(120), nullable=False),
sa.Column("description", sa.String(500), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_by_id", UUID(as_uuid=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.UniqueConstraint("storage_key", name="uq_documents_storage_key"),
)
op.create_index("ix_documents_tenant", "documents", ["tenant_id"])
op.create_index(
"ix_documents_entity", "documents", ["tenant_id", "entity_type", "entity_id"],
postgresql_where=sa.text("deleted_at IS NULL"),
)
op.execute("ALTER TABLE documents ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE documents FORCE ROW LEVEL SECURITY")
op.execute(
"""
CREATE POLICY tenant_isolation ON documents
USING (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
WITH CHECK (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
"""
)
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS tenant_isolation ON documents")
op.drop_index("ix_documents_entity", table_name="documents")
op.drop_index("ix_documents_tenant", table_name="documents")
op.drop_table("documents")