"""add platform_configs (app-level config center — Feishu etc.) Revision ID: 026 Revises: 025 Create Date: 2026-04-17 Split of concern vs `integrations` (renamed to 连接器 on the UI): - `integrations` — per-connector external-system credentials shared by software connectors (e.g. Google Workspace Service Account). - `platform_configs` — platform-level app config (Feishu app_id / secret, approval codes, event tokens). One row per logical category. This migration also seeds the `feishu` row from any FEISHU_* env vars still present in the deploy environment — a one-shot bridge so production keeps working after this deploy removes env-based reads. Skipped when CREDENTIAL_ENCRYPTION_KEY is missing, when the row already exists, or when no env values are set. """ import json import os import uuid import sqlalchemy as sa from alembic import op from app.core.migration_helpers import id_type_for, table_kw_for revision = "026" down_revision = "025" def upgrade() -> None: ID = id_type_for("identity_persons") KW = table_kw_for("identity_persons") op.create_table( "platform_configs", sa.Column("id", ID, primary_key=True), sa.Column("category", sa.String(64), nullable=False), sa.Column("display_name", sa.String(128), nullable=False), sa.Column("description", sa.Text, nullable=True), sa.Column("secrets_encrypted", sa.Text, nullable=True), sa.Column("secrets_nonce", sa.String(64), nullable=True), sa.Column("public_config", sa.JSON, nullable=True), sa.Column("updated_by", ID, sa.ForeignKey("identity_persons.id"), nullable=True), 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()), **KW, ) op.create_unique_constraint( "uq_platform_configs_category", "platform_configs", ["category"], ) _seed_feishu_from_env() def _seed_feishu_from_env() -> None: """Bridge seed: pull the legacy FEISHU_* env vars (still set in prod at deploy time) into the new platform_configs table so Feishu-backed flows keep working after env reads are removed. Idempotent.""" app_id = (os.environ.get("FEISHU_APP_ID") or "").strip() app_secret = (os.environ.get("FEISHU_APP_SECRET") or "").strip() public_env_map = { "approval_laptop_replacement_code": "FEISHU_APPROVAL_LAPTOP_REPLACEMENT_CODE", "approval_computer_borrow_code": "FEISHU_APPROVAL_COMPUTER_BORROW_CODE", "approval_asset_return_code": "FEISHU_APPROVAL_ASSET_RETURN_CODE", "approval_offboarding_buyback_code": "FEISHU_APPROVAL_OFFBOARDING_BUYBACK_CODE", "approval_software_seat_code": "FEISHU_APPROVAL_SOFTWARE_SEAT_CODE", "approval_api_key_request_code": "FEISHU_APPROVAL_API_KEY_REQUEST_CODE", "alert_webhook_url": "FEISHU_ALERT_WEBHOOK_URL", } secret_env_map = { "event_verification_token": "FEISHU_EVENT_VERIFICATION_TOKEN", "event_encrypt_key": "FEISHU_EVENT_ENCRYPT_KEY", } public: dict[str, str] = {} if app_id: public["app_id"] = app_id for cfg_key, env_key in public_env_map.items(): v = (os.environ.get(env_key) or "").strip() if v: public[cfg_key] = v secrets: dict[str, str] = {} if app_secret: secrets["app_secret"] = app_secret for cfg_key, env_key in secret_env_map.items(): v = (os.environ.get(env_key) or "").strip() if v: secrets[cfg_key] = v if not public and not secrets: return # Nothing to seed — greenfield deploy. conn = op.get_bind() exists = conn.execute( sa.text("SELECT 1 FROM platform_configs WHERE category='feishu' LIMIT 1") ).scalar() if exists: return ciphertext = None nonce = None if secrets: try: from app.domains.cmdb.credential_crypto import encrypt_fields ciphertext, nonce = encrypt_fields(secrets) except Exception: # noqa: BLE001 — missing key is expected on fresh dev DBs ciphertext = None nonce = None conn.execute( sa.text( "INSERT INTO platform_configs " "(id, category, display_name, description, " " secrets_encrypted, secrets_nonce, public_config) " "VALUES (:id, 'feishu', :display_name, :description, " ":ciphertext, :nonce, :public_config)" ), { "id": str(uuid.uuid4()), "display_name": "飞书 (Lark)", "description": "Seeded from legacy FEISHU_* env vars at migration time.", "ciphertext": ciphertext, "nonce": nonce, "public_config": json.dumps(public, ensure_ascii=False) if public else None, }, ) def downgrade() -> None: op.drop_constraint("uq_platform_configs_category", "platform_configs", type_="unique") op.drop_table("platform_configs")