"""Production cloud-account write lock — default LOCKED. Why this exists --------------- A production cloud account (``CmdbCloudScope.environment == 'prod'``) is the highest-blast-radius object in Helix: adding a member, releasing a role, transferring ownership, or decommissioning it all reach the real cloud provider's IAM. A single careless click against a prod scope is a production incident. This module is the global kill-switch the user asked for after a role was released on a production account in error: **no one** — not a plain employee, not a scope owner, not a ``cloud_admin``, not a ``global_admin`` / ``it_admin`` — may perform a write operation on a production cloud scope while the lock is engaged. The lock is engaged by default (the setting is *opt-in* to allow writes), so a fresh database is safe-by-default. Setting ------- ``cmdb.prod_cloud_writes_enabled`` (platform_settings, JSON boolean): * missing / ``false`` / any non-truthy value → **LOCKED** (writes blocked) * ``true`` → writes allowed The intended operating posture is: stay locked. When IT genuinely needs to change a prod scope, an admin flips the setting ``true`` in ``Settings → 平台设置``, performs the change, and flips it back to ``false`` immediately. The setting row carries that instruction in its description. Where the gate is enforced -------------------------- At every **human** entry point that mutates a specific cloud scope: * HTTP router — ``app/domains/cmdb/lifecycle_router.py`` (every per-scope write + every grant lifecycle write resolves the target scope and calls :func:`ensure_prod_scope_writable`). * MCP tools — ``app/mcp/tools.py`` (the self-service cloud-write tools). System automation is intentionally **NOT** gated here: offboarding leaver sweeps and TTL-expiry sweeps call ``CloudLifecycleService`` directly with ``suppress_driver_error=True``. A departing employee must still lose production access regardless of the lock — the lock is about *discretionary human operations*, not *automated cleanup*. Carve-outs (Helix-metadata-only writes that never reach provider IAM): * ``environment``-only PATCH (2026-06-04) — escape hatch for a scope mis-marked prod; admin-gated via ``can_manage_cloud``. * Owner transfer by a cloud / global admin (2026-06-12) — ``assign_scope_owner`` is a DB rebind + audit row only; admins must be able to re-seat ownership (e.g. off a leaver) while prod stays locked. Owner SELF-transfer is NOT exempt — a non-admin owner on a locked prod scope still gets 403. """ from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import ProdCloudLockedError from app.domains.cmdb.models import PROD_ENVIRONMENTS, CmdbCloudScope # ``app.integrations`` is shared infra (the platform_settings store), # not a peer domain — cmdb already imports PlatformSetting from it (see # console_urls.py). Importing here does not violate the # domains-don't-import-domains rule. from app.integrations.models import PlatformSetting SETTING_KEY = "cmdb.prod_cloud_writes_enabled" # Surfaced to the user verbatim on the 403. Kept here (not in the i18n # bundle) because it must be intelligible at the API boundary for curl / # MCP clients too; the frontend additionally renders its own localized # banner via the per-scope ``write_locked`` flag before the call is even # attempted. LOCKED_MESSAGE = ( "生产环境云账号的写操作已被全局锁定。任何人(普通员工 / 子账号 owner / " "云管理员 / IT 管理员)都不能对生产云账号执行加人、释放角色、续期、" "改配置、关停等操作 —— 这是为防止误操作生产而设置的保护。" "例外:云管理员 / IT 管理员可在锁定状态下转让 owner(纯 Helix 元数据," "不触达云厂商 IAM);owner 本人自转让仍被锁挡。" "如确需其它变更:请 IT 管理员在「设置 → 平台设置」中临时将 " "cmdb.prod_cloud_writes_enabled 开为 true,完成操作后立即改回 false。" ) async def prod_cloud_writes_enabled(db: AsyncSession) -> bool: """Return ``True`` only when an admin has explicitly enabled prod cloud writes. Safe default is LOCKED — a missing row or any non-truthy value reads as ``False``. """ row = await db.get(PlatformSetting, SETTING_KEY) if row is None: return False return _coerce_truthy(row.value) # ── DNS write control (2026-07-06) ─────────────────────────────────── # # DNS record changes (create / update / delete an A/CNAME/TXT/… record # via the self-service → Feishu approval → execute path) are a MUCH # lower blast-radius operation than a cloud-IAM scope mutation: they # touch one DNS record on a zone, they are trivially reversible (re-add # the record), and they do not grant or revoke access to anything. They # were nonetheless coupled to the ``cmdb.prod_cloud_writes_enabled`` # kill-switch (the same lock that guards prod-IAM writes) — so an # incident freeze meant to stop accidental prod-IAM mutation ALSO stalled # every already-approved DNS request, failing them with no auto-resume. # # Decouple them: DNS writes get their OWN switch, defaulting to ALLOWED # (opt-OUT, the inverse posture of the IAM lock). A missing row reads as # enabled — a fresh database lets DNS requests execute. IT can still # freeze DNS specifically by flipping this to ``false``, but doing so no # longer requires freezing all cloud-IAM writes, and vice-versa. DNS_WRITES_SETTING_KEY = "cmdb.dns_writes_enabled" # Surfaced verbatim on the deferred-execute path when DNS writes are # explicitly frozen. Kept out of the i18n bundle for the same reason as # ``LOCKED_MESSAGE`` (must be intelligible at the API / MCP boundary). DNS_LOCKED_MESSAGE = ( "DNS 解析记录的写操作当前被管控开关锁定 (cmdb.dns_writes_enabled=false)。" "已审批通过的 DNS 变更暂不会执行。请 IT 管理员在「设置 → 平台设置」中" "将 cmdb.dns_writes_enabled 改回 true 后,对失败的申请点「重跑」即可。" ) async def dns_writes_enabled(db: AsyncSession) -> bool: """Return whether self-service DNS record writes may execute. Opt-OUT: the safe/normal posture is ENABLED. A missing row reads as ``True`` so a fresh database (and every environment that never touched this key) executes DNS requests. Only an explicit non-truthy value locks DNS writes — deliberately the inverse default of :func:`prod_cloud_writes_enabled` (the prod-IAM lock), because a DNS record change is low blast-radius and easily reversible. """ row = await db.get(PlatformSetting, DNS_WRITES_SETTING_KEY) if row is None: return True return _coerce_truthy(row.value) async def dns_writes_locked(db: AsyncSession) -> bool: """Convenience inverse of :func:`dns_writes_enabled`.""" return not await dns_writes_enabled(db) def _coerce_truthy(raw: object) -> bool: """JSON platform-setting value → bool. Shared by both switches.""" if isinstance(raw, bool): return raw if isinstance(raw, str): return raw.strip().lower() in ("true", "1", "yes", "on") if isinstance(raw, (int, float)): return bool(raw) return False async def prod_cloud_writes_locked(db: AsyncSession) -> bool: """Convenience inverse of :func:`prod_cloud_writes_enabled`. Used by the serializers to stamp a per-scope / per-grant ``write_locked`` flag so the frontend can disable action buttons and show the explainer *before* a blocked call is attempted. """ return not await prod_cloud_writes_enabled(db) def is_prod_scope(scope: CmdbCloudScope | None) -> bool: return scope is not None and scope.environment in PROD_ENVIRONMENTS async def ensure_prod_scope_writable( db: AsyncSession, scope: CmdbCloudScope | None, ) -> None: """Raise :class:`ProdCloudLockedError` (403) if ``scope`` is a production scope and the global prod-write lock is engaged. Role-independent by design — the caller's role is never consulted. Even ``global_admin`` is blocked while locked; that is the whole point of the kill-switch. No-op for non-prod scopes and for ``None`` (nothing to protect). """ if not is_prod_scope(scope): return if await prod_cloud_writes_enabled(db): return raise ProdCloudLockedError(LOCKED_MESSAGE)