"""云角色「默认下拉」的开关要活过下一个子账号的发现 ## 这是在解决什么 管理员在 云角色目录 里把一个角色的「默认下拉?」关掉,员工的 申请云访问 主角色下拉框里**它还在**。 `is_promoted` 存在 `(integration_id, scope_id, role_key)` 这一行上,而 promote/demote 端点只能改「点下去那一刻已经存在的行」。之后新发现的每个 子账号都会由 roles_sync / 单 scope 预热插入一批**新行**,新行的 `is_promoted` 来自驱动的默认分类 —— 所有驱动对 `is_custom=True` 一律给 `is_promoted=True`。于是被关掉的角色在新子账号上原样复活。 生产实测(2026-09-07):`alicloud_intl` 的 10 个 `DBaaS-Prod-*` CloudSSO 角色在 2026-06-11 被逐个 demote(审计里每条 `rows_affected=21`),而 6-13 至 8-24 之间新接入的 8 个子账号上它们全是 `is_promoted=1`; `BaiLian` / `rds-secret` 同样(2026-07-09 demote 了 25 行,新的 4 个上还开着)。 更糟的是目录页把 `is_promoted` 按「跨 scope 取一致值」上报,8/29 这种混合 状态显示成「否」,和「全部关掉」长得一模一样 —— 管理员看不见,也点不掉 (开关已经在「否」的位置,再点是开)。 ## 这次加的列 `cmdb_cloud_roles.promotion_overridden_by_admin` —— 管理员在 `(integration, role_key)` 这个粒度上的决定。写侧(promote / promote-by-key) 置 1;`重置为默认` 清 0;roles_sync 用它给**新行**播种,并把跨 scope 漂移的 行重新盖成同一个值。姿势与既有的 `governance_overridden_by_admin` 一致。 ## 回填只做能证明的那一半 `is_custom=1 AND is_promoted=0` ⇒ **一定**是管理员关的:七个云驱动里 `is_custom=True` 的角色一律以 `is_promoted=True` 插入(azure/aliyun/tencent 是 `is_custom or key in promoted_keys`,alicloud_intl / aws / ucloud / gcp 自定义角色写死 `True`),而 `重置为默认` 的目标值 `is_custom or key in defaults` 对自定义角色也恒为 True。没有任何非管理员写者能产出这个组合。 生产上命中 12 个 alicloud_intl 角色键(正是上面那 12 个)+ 66 个 azure、 11 个 gcp 自定义角色(它们本来就已经全 scope 一致,打上标记只是让决定变得 持久)。 **不回填**的那一半:`is_custom=0 AND is_promoted=0`。内置角色的「关」既可能 是管理员点的、也可能就是驱动默认值,数据上分不开(生产上 31 个 AWS permission set 是 32/100 的混合态,方向大概率也是「关」,但那是推测)。 它们留给目录页新加的「不一致」筛选 + 「统一为否」按钮,由管理员按意图点一次 —— 猜一个方向去改 100 行的授权可见性,不是迁移该干的事。 Revision ID: 623 Revises: 622 ⚠️ 迁移号对着 ``origin/main`` 抢的(622 是当时的 head),push 前重新核一次。 """ from __future__ import annotations import sqlalchemy as sa from alembic import op revision = "623" down_revision = "622" branch_labels = None depends_on = None ROLES = "cmdb_cloud_roles" COL = "promotion_overridden_by_admin" def _has_table(bind, name: str) -> bool: return bind.execute( sa.text( "SELECT 1 FROM information_schema.tables " "WHERE table_schema = DATABASE() AND table_name = :n" ), {"n": name}, ).scalar() is not None def _has_column(bind, table: str, column: str) -> bool: return bind.execute( sa.text( "SELECT 1 FROM information_schema.columns " "WHERE table_schema = DATABASE() " "AND table_name = :t AND column_name = :c" ), {"t": table, "c": column}, ).scalar() is not None def upgrade() -> None: bind = op.get_bind() if not _has_table(bind, ROLES): return if not _has_column(bind, ROLES, COL): op.add_column(ROLES, sa.Column( COL, sa.Boolean(), nullable=False, server_default="0", )) # Provable half of the backfill (see the module docstring): a custom # role that is NOT promoted can only have been demoted by an admin. # Soft-deleted rows included on purpose — a revived row must come # back carrying the decision, not the driver default. bind.execute(sa.text( f"UPDATE {ROLES} SET {COL} = 1 " "WHERE is_custom = 1 AND is_promoted = 0" )) def downgrade() -> None: bind = op.get_bind() if _has_table(bind, ROLES) and _has_column(bind, ROLES, COL): op.drop_column(ROLES, COL)