Files
DadaoTask/backend/alembic/versions/0001_init.py
T
Steven 2b493bd9f8
CI / backend (push) Canceled after 0s
fix(m0): 迁移可执行 + 测试全绿(33 条)
跑通迁移与测试后修掉的真问题:

- alembic 0001:asyncpg 走预备语句,一次 execute 只能一条 SQL。
  CREATE FUNCTION / CREATE POLICY / GRANT 全部拆成单语句逐条执行,
  否则报 "cannot insert multiple commands into a prepared statement"。
- 创建工作区:ORM flush 只按 relationship 排序,裸外键不算数,
  member 可能先于 workspace 落地撞 FK。改为先 flush workspace 再插 member。
- datetime.UTC 是 3.11+ 才有的,本机 3.10 直接 ImportError。改用 timezone.utc。
- 幂等中间件:原来会缓存 4xx。校验失败被缓存后,用户改对参数仍拿到同一个 422。
  改为只缓存 <400。
- 发码节流窗口改为可配置 SMS_THROTTLE_SECONDS(测试置 0),
  节流用例自己显式打开,避免"整库关掉节流"导致节流从未被验证。
- 租户隔离用例:pytest.raises(Exception) 改为精确的 ProgrammingError,
  否则任何异常都会让"应用角色不能建表"这条断言假通过。

验收:pytest 33 passed;ruff check / format 通过;真机跑 uvicorn,
/health、/version、登录、建工作区、列表、/auth/me 全通。
2026-10-28 03:22:59 +08:00

218 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""M0 初始结构:workspace / app_user / member / invitation / audit_log + 行级安全。
注意:RLS 策略是**安全边界**,不是优化项。改这个文件请同时改
`tests/test_tenant_isolation.py`,并在 PR 里说明为什么。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
# 说明:asyncpg 走"预备语句"执行,**一条 execute 里只能有一条 SQL**,
# 多条语句会报 `cannot insert multiple commands into a prepared statement`。
# 所以这里按语句拆成元组,逐条执行。
HELPER_STATEMENTS: tuple[str, ...] = (
"""
CREATE OR REPLACE FUNCTION app_ws_id() RETURNS uuid
LANGUAGE sql STABLE AS $$
SELECT nullif(current_setting('app.ws_id', true), '')::uuid
$$
""",
"""
CREATE OR REPLACE FUNCTION app_user_id() RETURNS uuid
LANGUAGE sql STABLE AS $$
SELECT nullif(current_setting('app.user_id', true), '')::uuid
$$
""",
)
POLICY_STATEMENTS: tuple[str, ...] = (
# ── workspace:本租户 OR 我是其成员(用于"我加入了哪些工作区")──
"ALTER TABLE workspace ENABLE ROW LEVEL SECURITY;",
"""
CREATE POLICY workspace_isolation ON workspace
USING (
id = app_ws_id()
OR id IN (SELECT m.workspace_id FROM member m WHERE m.user_id = app_user_id())
)
WITH CHECK (id = app_ws_id());
""",
# ── member:本租户成员 OR 我自己那条 ──
"ALTER TABLE member ENABLE ROW LEVEL SECURITY;",
"""
CREATE POLICY member_isolation ON member
USING (workspace_id = app_ws_id() OR user_id = app_user_id())
WITH CHECK (workspace_id = app_ws_id());
""",
# ── invitation ──
"ALTER TABLE invitation ENABLE ROW LEVEL SECURITY;",
"""
CREATE POLICY invitation_isolation ON invitation
USING (workspace_id = app_ws_id())
WITH CHECK (workspace_id = app_ws_id());
""",
# ── audit_log:只增不改,租户内可见 ──
"ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;",
"""
CREATE POLICY audit_log_isolation ON audit_log
USING (workspace_id = app_ws_id())
WITH CHECK (workspace_id = app_ws_id());
""",
# ── 授权:应用角色没有 DDL、没有 BYPASSRLS,只能读写到自己该看的数据 ──
f"GRANT USAGE ON SCHEMA public TO {APP_ROLE};",
(
"GRANT SELECT, INSERT, UPDATE, DELETE ON "
f"workspace, app_user, member, invitation, audit_log TO {APP_ROLE};"
),
(
"ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public "
f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE};"
),
)
def _exec_each(statements: tuple[str, ...]) -> None:
for statement in statements:
op.execute(statement)
def upgrade() -> None:
_exec_each(HELPER_STATEMENTS)
op.create_table(
"app_user",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("phone", sa.String(32), nullable=False, unique=True),
sa.Column("nickname", sa.String(64), nullable=False, server_default=""),
sa.Column("avatar_url", sa.String(512)),
sa.Column("status", sa.String(16), nullable=False, server_default="active"),
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"),
)
op.create_index("ix_app_user_phone", "app_user", ["phone"])
op.create_table(
"workspace",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(64), nullable=False),
sa.Column("slug", sa.String(64), nullable=False, unique=True),
sa.Column("plan", sa.String(16), nullable=False, server_default="free"),
sa.Column(
"owner_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="RESTRICT"),
nullable=False,
),
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"),
sa.Column("deleted_at", sa.DateTime(timezone=True)),
)
op.create_index("ix_workspace_owner", "workspace", ["owner_id"])
op.create_table(
"member",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"workspace_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("workspace.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("role", sa.String(16), nullable=False, server_default="member"),
sa.Column("status", sa.String(16), nullable=False, server_default="active"),
sa.Column("joined_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
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"),
sa.UniqueConstraint("workspace_id", "user_id", name="uq_member_ws_user"),
sa.CheckConstraint("role IN ('owner','admin','member','guest')", name="ck_member_role"),
sa.CheckConstraint("status IN ('active','removed')", name="ck_member_status"),
)
op.create_index("ix_member_user", "member", ["user_id"])
op.create_index("ix_member_created", "member", ["created_at", "id"])
op.create_table(
"invitation",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"workspace_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("workspace.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("code", sa.String(32), nullable=False),
sa.Column("role", sa.String(16), nullable=False, server_default="member"),
sa.Column(
"invited_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("accepted_at", sa.DateTime(timezone=True)),
sa.Column(
"accepted_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="SET NULL"),
),
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"),
sa.UniqueConstraint("code", name="uq_invitation_code"),
)
op.create_index("ix_invitation_ws", "invitation", ["workspace_id"])
op.create_table(
"audit_log",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"workspace_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("workspace.id", ondelete="SET NULL"),
),
sa.Column(
"actor_user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="SET NULL"),
),
sa.Column("action", sa.String(64), nullable=False),
sa.Column("target_type", sa.String(32), nullable=False, server_default=""),
sa.Column("target_id", sa.String(64), nullable=False, server_default=""),
sa.Column("payload", postgresql.JSONB),
sa.Column("ip", sa.String(64), nullable=False, server_default=""),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_audit_ws_created", "audit_log", ["workspace_id", "created_at"])
_exec_each(POLICY_STATEMENTS)
def downgrade() -> None:
op.execute(f"REVOKE ALL ON workspace, app_user, member, invitation, audit_log FROM {APP_ROLE};")
op.drop_table("audit_log")
op.drop_table("invitation")
op.drop_table("member")
op.drop_table("workspace")
op.drop_table("app_user")
op.execute("DROP FUNCTION IF EXISTS app_ws_id();")
op.execute("DROP FUNCTION IF EXISTS app_user_id();")