0012_role_management.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. """角色管理与权限矩阵:三张新表 + 用户角色外键 + 审计CHECK扩展。
  2. Revision ID: 0012_role_management
  3. Revises: 0011_reorder_category_audit_action
  4. Create Date: 2026-08-20
  5. 变更要点:
  6. 1. 新建 sys_role / sys_role_dept / sys_role_permission 三张表:
  7. - sys_role 含 default_marker 虚拟生成列 + 唯一约束,保证全局至多一个默认角色;
  8. - sys_role_dept 角色可访问部门列表(矩阵 ASSIGNED 维度判定依据);
  9. - sys_role_permission 权限矩阵(密级 × 部门范围 × 7 操作布尔列)。
  10. 2. sys_user 新增 role_id 可空外键(NULL → 套用默认角色)。
  11. 3. sys_audit_log 的 action_type / target_type CHECK 约束扩展,
  12. 覆盖角色管理相关的新枚举值。
  13. 4. 降级保护:已产生角色数据/审计记录时拒绝有损降级。
  14. """
  15. from __future__ import annotations
  16. from collections.abc import Sequence
  17. from alembic import op
  18. import sqlalchemy as sa
  19. from sqlalchemy.dialects import mysql
  20. revision: str = "0012_role_management"
  21. down_revision: str | None = "0011_reorder_category_audit_action"
  22. branch_labels: str | Sequence[str] | None = None
  23. depends_on: str | Sequence[str] | None = None
  24. # 既有审计动作(与 0011 对齐)+ 本期新增动作
  25. AUDIT_ACTIONS_V5 = (
  26. "LOGIN",
  27. "LOGOUT",
  28. "VIEW_DOCUMENT",
  29. "DOWNLOAD_DOCUMENT",
  30. "UPLOAD_DOCUMENT",
  31. "BATCH_IMPORT",
  32. "EDIT_DOCUMENT",
  33. "DELETE_DOCUMENT",
  34. "CREATE_CATEGORY",
  35. "EDIT_CATEGORY",
  36. "DELETE_CATEGORY",
  37. "CHANGE_PERMISSION",
  38. "BIND_ATTACHMENT",
  39. "UNBIND_ATTACHMENT",
  40. "RESTORE_DOCUMENT",
  41. "CREATE_ORGANIZATION",
  42. "EDIT_ORGANIZATION",
  43. "DELETE_ORGANIZATION",
  44. "CREATE_USER",
  45. "EDIT_USER",
  46. "DELETE_USER",
  47. "RESET_USER_PASSWORD",
  48. "REORDER_DOCUMENT",
  49. "REORDER_ATTACHMENT",
  50. "REORDER_CATEGORY",
  51. "CREATE_ROLE",
  52. "EDIT_ROLE",
  53. "DELETE_ROLE",
  54. "CHANGE_ROLE_PERMISSION",
  55. "CHANGE_ROLE_USER",
  56. )
  57. AUDIT_ACTIONS_V4 = AUDIT_ACTIONS_V5[:-5]
  58. AUDIT_TARGETS_V3 = (
  59. "AUTH",
  60. "CATEGORY",
  61. "DOCUMENT",
  62. "ATTACHMENT",
  63. "PERMISSION",
  64. "ATTACHMENT_BINDING",
  65. "ORGANIZATION",
  66. "USER",
  67. "ROLE",
  68. )
  69. AUDIT_TARGETS_V2 = AUDIT_TARGETS_V3[:-1]
  70. ACTION_CONSTRAINT = "audit_action_type"
  71. TARGET_CONSTRAINT = "audit_target_type"
  72. def _business_columns() -> list[sa.Column]:
  73. return [
  74. sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
  75. sa.Column("created_by", sa.BigInteger(), nullable=True),
  76. sa.Column("updated_by", sa.BigInteger(), nullable=True),
  77. sa.Column(
  78. "created_at",
  79. mysql.DATETIME(fsp=3),
  80. server_default=sa.text("CURRENT_TIMESTAMP(3)"),
  81. nullable=False,
  82. ),
  83. sa.Column(
  84. "updated_at",
  85. mysql.DATETIME(fsp=3),
  86. server_default=sa.text("CURRENT_TIMESTAMP(3)"),
  87. nullable=False,
  88. ),
  89. sa.Column("deleted_at", mysql.DATETIME(fsp=3), nullable=True),
  90. sa.Column(
  91. "is_deleted",
  92. sa.Boolean(),
  93. server_default=sa.text("0"),
  94. nullable=False,
  95. ),
  96. sa.Column(
  97. "row_version",
  98. sa.Integer(),
  99. server_default=sa.text("0"),
  100. nullable=False,
  101. ),
  102. sa.PrimaryKeyConstraint("id"),
  103. ]
  104. def _check_sql(values: tuple[str, ...]) -> str:
  105. # 安全性:values 为本文件内硬编码的枚举常量,非用户输入,无注入风险
  106. inline = ", ".join(f"'{v}'" for v in values)
  107. return f"action_type IN ({inline})"
  108. def _target_check_sql(values: tuple[str, ...]) -> str:
  109. # 安全性:values 为本文件内硬编码的枚举常量,非用户输入,无注入风险
  110. inline = ", ".join(f"'{v}'" for v in values)
  111. return f"(target_type IS NULL OR target_type IN ({inline}))"
  112. def _existing_constraint_names(table: str) -> set[str]:
  113. inspector = sa.inspect(op.get_bind())
  114. return {item["name"] for item in inspector.get_check_constraints(table)}
  115. def _drop_audit_checks(existing: set[str]) -> None:
  116. # op.drop_constraint 会按 metadata.naming_convention 再次前缀化约束名
  117. # ("ck_%(table_name)s_%(constraint_name)s"),因此必须传入原始约束名,
  118. # 让 SQLAlchemy 渲染出最终名;数据库实际存的也是渲染后的最终名。
  119. for raw_name, final_name in (
  120. (ACTION_CONSTRAINT, f"ck_sys_audit_log_{ACTION_CONSTRAINT}"),
  121. (TARGET_CONSTRAINT, f"ck_sys_audit_log_{TARGET_CONSTRAINT}"),
  122. ):
  123. if final_name in existing:
  124. op.drop_constraint(raw_name, "sys_audit_log", type_="check")
  125. def upgrade() -> None:
  126. # 1) 角色本体
  127. op.create_table(
  128. "sys_role",
  129. *_business_columns(),
  130. sa.Column("role_name", sa.String(length=64), nullable=False),
  131. sa.Column(
  132. "sort_no",
  133. sa.Integer(),
  134. server_default=sa.text("0"),
  135. nullable=False,
  136. ),
  137. sa.Column(
  138. "status",
  139. sa.String(length=20),
  140. server_default=sa.text("'ENABLED'"),
  141. nullable=False,
  142. ),
  143. sa.Column("remark", sa.String(length=255), nullable=True),
  144. sa.Column(
  145. "is_default",
  146. sa.Boolean(),
  147. server_default=sa.text("0"),
  148. nullable=False,
  149. ),
  150. sa.Column(
  151. "active_marker",
  152. sa.Integer(),
  153. sa.Computed("IF(is_deleted = 0, 1, NULL)", persisted=False),
  154. nullable=True,
  155. ),
  156. sa.Column(
  157. "default_marker",
  158. sa.Integer(),
  159. sa.Computed(
  160. "IF(is_default = 1 AND is_deleted = 0, 1, NULL)",
  161. persisted=False,
  162. ),
  163. nullable=True,
  164. ),
  165. sa.CheckConstraint(
  166. "status IN ('ENABLED', 'DISABLED')",
  167. name="role_status",
  168. ),
  169. sa.UniqueConstraint(
  170. "role_name",
  171. "active_marker",
  172. name="uq_sys_role_role_name_active",
  173. ),
  174. sa.UniqueConstraint("default_marker", name="uq_sys_role_default_active"),
  175. )
  176. op.create_index(
  177. "ix_sys_role_status_active",
  178. "sys_role",
  179. ["status", "is_deleted"],
  180. )
  181. # 2) 角色可访问部门列表
  182. op.create_table(
  183. "sys_role_dept",
  184. *_business_columns(),
  185. sa.Column("role_id", sa.BigInteger(), nullable=False),
  186. sa.Column("organization_id", sa.BigInteger(), nullable=False),
  187. sa.Column(
  188. "active_marker",
  189. sa.Integer(),
  190. sa.Computed("IF(is_deleted = 0, 1, NULL)", persisted=False),
  191. nullable=True,
  192. ),
  193. sa.ForeignKeyConstraint(
  194. ["role_id"],
  195. ["sys_role.id"],
  196. name="fk_sys_role_dept_role_id_sys_role",
  197. ondelete="RESTRICT",
  198. ),
  199. sa.ForeignKeyConstraint(
  200. ["organization_id"],
  201. ["sys_organization.id"],
  202. name="fk_sys_role_dept_organization_id_sys_organization",
  203. ondelete="RESTRICT",
  204. ),
  205. sa.UniqueConstraint(
  206. "role_id",
  207. "organization_id",
  208. "active_marker",
  209. name="uq_sys_role_dept_active",
  210. ),
  211. )
  212. op.create_index(
  213. "ix_sys_role_dept_role_active",
  214. "sys_role_dept",
  215. ["role_id", "is_deleted"],
  216. )
  217. op.create_index(
  218. "ix_sys_role_dept_org_active",
  219. "sys_role_dept",
  220. ["organization_id", "is_deleted"],
  221. )
  222. # 3) 角色权限矩阵(每角色固定 15 行:5 密级 × 3 部门范围)
  223. op.create_table(
  224. "sys_role_permission",
  225. *_business_columns(),
  226. sa.Column("role_id", sa.BigInteger(), nullable=False),
  227. sa.Column("security_level", sa.String(length=32), nullable=False),
  228. sa.Column("dept_scope", sa.String(length=32), nullable=False),
  229. sa.Column(
  230. "can_view",
  231. sa.Boolean(),
  232. server_default=sa.text("0"),
  233. nullable=False,
  234. ),
  235. sa.Column(
  236. "can_download",
  237. sa.Boolean(),
  238. server_default=sa.text("0"),
  239. nullable=False,
  240. ),
  241. sa.Column(
  242. "can_edit",
  243. sa.Boolean(),
  244. server_default=sa.text("0"),
  245. nullable=False,
  246. ),
  247. sa.Column(
  248. "can_delete",
  249. sa.Boolean(),
  250. server_default=sa.text("0"),
  251. nullable=False,
  252. ),
  253. sa.Column(
  254. "can_config_permission",
  255. sa.Boolean(),
  256. server_default=sa.text("0"),
  257. nullable=False,
  258. ),
  259. sa.Column(
  260. "can_bind_attachment",
  261. sa.Boolean(),
  262. server_default=sa.text("0"),
  263. nullable=False,
  264. ),
  265. sa.Column(
  266. "can_unbind_attachment",
  267. sa.Boolean(),
  268. server_default=sa.text("0"),
  269. nullable=False,
  270. ),
  271. sa.Column(
  272. "active_marker",
  273. sa.Integer(),
  274. sa.Computed("IF(is_deleted = 0, 1, NULL)", persisted=False),
  275. nullable=True,
  276. ),
  277. sa.CheckConstraint(
  278. "security_level IN "
  279. "('PUBLIC', 'INTERNAL', 'SECRET', 'CONFIDENTIAL', 'TOP_SECRET')",
  280. name="role_permission_security_level",
  281. ),
  282. sa.CheckConstraint(
  283. "dept_scope IN ('OWN', 'ASSIGNED', 'OTHER')",
  284. name="role_permission_dept_scope",
  285. ),
  286. sa.ForeignKeyConstraint(
  287. ["role_id"],
  288. ["sys_role.id"],
  289. name="fk_sys_role_permission_role_id_sys_role",
  290. ondelete="RESTRICT",
  291. ),
  292. sa.UniqueConstraint(
  293. "role_id",
  294. "security_level",
  295. "dept_scope",
  296. "active_marker",
  297. name="uq_sys_role_permission_active",
  298. ),
  299. )
  300. op.create_index(
  301. "ix_sys_role_permission_role_active",
  302. "sys_role_permission",
  303. ["role_id", "is_deleted"],
  304. )
  305. # 4) 用户挂角色(NULL → 默认角色)
  306. op.add_column("sys_user", sa.Column("role_id", sa.BigInteger(), nullable=True))
  307. op.create_foreign_key(
  308. "fk_sys_user_role_id_sys_role",
  309. "sys_user",
  310. "sys_role",
  311. ["role_id"],
  312. ["id"],
  313. ondelete="RESTRICT",
  314. )
  315. op.create_index(
  316. "ix_sys_user_role_active",
  317. "sys_user",
  318. ["role_id", "is_deleted"],
  319. )
  320. # 5) 审计 CHECK 扩展
  321. _drop_audit_checks(_existing_constraint_names("sys_audit_log"))
  322. op.create_check_constraint(
  323. ACTION_CONSTRAINT,
  324. "sys_audit_log",
  325. _check_sql(AUDIT_ACTIONS_V5),
  326. )
  327. op.create_check_constraint(
  328. TARGET_CONSTRAINT,
  329. "sys_audit_log",
  330. _target_check_sql(AUDIT_TARGETS_V3),
  331. )
  332. def downgrade() -> None:
  333. bind = op.get_bind()
  334. role_rows = bind.scalar(sa.text("SELECT COUNT(*) FROM sys_role"))
  335. user_role_rows = bind.scalar(
  336. sa.text("SELECT COUNT(*) FROM sys_user WHERE role_id IS NOT NULL")
  337. )
  338. audit_rows = bind.scalar(
  339. sa.text(
  340. "SELECT COUNT(*) FROM sys_audit_log "
  341. "WHERE action_type IN ("
  342. "'CREATE_ROLE','EDIT_ROLE','DELETE_ROLE',"
  343. "'CHANGE_ROLE_PERMISSION','CHANGE_ROLE_USER') "
  344. "OR target_type = 'ROLE'"
  345. )
  346. )
  347. if role_rows or user_role_rows or audit_rows:
  348. raise RuntimeError(
  349. "0012已产生角色管理相关数据或审计记录,拒绝有损降级;"
  350. "请先备份并按数据治理流程处理"
  351. )
  352. _drop_audit_checks(_existing_constraint_names("sys_audit_log"))
  353. op.create_check_constraint(
  354. ACTION_CONSTRAINT,
  355. "sys_audit_log",
  356. _check_sql(AUDIT_ACTIONS_V4),
  357. )
  358. op.create_check_constraint(
  359. TARGET_CONSTRAINT,
  360. "sys_audit_log",
  361. _target_check_sql(AUDIT_TARGETS_V2),
  362. )
  363. op.drop_index("ix_sys_user_role_active", table_name="sys_user")
  364. op.drop_constraint("fk_sys_user_role_id_sys_role", "sys_user", type_="foreignkey")
  365. op.drop_column("sys_user", "role_id")
  366. op.drop_table("sys_role_permission")
  367. op.drop_table("sys_role_dept")
  368. op.drop_table("sys_role")