0026_borrow_permission.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. """借阅授权改造:doc_permission 瘦身为用户借阅表,doc_document 下线可见范围。
  2. Revision ID: 0026_borrow_permission
  3. Revises: 0025_move_audit_actions
  4. Create Date: 2026-09-03
  5. 变更要点:
  6. 1. 借阅语义(契约1.19):授权界面三种可见范围(全员/组织/自定义)全部下线,
  7. 只保留面向用户的限时借阅授权(仅查看/下载两个操作位,时限最长7天);
  8. 2. doc_permission 瘦身:物理删除全部 ORG 主体行;删列 subject_type /
  9. can_edit / can_manage_permission / can_delete;唯一约束收敛为
  10. (document_id, subject_id, active_marker);索引 ix_doc_permission_subject_active
  11. 收敛为 (subject_id, is_deleted);新增 expires_at 到期时刻(NOT NULL,
  12. 存量活跃 USER 行初始化为迁移时刻+7天)与审批单凭证四列
  13. voucher_file_name / voucher_storage_path / voucher_file_size / voucher_uploaded_at;
  14. 3. doc_document:删除 visibility_type 列(连同引用它的 CHECK 约束),
  15. shape 约束重建为 v3(附件分支去掉 visibility 条件);
  16. 存量“全员可见/组织可见”文档回退角色权限矩阵控制;
  17. 4. sys_audit_log:action_type CHECK 白名单 +2
  18. (UPLOAD_PERMISSION_VOUCHER / DELETE_PERMISSION_VOUCHER,38+2=40 项);
  19. 5. 达梦兼容:探测统一走 SQLAlchemy inspector、删除以库中实际名执行
  20. (规避 naming convention 渲染差异与达梦大写名);存量初始化时刻由
  21. Python 侧计算参数化传入(规避 DATE_ADD 方言差异);
  22. 6. 升级幂等:列/约束/索引先探测;expires_at 初始化以 1970 哨兵为标记可重跑
  23. (与 Flyway V11 并行执行结果一致);降级仅重建结构(ORG 行已物理删除
  24. 不可恢复),且拒绝已存在凭证审计记录的库。
  25. """
  26. from __future__ import annotations
  27. from collections.abc import Sequence
  28. from datetime import datetime, timedelta, timezone
  29. import sqlalchemy as sa
  30. from alembic import op
  31. revision: str = "0026_borrow_permission"
  32. down_revision: str | None = "0025_move_audit_actions"
  33. branch_labels: str | Sequence[str] | None = None
  34. depends_on: str | Sequence[str] | None = None
  35. _GRACE = timedelta(days=7)
  36. _SENTINEL = "1970-01-01 00:00:00"
  37. _DROPPED_COLUMNS = (
  38. "subject_type",
  39. "can_edit",
  40. "can_manage_permission",
  41. "can_delete",
  42. )
  43. _VOUCHER_COLUMNS = (
  44. ("voucher_file_name", sa.String(length=255)),
  45. ("voucher_storage_path", sa.String(length=512)),
  46. ("voucher_file_size", sa.BigInteger()),
  47. ("voucher_uploaded_at", sa.DateTime()),
  48. )
  49. # 0025 的 38 项全集 + 凭证动作 2 项 = 40 项
  50. AUDIT_ACTIONS_V9 = (
  51. "LOGIN",
  52. "LOGOUT",
  53. "VIEW_DOCUMENT",
  54. "DOWNLOAD_DOCUMENT",
  55. "UPLOAD_DOCUMENT",
  56. "BATCH_IMPORT",
  57. "EDIT_DOCUMENT",
  58. "DELETE_DOCUMENT",
  59. "CREATE_CATEGORY",
  60. "EDIT_CATEGORY",
  61. "DELETE_CATEGORY",
  62. "CHANGE_PERMISSION",
  63. "BIND_ATTACHMENT",
  64. "UNBIND_ATTACHMENT",
  65. "RESTORE_DOCUMENT",
  66. "CREATE_ORGANIZATION",
  67. "EDIT_ORGANIZATION",
  68. "DELETE_ORGANIZATION",
  69. "CREATE_USER",
  70. "EDIT_USER",
  71. "DELETE_USER",
  72. "RESET_USER_PASSWORD",
  73. "REORDER_DOCUMENT",
  74. "REORDER_ATTACHMENT",
  75. "REORDER_CATEGORY",
  76. "CREATE_ROLE",
  77. "EDIT_ROLE",
  78. "DELETE_ROLE",
  79. "CHANGE_ROLE_PERMISSION",
  80. "CHANGE_ROLE_USER",
  81. "DOCUMENT_PASSWORD_SET",
  82. "DOCUMENT_PASSWORD_REMOVE",
  83. "CREATE_IP_ENTRY",
  84. "EDIT_IP_ENTRY",
  85. "DELETE_IP_ENTRY",
  86. "ASSIGN_ORG_IP",
  87. "MOVE_DOCUMENT",
  88. "MOVE_ATTACHMENT",
  89. "UPLOAD_PERMISSION_VOUCHER",
  90. "DELETE_PERMISSION_VOUCHER",
  91. )
  92. AUDIT_ACTIONS_V8 = AUDIT_ACTIONS_V9[:-2]
  93. ACTION_CONSTRAINT = "audit_action_type"
  94. _PERMISSION_TABLE = "doc_permission"
  95. _DOCUMENT_TABLE = "doc_document"
  96. # 唯一约束/索引的目标形态(升级后)
  97. _NEW_UNIQUE = "uq_doc_permission_active"
  98. _NEW_UNIQUE_COLUMNS = {"document_id", "subject_id", "active_marker"}
  99. _NEW_INDEX = "ix_doc_permission_subject_active"
  100. _NEW_INDEX_COLUMNS = ["subject_id", "is_deleted"]
  101. # shape v3:附件分支去掉 visibility_type 条件(密级放宽沿用 0020 v2 口径)
  102. _SHAPE_V3 = "ck_doc_document_type_shape_v3"
  103. _SHAPE_V3_DDL = (
  104. "ALTER TABLE doc_document ADD CONSTRAINT " + _SHAPE_V3 + " CHECK ("
  105. "((document_type = 'MAIN') AND (parent_document_id IS NULL) "
  106. "AND (category_id IS NOT NULL) AND (attachment_type IS NULL)) "
  107. "OR ((document_type = 'SUB_PLAN') AND (parent_document_id IS NOT NULL) "
  108. "AND (root_document_id IS NOT NULL) AND (category_id IS NOT NULL) "
  109. "AND (attachment_type IS NULL)) "
  110. "OR ((document_type = 'ATTACHMENT') AND (parent_document_id IS NULL) "
  111. "AND (root_document_id IS NULL) AND (category_id IS NULL) "
  112. "AND (attachment_type IS NOT NULL)))"
  113. )
  114. def _columns_of(table: str) -> set[str]:
  115. inspector = sa.inspect(op.get_bind())
  116. return {column["name"] for column in inspector.get_columns(table)}
  117. def _unique_using(table: str, column: str) -> list[str]:
  118. """返回引用指定列的唯一约束实际名(兼容 convention 渲染与达梦大写名)。"""
  119. inspector = sa.inspect(op.get_bind())
  120. return [
  121. item["name"]
  122. for item in inspector.get_unique_constraints(table)
  123. if column in {col.lower() for col in item.get("column_names", [])}
  124. ]
  125. def _checks_referencing(table: str, column: str) -> list[str]:
  126. """返回 sqltext 引用指定列的 CHECK 约束实际名。"""
  127. inspector = sa.inspect(op.get_bind())
  128. return [
  129. item["name"]
  130. for item in inspector.get_check_constraints(table)
  131. if column in (item.get("sqltext") or "").lower()
  132. ]
  133. def _indexes_using(table: str, column: str) -> list[str]:
  134. """返回引用指定列的索引实际名。"""
  135. inspector = sa.inspect(op.get_bind())
  136. return [
  137. item["name"]
  138. for item in inspector.get_indexes(table)
  139. if column in {col.lower() for col in item.get("column_names", [])}
  140. ]
  141. def _run(statement: str) -> None:
  142. op.get_bind().execute(sa.text(statement))
  143. def _drop_unique(table: str, name: str) -> None:
  144. # MySQL 的唯一约束即唯一索引,须走 DROP INDEX;达梦为 DROP CONSTRAINT。
  145. if op.get_bind().dialect.name == "mysql":
  146. _run(f"ALTER TABLE {table} DROP INDEX {name}")
  147. else:
  148. _run(f"ALTER TABLE {table} DROP CONSTRAINT {name}")
  149. def _drop_index(table: str, name: str) -> None:
  150. if op.get_bind().dialect.name == "mysql":
  151. _run(f"ALTER TABLE {table} DROP INDEX {name}")
  152. else:
  153. _run(f"DROP INDEX {name}")
  154. def _has_unique_columns(table: str, columns: set[str]) -> bool:
  155. inspector = sa.inspect(op.get_bind())
  156. for item in inspector.get_unique_constraints(table):
  157. existing = {col.lower() for col in item.get("column_names", [])}
  158. if existing == columns:
  159. return True
  160. return False
  161. def _has_index_columns(table: str, columns: list[str]) -> bool:
  162. inspector = sa.inspect(op.get_bind())
  163. wanted = [col.lower() for col in columns]
  164. for item in inspector.get_indexes(table):
  165. existing = [col.lower() for col in item.get("column_names", [])]
  166. if existing == wanted:
  167. return True
  168. return False
  169. def _check_names(table: str) -> set[str]:
  170. return {
  171. item["name"].lower()
  172. for item in sa.inspect(op.get_bind()).get_check_constraints(table)
  173. }
  174. def _check_sql(values: tuple[str, ...]) -> str:
  175. # 安全性:values 为本文件内硬编码的枚举常量,非用户输入,无注入风险
  176. inline = ", ".join(f"'{v}'" for v in values)
  177. return f"action_type IN ({inline})"
  178. def _drop_action_check() -> None:
  179. # op.drop_constraint 按 metadata.naming_convention 前缀化约束名,
  180. # 须传原始约束名渲染最终名;达梦返回的约束名为大写,统一小写比较。
  181. existing = {
  182. item["name"].lower()
  183. for item in sa.inspect(op.get_bind()).get_check_constraints("sys_audit_log")
  184. }
  185. if f"ck_sys_audit_log_{ACTION_CONSTRAINT}" in existing:
  186. op.drop_constraint(ACTION_CONSTRAINT, "sys_audit_log", type_="check")
  187. def _upgrade_permission_table() -> None:
  188. existing = _columns_of(_PERMISSION_TABLE)
  189. # 1) ORG 主体行物理删除(组织授权语义整体下线,软删行一并清除)
  190. if "subject_type" in existing:
  191. _run(f"DELETE FROM {_PERMISSION_TABLE} WHERE subject_type = 'ORG'")
  192. # 2) 拆除引用 subject_type 的约束与索引(以库中实际名执行)
  193. for name in _unique_using(_PERMISSION_TABLE, "subject_type"):
  194. _drop_unique(_PERMISSION_TABLE, name)
  195. for name in _checks_referencing(_PERMISSION_TABLE, "subject_type"):
  196. _run(f"ALTER TABLE {_PERMISSION_TABLE} DROP CONSTRAINT {name}")
  197. for name in _indexes_using(_PERMISSION_TABLE, "subject_type"):
  198. _drop_index(_PERMISSION_TABLE, name)
  199. # 3) 删列 + 加列(幂等探测)
  200. for name in _DROPPED_COLUMNS:
  201. if name in existing:
  202. op.drop_column(_PERMISSION_TABLE, name)
  203. if "expires_at" not in existing:
  204. op.add_column(
  205. _PERMISSION_TABLE,
  206. sa.Column(
  207. "expires_at",
  208. sa.DateTime(),
  209. nullable=False,
  210. # 1970 哨兵=fail-safe:漏赋值路径插入的行立即过期拒绝访问
  211. server_default=sa.text(f"'{_SENTINEL}'"),
  212. ),
  213. )
  214. for name, type_ in _VOUCHER_COLUMNS:
  215. if name not in existing:
  216. op.add_column(_PERMISSION_TABLE, sa.Column(name, type_, nullable=True))
  217. # 4) 存量活跃 USER 行初始化为迁移时刻+7天(UTC,与业务时间戳口径一致;
  218. # 参数化规避 DATE_ADD 方言差异)
  219. deadline = (
  220. datetime.now(timezone.utc).replace(tzinfo=None) + _GRACE
  221. ).replace(microsecond=0)
  222. op.get_bind().execute(
  223. sa.text(
  224. f"UPDATE {_PERMISSION_TABLE} SET expires_at = :deadline "
  225. f"WHERE is_deleted = 0 AND expires_at = :sentinel"
  226. ),
  227. {"deadline": deadline, "sentinel": _SENTINEL},
  228. )
  229. # 5) 重建唯一约束与索引(收敛主体维度)
  230. if not _has_unique_columns(_PERMISSION_TABLE, _NEW_UNIQUE_COLUMNS):
  231. _run(
  232. f"ALTER TABLE {_PERMISSION_TABLE} ADD CONSTRAINT {_NEW_UNIQUE} "
  233. "UNIQUE (document_id, subject_id, active_marker)"
  234. )
  235. if not _has_index_columns(_PERMISSION_TABLE, _NEW_INDEX_COLUMNS):
  236. _run(
  237. f"CREATE INDEX {_NEW_INDEX} ON {_PERMISSION_TABLE} "
  238. "(subject_id, is_deleted)"
  239. )
  240. def _upgrade_document_table() -> None:
  241. existing = _columns_of(_DOCUMENT_TABLE)
  242. # 1) 拆除引用 visibility_type 的 CHECK 与索引(命中枚举约束与两代 shape)
  243. for name in _checks_referencing(_DOCUMENT_TABLE, "visibility_type"):
  244. _run(f"ALTER TABLE {_DOCUMENT_TABLE} DROP CONSTRAINT {name}")
  245. for name in _indexes_using(_DOCUMENT_TABLE, "visibility_type"):
  246. _drop_index(_DOCUMENT_TABLE, name)
  247. # 2) 删列 + 重建 shape v3(附件分支不再约束可见范围)
  248. if "visibility_type" in existing:
  249. op.drop_column(_DOCUMENT_TABLE, "visibility_type")
  250. if _SHAPE_V3.lower() not in _check_names(_DOCUMENT_TABLE):
  251. _run(_SHAPE_V3_DDL)
  252. def upgrade() -> None:
  253. _upgrade_permission_table()
  254. _upgrade_document_table()
  255. _drop_action_check()
  256. op.create_check_constraint(
  257. ACTION_CONSTRAINT,
  258. "sys_audit_log",
  259. _check_sql(AUDIT_ACTIONS_V9),
  260. )
  261. def downgrade() -> None:
  262. # ORG 主体行已物理删除、expires_at 数据在删列时丢失,降级仅重建结构。
  263. voucher_rows = op.get_bind().scalar(
  264. sa.text(
  265. "SELECT COUNT(*) FROM sys_audit_log WHERE action_type IN "
  266. "('UPLOAD_PERMISSION_VOUCHER', 'DELETE_PERMISSION_VOUCHER')"
  267. )
  268. )
  269. if voucher_rows:
  270. raise RuntimeError(
  271. "0026已存在审批单凭证审计记录,拒绝有损降级;"
  272. "请先备份并按数据治理流程处理"
  273. )
  274. _drop_action_check()
  275. op.create_check_constraint(
  276. ACTION_CONSTRAINT,
  277. "sys_audit_log",
  278. _check_sql(AUDIT_ACTIONS_V8),
  279. )
  280. # doc_document:重建 visibility_type(存量统一回退全员可见)
  281. for name in _checks_referencing(_DOCUMENT_TABLE, "document_type"):
  282. if name.lower() == _SHAPE_V3.lower():
  283. _run(f"ALTER TABLE {_DOCUMENT_TABLE} DROP CONSTRAINT {name}")
  284. document_columns = _columns_of(_DOCUMENT_TABLE)
  285. if "visibility_type" not in document_columns:
  286. op.add_column(
  287. _DOCUMENT_TABLE,
  288. sa.Column(
  289. "visibility_type",
  290. sa.String(length=20),
  291. nullable=False,
  292. server_default=sa.text("'ALL_AUTHENTICATED'"),
  293. ),
  294. )
  295. existing_checks = _check_names(_DOCUMENT_TABLE)
  296. if "ck_doc_document_document_visibility_type" not in existing_checks:
  297. _run(
  298. "ALTER TABLE doc_document ADD CONSTRAINT "
  299. "ck_doc_document_document_visibility_type CHECK ("
  300. "visibility_type IN ('ALL_AUTHENTICATED', 'ORGANIZATION', 'CUSTOM'))"
  301. )
  302. if "ck_doc_document_type_shape_v2" not in existing_checks:
  303. _run(
  304. "ALTER TABLE doc_document ADD CONSTRAINT ck_doc_document_type_shape_v2 "
  305. "CHECK ("
  306. "((document_type = 'MAIN') AND (parent_document_id IS NULL) "
  307. "AND (category_id IS NOT NULL) AND (attachment_type IS NULL)) "
  308. "OR ((document_type = 'SUB_PLAN') AND (parent_document_id IS NOT NULL) "
  309. "AND (root_document_id IS NOT NULL) AND (category_id IS NOT NULL) "
  310. "AND (attachment_type IS NULL)) "
  311. "OR ((document_type = 'ATTACHMENT') AND (parent_document_id IS NULL) "
  312. "AND (root_document_id IS NULL) AND (category_id IS NULL) "
  313. "AND (attachment_type IS NOT NULL) "
  314. "AND (visibility_type = 'ALL_AUTHENTICATED')))"
  315. )
  316. # doc_permission:拆除新约束/索引,恢复旧结构(subject_type 统一回填 USER)
  317. uniques = sa.inspect(op.get_bind()).get_unique_constraints(_PERMISSION_TABLE)
  318. for item in uniques:
  319. columns = {col.lower() for col in item.get("column_names", [])}
  320. if columns == _NEW_UNIQUE_COLUMNS:
  321. _drop_unique(_PERMISSION_TABLE, item["name"])
  322. break
  323. if _has_index_columns(_PERMISSION_TABLE, _NEW_INDEX_COLUMNS):
  324. _drop_index(_PERMISSION_TABLE, _NEW_INDEX)
  325. permission_columns = _columns_of(_PERMISSION_TABLE)
  326. for name, type_ in _VOUCHER_COLUMNS:
  327. if name in permission_columns:
  328. op.drop_column(_PERMISSION_TABLE, name)
  329. if "expires_at" in permission_columns:
  330. op.drop_column(_PERMISSION_TABLE, "expires_at")
  331. if "subject_type" not in permission_columns:
  332. op.add_column(
  333. _PERMISSION_TABLE,
  334. sa.Column(
  335. "subject_type",
  336. sa.String(length=20),
  337. nullable=False,
  338. server_default=sa.text("'USER'"),
  339. ),
  340. )
  341. for name in ("can_edit", "can_manage_permission", "can_delete"):
  342. if name not in permission_columns:
  343. op.add_column(
  344. _PERMISSION_TABLE,
  345. sa.Column(
  346. name,
  347. sa.Boolean(),
  348. nullable=False,
  349. server_default=sa.text("0"),
  350. ),
  351. )
  352. if not _has_unique_columns(
  353. _PERMISSION_TABLE,
  354. {"document_id", "subject_type", "subject_id", "active_marker"},
  355. ):
  356. _run(
  357. "ALTER TABLE doc_permission ADD CONSTRAINT uq_doc_permission_active "
  358. "UNIQUE (document_id, subject_type, subject_id, active_marker)"
  359. )
  360. if not _has_index_columns(
  361. _PERMISSION_TABLE, ["subject_type", "subject_id", "is_deleted"]
  362. ):
  363. _run(
  364. "CREATE INDEX ix_doc_permission_subject_active ON doc_permission "
  365. "(subject_type, subject_id, is_deleted)"
  366. )
  367. if "ck_doc_permission_permission_subject_type" not in (
  368. item["name"].lower()
  369. for item in sa.inspect(op.get_bind()).get_check_constraints(_PERMISSION_TABLE)
  370. ):
  371. _run(
  372. "ALTER TABLE doc_permission ADD CONSTRAINT "
  373. "ck_doc_permission_permission_subject_type CHECK "
  374. "(subject_type IN ('ORG', 'USER'))"
  375. )