"""Adopt orphan Bailian API keys into the canonical API-Key registry. Background ---------- Many Bailian (Alibaba Cloud Model Studio) DashScope ``sk-...`` keys were created directly in the Aliyun console — long before Helix's 申请 flow existed, or by users bypassing it. ``bailian.api_key_sync`` pulls every such key into ``bailian_api_keys`` (the live inventory), and an admin can pin an owner on each via "编辑负责人" (:meth:`app.domains.bailian.service.BailianService.reassign_api_key_owner`). But those rows never get an :class:`ApiKeyAssignment` — so they are invisible in the central "API Key 管理" registry AND in the owner's "我的 API Key" self-service view. The owner literally cannot see the key Helix already knows they own. This reconciler closes that gap. For every *live* Bailian key that (a) has an owner pinned and (b) is not yet linked to an assignment, it mints a synthetic :class:`ApiKeyAssignment` (``status='active'``, ``request_number='AK-ADOPT-'``, ``detail.source='adopted'``) assigned to the owner, and back-links ``bailian_api_keys.assignment_id``. Idempotent on both the back-link and the ``request_number`` unique key, so it is safe to run on every nightly ``bailian.api_key_sync`` tick. Honesty markers --------------- Adopted rows did NOT go through Feishu approval, so we never dress them up as if they did: * ``request_number`` is prefixed ``AK-ADOPT-`` (not ``AK--``). * ``approval_request_id`` / ``employee_request_id`` stay NULL. * ``detail.source = 'adopted'`` drives the "存量纳管" tag in the UI. * ``key_value`` stays NULL — Helix never held the console key's plaintext, so reveal is genuinely unavailable (the UI renders "-"). The key is real and in use, so ``status='active'`` is the truthful state — including for a leaver owner, where surfacing the still-active key is exactly the governance signal we want, not something to hide. """ from __future__ import annotations import uuid from datetime import UTC, datetime from decimal import Decimal from typing import Any from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.core.safe_logging import get_safe_logger from app.domains.apikey.models import ApiKeyAssignment, ApiKeyProvider from app.domains.bailian.models import BailianApiKey, BailianWorkspace from app.identity.person_models import IdentityPerson logger = get_safe_logger(__name__) # Canonical provider row the adopted assignments are filed under. Matched # by name — the same string the autogrant + frontend already key on. _BAILIAN_PROVIDER_NAME = "Alibaba Cloud Model Studio" # ``api_key_assignments.request_number`` is VARCHAR(64). ``AK-ADOPT-`` is # 9 chars; a numeric ModelStudio apiKeyId is ~6 digits so the natural id # fits comfortably. Guard the rare long handle (e.g. a RAM AccessKey id # that slipped in) by falling back to the row PK, which is equally stable # across re-runs so idempotency holds. _REQUEST_NUMBER_MAX = 64 _REQUEST_NUMBER_PREFIX = "AK-ADOPT-" def _adopt_request_number(key: BailianApiKey) -> str: candidate = f"{_REQUEST_NUMBER_PREFIX}{key.api_key_id}" if len(candidate) <= _REQUEST_NUMBER_MAX: return candidate return f"{_REQUEST_NUMBER_PREFIX}{key.id.hex[:12]}" async def adopt_orphan_bailian_keys( db: AsyncSession, *, integration_id: str | None = None, actor_id: uuid.UUID | None = None, trace_id: str | None = None, ) -> dict[str, Any]: """Mint assignments for owned-but-orphan Bailian keys. Args: db: active async session (caller owns the transaction / commit). integration_id: optionally restrict to one Bailian integration; ``None`` sweeps every integration. actor_id: person id stamped on the audit row; ``None`` ⇒ system. trace_id: correlation id for the audit rows. Returns: Summary dict — ``scanned`` (orphan-with-owner candidates), ``adopted`` (new assignments), ``relinked`` (existing ``AK-ADOPT-`` assignment re-attached to its inventory row), ``skipped_no_owner`` (orphan keys still missing an owner — left for an admin to pin), ``skipped_missing_person``. """ trace_id = trace_id or f"bailian-adopt-{uuid.uuid4().hex[:12]}" audit = AuditService(db) provider = ( await db.execute( select(ApiKeyProvider).where( ApiKeyProvider.name == _BAILIAN_PROVIDER_NAME, ) ) ).scalar_one_or_none() if provider is None: logger.warning( "bailian adopt: provider %r not found — nothing adopted", _BAILIAN_PROVIDER_NAME, ) return { "scanned": 0, "adopted": 0, "relinked": 0, "skipped_no_owner": 0, "skipped_missing_person": 0, "error": "provider_not_found", } # Count owner-less orphans separately so the summary can nudge admins # to pin owners — we can only adopt what someone owns. orphan_conditions = [ BailianApiKey.deleted_at.is_(None), BailianApiKey.assignment_id.is_(None), ] if integration_id is not None: orphan_conditions.append( BailianApiKey.integration_id == integration_id, ) orphans = ( await db.execute( select(BailianApiKey).where(and_(*orphan_conditions)) ) ).scalars().all() skipped_no_owner = sum(1 for k in orphans if not k.owner_person_id) candidates = [k for k in orphans if k.owner_person_id] adopted = 0 relinked = 0 enriched = 0 skipped_missing_person = 0 async def _bailian_revoke_blob(key: BailianApiKey) -> dict[str, Any]: """The delete-handle the revoke path reads so 回收 on an adopted key actually invalidates the console key in Aliyun. Carries ``adopted=True`` (NOT ``granted``) + the numeric ModelStudio ``api_key_id`` + workspace external id + integration. Deliberately omits ``iam_user_name``: a console key's RAM sub-account is shared across many keys, so revoke must delete the single key only and never tear down the shared sub-account (``_revoke_cloud_resources`` gates RAM teardown on ``granted``). """ workspace_external: str | None = None if key.workspace_pk is not None: ws = await db.get(BailianWorkspace, key.workspace_pk) workspace_external = ws.workspace_id if ws is not None else None return { "adopted": True, "api_key_id": key.api_key_id, "workspace_id": workspace_external, "integration_id": key.integration_id, } for key in candidates: try: owner_uuid = uuid.UUID(str(key.owner_person_id)) except (ValueError, AttributeError): skipped_missing_person += 1 logger.warning( "bailian adopt: key %s has unparseable owner %r — skipped", key.id, key.owner_person_id, ) continue person = await db.get(IdentityPerson, owner_uuid) if person is None or person.deleted_at is not None: skipped_missing_person += 1 logger.warning( "bailian adopt: key %s owner %s missing/deleted — skipped", key.id, owner_uuid, ) continue request_number = _adopt_request_number(key) # Capture the PK as a local BEFORE the savepoint. A ``begin_nested`` # rollback EXPIRES every attribute of an object mutated inside it # (we set ``key.assignment_id`` below), so reading ``key.id`` in the # except block would trigger an implicit async lazy-load → raises # ``MissingGreenlet`` (no active greenlet outside an explicit await). key_pk = key.id # Per-row SAVEPOINT: this loop mints+flushes one assignment per # iteration on a session shared with the caller. Without isolation # a single bad row (e.g. an ``IntegrityError`` on the # ``request_number`` unique key from a concurrent adopt run) would # poison the whole transaction and lose every other row's adoption # this tick. Begin-nested per row so one failure rolls back only # that row; the rest still adopt. Idempotent, so the failed row is # retried on the next tick. # Track the outcome inside the savepoint but only fold it into the # summary counters AFTER the savepoint commits — a Python ``+= 1`` # is NOT rolled back by ``begin_nested``, so incrementing in-block # would over-count a row whose savepoint later rolled back. did_adopt = False did_relink = False try: async with db.begin_nested(): # Idempotency: a prior run created the assignment but the # back-link was lost (e.g. an interrupted commit). Re-attach # rather than colliding on the request_number unique key. existing = ( await db.execute( select(ApiKeyAssignment).where( ApiKeyAssignment.request_number == request_number, ) ) ).scalar_one_or_none() if existing is not None: # Re-attach a lost back-link. Detail enrichment (incl. # the revoke handle) is handled by the linked-row pass # below, so this branch only fixes the FK. if key.assignment_id != existing.id: key.assignment_id = existing.id did_relink = True await db.flush() else: now = datetime.now(UTC) project_name = (key.display_name or key.api_key_id)[:256] assignment = ApiKeyAssignment( provider_id=provider.id, assigned_to=owner_uuid, status="active", request_number=request_number, project_name=project_name, reason=( "存量百炼 API Key 纳管:在阿里云控制台直接创建,由 Helix " "自动纳管以便归属与可见(未走飞书申请审批)。" ), monthly_budget_usd=Decimal("0"), iam_integration_id=key.integration_id, valid_from=( key.created_at.date() if key.created_at is not None else None ), detail={ "source": "adopted", "adopted_at": now.isoformat(), "bailian_api_key_pk": str(key.id), "api_key_id": key.api_key_id, "workspace_pk": ( str(key.workspace_pk) if key.workspace_pk else None ), # Delete-handle for the revoke path (see above). "bailian": await _bailian_revoke_blob(key), }, ) db.add(assignment) await db.flush() key.assignment_id = assignment.id await db.flush() did_adopt = True await audit.log( trace_id=trace_id, actor_type="user" if actor_id else "system", actor_user_id=actor_id, action="apikey.assignment.adopted", resource_type="api_key_assignment", resource_id=str(assignment.id), status="success", detail={ "request_number": request_number, "bailian_api_key_pk": str(key.id), "api_key_id": key.api_key_id, "owner_person_id": str(owner_uuid), "integration_id": key.integration_id, }, ) except Exception: # noqa: BLE001 logger.exception( "bailian adopt: failed to adopt key %s (request_number=%s)" " — skipped, will retry next tick", key_pk, request_number, ) continue # Savepoint committed cleanly — now it is safe to count it. if did_adopt: adopted += 1 if did_relink: relinked += 1 # ── Pass 2: self-heal already-linked adopted rows that predate the # revoke delete-handle. These rows have ``assignment_id`` set (so # pass 1 skipped them as non-orphans) but their assignment's # ``detail`` lacks ``bailian.api_key_id`` — without it 回收 is a # cloud no-op. We touch ONLY adopted-source rows; autogrant rows # own their own ``detail.bailian`` (with the dedicated RAM user) # and must not be rewritten. linked_conditions = [ BailianApiKey.deleted_at.is_(None), BailianApiKey.assignment_id.is_not(None), ] if integration_id is not None: linked_conditions.append( BailianApiKey.integration_id == integration_id, ) linked_keys = ( await db.execute( select(BailianApiKey).where(and_(*linked_conditions)) ) ).scalars().all() for key in linked_keys: assignment = await db.get(ApiKeyAssignment, key.assignment_id) if assignment is None: continue detail = dict(assignment.detail or {}) is_adopted = ( detail.get("source") == "adopted" or assignment.request_number.startswith(_REQUEST_NUMBER_PREFIX) ) if not is_adopted: continue if not (detail.get("bailian") or {}).get("api_key_id"): try: async with db.begin_nested(): detail["bailian"] = await _bailian_revoke_blob(key) assignment.detail = detail await db.flush() except Exception: # noqa: BLE001 logger.exception( "bailian adopt: failed to enrich assignment %s — skipped", key.assignment_id, ) continue # Count only after the savepoint commits (see pass-1 note). enriched += 1 return { "scanned": len(candidates), "adopted": adopted, "relinked": relinked, "enriched": enriched, "skipped_no_owner": skipped_no_owner, "skipped_missing_person": skipped_missing_person, }