Files

227 lines
9.3 KiB
Python
Raw Permalink 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
from app.roles import APP_ROLE, OWNER_ROLE
revision: str = "0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# 说明: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};"
),
(
f"ALTER DEFAULT PRIVILEGES FOR ROLE {OWNER_ROLE} 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};")
# 必须先删策略:workspace 的策略里引用了 member,直接 drop 表会报
# "cannot drop table member because other objects depend on it"。
for table, policy in (
("workspace", "workspace_isolation"),
("member", "member_isolation"),
("invitation", "invitation_isolation"),
("audit_log", "audit_log_isolation"),
):
op.execute(f"DROP POLICY IF EXISTS {policy} ON {table};")
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();")