203 lines
7.4 KiB
Python
203 lines
7.4 KiB
Python
"""M1 任务域:item / tag / item_tag / comment / activity + 行级安全。
|
||
|
||
RLS 策略与 0001 同一套模式:`workspace_id = app_ws_id()`,读写都校验。
|
||
`item` 额外允许"我创建或分配给我"——不,**不做这个特例**:跨租户看别人的任务没有业务含义,
|
||
越权面越小越好,列表查询本来就带 workspace_id 过滤。
|
||
|
||
注意:`pg_trgm` 是 trusted extension(PG13+),库 owner 就能装,所以迁移不需要 superuser。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Sequence
|
||
|
||
import sqlalchemy as sa
|
||
from alembic import op
|
||
from sqlalchemy.dialects import postgresql
|
||
|
||
from app.roles import APP_ROLE
|
||
|
||
revision: str = "0002"
|
||
down_revision: str | None = "0001"
|
||
branch_labels: str | Sequence[str] | None = None
|
||
depends_on: str | Sequence[str] | None = None
|
||
|
||
TENANT_TABLES_0002: tuple[str, ...] = ("item", "tag", "item_tag", "comment", "activity")
|
||
|
||
_TIMESTAMP_COLUMNS = (
|
||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||
)
|
||
|
||
|
||
def _ws_fk() -> sa.Column:
|
||
return sa.Column(
|
||
"workspace_id",
|
||
postgresql.UUID(as_uuid=True),
|
||
sa.ForeignKey("workspace.id", ondelete="CASCADE"),
|
||
nullable=False,
|
||
)
|
||
|
||
|
||
def upgrade() -> None:
|
||
# 中文没有分词器(zhparser 未装),M1 的搜索走 ILIKE 子串匹配,
|
||
# trigram 索引能让"标题里找关键字"这条最常用的查询走索引而不是全表扫。
|
||
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||
|
||
op.create_table(
|
||
"item",
|
||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
_ws_fk(),
|
||
sa.Column("kind", sa.String(16), nullable=False, server_default="task"),
|
||
sa.Column("title", sa.String(200), nullable=False),
|
||
sa.Column("body_md", sa.Text, nullable=False, server_default=""),
|
||
sa.Column("status", sa.String(16), nullable=False, server_default="inbox"),
|
||
sa.Column("priority", sa.Integer, nullable=False, server_default="0"),
|
||
sa.Column(
|
||
"creator_id",
|
||
postgresql.UUID(as_uuid=True),
|
||
sa.ForeignKey("app_user.id", ondelete="RESTRICT"),
|
||
nullable=False,
|
||
),
|
||
sa.Column(
|
||
"assignee_id",
|
||
postgresql.UUID(as_uuid=True),
|
||
sa.ForeignKey("app_user.id", ondelete="SET NULL"),
|
||
),
|
||
sa.Column("due_at", sa.DateTime(timezone=True)),
|
||
sa.Column("completed_at", sa.DateTime(timezone=True)),
|
||
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
|
||
sa.Column("deleted_at", sa.DateTime(timezone=True)),
|
||
*_TIMESTAMP_COLUMNS,
|
||
sa.CheckConstraint(
|
||
"status IN ('inbox','todo','doing','done','blocked','canceled')",
|
||
name="ck_item_status",
|
||
),
|
||
sa.CheckConstraint("kind IN ('task','ticket','knowledge')", name="ck_item_kind"),
|
||
sa.CheckConstraint("priority BETWEEN 0 AND 3", name="ck_item_priority"),
|
||
)
|
||
op.create_index("ix_item_ws_status_due", "item", ["workspace_id", "status", "due_at"])
|
||
op.create_index("ix_item_ws_assignee", "item", ["workspace_id", "assignee_id", "status"])
|
||
op.create_index("ix_item_ws_updated", "item", ["workspace_id", "updated_at"])
|
||
op.create_index("ix_item_ws_sort", "item", ["workspace_id", "status", "sort_order"])
|
||
|
||
op.create_table(
|
||
"tag",
|
||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
_ws_fk(),
|
||
sa.Column("name", sa.String(32), nullable=False),
|
||
sa.Column("color", sa.String(16), nullable=False, server_default=""),
|
||
*_TIMESTAMP_COLUMNS,
|
||
sa.UniqueConstraint("workspace_id", "name", name="uq_tag_ws_name"),
|
||
)
|
||
op.create_index("ix_tag_ws", "tag", ["workspace_id", "name"])
|
||
|
||
op.create_table(
|
||
"item_tag",
|
||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
_ws_fk(),
|
||
sa.Column(
|
||
"item_id",
|
||
postgresql.UUID(as_uuid=True),
|
||
sa.ForeignKey("item.id", ondelete="CASCADE"),
|
||
nullable=False,
|
||
),
|
||
sa.Column(
|
||
"tag_id",
|
||
postgresql.UUID(as_uuid=True),
|
||
sa.ForeignKey("tag.id", ondelete="CASCADE"),
|
||
nullable=False,
|
||
),
|
||
*_TIMESTAMP_COLUMNS,
|
||
sa.UniqueConstraint("item_id", "tag_id", name="uq_item_tag"),
|
||
)
|
||
op.create_index("ix_item_tag_ws_tag", "item_tag", ["workspace_id", "tag_id"])
|
||
|
||
op.create_table(
|
||
"comment",
|
||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
_ws_fk(),
|
||
sa.Column(
|
||
"item_id",
|
||
postgresql.UUID(as_uuid=True),
|
||
sa.ForeignKey("item.id", ondelete="CASCADE"),
|
||
nullable=False,
|
||
),
|
||
sa.Column(
|
||
"author_id",
|
||
postgresql.UUID(as_uuid=True),
|
||
sa.ForeignKey("app_user.id", ondelete="RESTRICT"),
|
||
nullable=False,
|
||
),
|
||
sa.Column("body_md", sa.Text, nullable=False, server_default=""),
|
||
sa.Column("deleted_at", sa.DateTime(timezone=True)),
|
||
*_TIMESTAMP_COLUMNS,
|
||
)
|
||
op.create_index("ix_comment_ws_item_created", "comment", ["workspace_id", "item_id", "created_at"])
|
||
|
||
op.create_table(
|
||
"activity",
|
||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
_ws_fk(),
|
||
sa.Column(
|
||
"item_id",
|
||
postgresql.UUID(as_uuid=True),
|
||
sa.ForeignKey("item.id", ondelete="CASCADE"),
|
||
nullable=False,
|
||
),
|
||
sa.Column(
|
||
"actor_id",
|
||
postgresql.UUID(as_uuid=True),
|
||
sa.ForeignKey("app_user.id", ondelete="SET NULL"),
|
||
),
|
||
sa.Column("action", sa.String(32), nullable=False),
|
||
sa.Column("payload", postgresql.JSONB),
|
||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||
)
|
||
op.create_index("ix_activity_ws_item_created", "activity", ["workspace_id", "item_id", "created_at"])
|
||
|
||
# 标题三元组索引:ILIKE '%关键字%' 能用到它
|
||
op.execute(
|
||
"CREATE INDEX ix_item_title_trgm ON item USING gin (title gin_trgm_ops)"
|
||
)
|
||
|
||
for statement in _POLICY_STATEMENTS:
|
||
op.execute(statement)
|
||
for statement in _GRANT_STATEMENTS:
|
||
op.execute(statement)
|
||
|
||
|
||
def downgrade() -> None:
|
||
# 先摘策略:策略里引用了这些表本身,表没了策略语句就悬空
|
||
for table in TENANT_TABLES_0002:
|
||
op.execute(f"DROP POLICY IF EXISTS {table}_isolation ON {table}")
|
||
op.execute("DROP INDEX IF EXISTS ix_item_title_trgm")
|
||
for table in (
|
||
"activity",
|
||
"comment",
|
||
"item_tag",
|
||
"tag",
|
||
"item",
|
||
):
|
||
op.drop_table(table)
|
||
# pg_trgm 是库级共享资源,别在这里删(其它模块可能也在用)
|
||
|
||
|
||
_POLICY_STATEMENTS: tuple[str, ...] = tuple(
|
||
statement
|
||
for table in TENANT_TABLES_0002
|
||
for statement in (
|
||
f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;",
|
||
f"""
|
||
CREATE POLICY {table}_isolation ON {table}
|
||
USING (workspace_id = app_ws_id())
|
||
WITH CHECK (workspace_id = app_ws_id());
|
||
""",
|
||
)
|
||
)
|
||
|
||
_GRANT_STATEMENTS: tuple[str, ...] = (
|
||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON {', '.join(TENANT_TABLES_0002)} TO {APP_ROLE};",
|
||
)
|