106 lines
4.6 KiB
Python
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")
|