"""Cloud-subscription pull/write API — the cmdb data surface for the External API feature. GET /api/v1/open/cloud/subscriptions cloud.subscriptions:read GET /api/v1/open/cloud/subscriptions/{key} cloud.subscriptions:read PATCH /api/v1/open/cloud/subscriptions/{key} cloud.subscriptions:write The credential / token / rate-limit / audit / admin plane is the standalone External API feature (:mod:`app.api_clients`); this module only adds the cmdb data layer and gates each route on the cloud scope. It lives in the cmdb domain (not under ``app/api_clients``) because the data it serves — and the service it writes through — are cmdb's, so reading/writing ``cmdb_cloud_scopes`` here stays in-domain. The only cross-cutting import is :func:`require_api_client`, the shared auth dependency. Design contract (documented for integrators in ``docs/api/external-api.md``): * **Offset pagination** — ``page`` (1-based) + ``page_size`` (≤200). Cloud subscriptions are an admin-curated, low-volume inventory. * **Filters** — ``provider`` / ``environment`` / ``status`` (lifecycle: active·suspended = in use, closed = offline) / ``owner_email`` / ``q`` (keyword on display_name or external_id), all combinable. * **Owner exposure** — ``{name, email, department}`` (work email + primary department name). Work email is operational contact, not PII-masked. * **Write is metadata-only** — PATCH sets Helix-side fields (owner, environment, display_name, notes, budget); never the cloud resource, so the prod-write lock does not apply. Owner is set by ``owner_email`` (must resolve to an active, non-leaver person). Closed scopes reject all writes. * **Per-client whitelist** — ``api_clients.cloud_visibility`` narrows a client to a set of providers / subscriptions (see :mod:`app.domains.cmdb.open_visibility`). Unset = the whole inventory, which is what every client had before migration 470. A subscription outside the whitelist is ``404`` on read AND write — not ``403``, so the response never confirms that an out-of-scope account exists. """ from __future__ import annotations import re import uuid as uuid_mod from decimal import Decimal from typing import Any, Literal from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, Field, field_validator from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.api_clients.auth import ApiClientContext, require_api_client from app.api_clients.scopes import ( SCOPE_CLOUD_SUBSCRIPTIONS_READ, SCOPE_CLOUD_SUBSCRIPTIONS_WRITE, ) from app.core.database import get_db from app.core.dependencies import get_real_ip from app.core.exceptions import NotFoundError, ValidationError from app.core.response import paginated_response, success_response from app.domains.cmdb.lifecycle_service import ( CloudLifecycleService, scope_budget_measure, ) from app.domains.cmdb.models import CmdbCloudScope from app.domains.cmdb.open_visibility import ( ResolvedCloudVisibility, resolve_client_cloud_visibility, ) from app.identity.bu_helpers import department_segments as bu_department_segments from app.identity.person_models import IdentityOrgMembership, IdentityPerson from app.integrations.models import Integration router = APIRouter( prefix="/api/v1/open/cloud", tags=["open-cloud"], ) _DEFAULT_PAGE_SIZE = 50 _MAX_PAGE_SIZE = 200 _VALID_ENVIRONMENTS = {"dev", "staging", "prod"} _VALID_STATUSES = {"active", "suspended", "closed"} # Response examples — surfaced in the hosted OpenAPI/Redoc reference so an # integrator sees exactly what each endpoint returns. _SUBSCRIPTION_EXAMPLE = { "id": "b1c2d3e4-0000-0000-0000-000000000000", "external_id": "123456789012", "provider": "aws", "integration_name": "AWS Prod", "scope_type": "aws_account", "name": "RD Compute", "environment": "prod", "status": "active", "owner": { "name": "Zhang San", "email": "zhangsan@pingcap.com", "department": "RD Infra", "department_full_path": "Engineering Group / TiDB / RD Infra", "department_path_segments": ["Engineering Group", "TiDB", "RD Infra"], }, "budget_usd_monthly": 5000.0, "budget_measure": "net", "account_email": "aws-rd-compute@pingcap.com", "cloud_account_email": "aws-rd-compute@pingcap.com", "account_email_override": None, "updated_at": "2026-06-15T08:30:00+00:00", } _LIST_EXAMPLE = { "success": True, "data": [_SUBSCRIPTION_EXAMPLE], "meta": {"trace_id": "…", "page": 1, "page_size": 50, "total": 1}, "errors": [], } _SINGLE_EXAMPLE = { "success": True, "data": _SUBSCRIPTION_EXAMPLE, "meta": {"trace_id": "…"}, "errors": [], } def _example(payload: dict) -> dict: return {200: {"content": {"application/json": {"example": payload}}}} # ── Serialization ───────────────────────────────────────────────────── def _serialize( scope: CmdbCloudScope, *, integration: Integration | None, owner: IdentityPerson | None, owner_department: str | None, owner_department_path: str | None, ) -> dict[str, Any]: """Wire shape for one cloud subscription. ``owner`` is ``{name, email, department, department_full_path, department_path_segments}`` (work email + the owner's primary department name, plus the full department chain) or ``null`` for an unowned scope. ``status`` is the lifecycle state — ``active`` / ``suspended`` are in-use, ``closed`` is decommissioned (offline). """ department_segments = bu_department_segments(owner_department_path) return { "id": str(scope.id), "external_id": scope.external_id, "provider": integration.provider if integration else None, "integration_name": integration.name if integration else None, "scope_type": scope.scope_type, "name": scope.display_name, "environment": scope.environment, "status": scope.status, "owner": ( { "name": owner.display_name, "email": owner.work_email, "department": owner_department, # Full department chain (tenant root "PingCAP" stripped), # same derivation as IdentityPerson.department_chain. The # slash-joined string and the pre-split segment list are # both offered so a consumer needn't re-split (and needn't # guess whether the delimiter is padded). Empty path → null # string + [] so the fields are always present. "department_full_path": ( " / ".join(department_segments) if department_segments else None ), "department_path_segments": department_segments, } if owner is not None else None ), "budget_usd_monthly": ( float(scope.budget_usd_monthly) if scope.budget_usd_monthly is not None else None ), # Which cost basis the budget figure is denominated in — a bare USD # number is not interpretable without it once any scope has been # re-denominated to the pre-discount list measure. "budget_measure": scope_budget_measure(scope), # Account contact email. ``account_email`` is the effective value # an integrator should use (owner override if set, else the value # the cloud provider reported — AWS Organizations account # ``Email``). ``cloud_account_email`` / ``account_email_override`` # are exposed too so a consumer can tell a synced value apart from # a human correction. Refreshed from the cloud on every sync; # ``null`` when the provider reports no email (non-AWS today). "account_email": ( scope.account_email_override or scope.cloud_account_email ), "cloud_account_email": scope.cloud_account_email, "account_email_override": scope.account_email_override, "updated_at": ( scope.updated_at.isoformat() if scope.updated_at else None ), } async def _serialize_scopes( db: AsyncSession, scopes: list[CmdbCloudScope], ) -> list[dict[str, Any]]: """Batch-load integrations + owner persons + each owner's primary department (avoids N+1) and serialize a page of scopes.""" if not scopes: return [] integ_ids = {s.integration_id for s in scopes if s.integration_id} owner_ids = {s.owner_person_id for s in scopes if s.owner_person_id} integ_by_id: dict[str, Integration] = {} owner_by_id: dict[str, IdentityPerson] = {} dept_by_owner: dict[str, str] = {} dept_path_by_owner: dict[str, str] = {} if integ_ids: rows = (await db.execute( select(Integration).where( Integration.id.in_([uuid_mod.UUID(i) for i in integ_ids]), ) )).scalars().all() integ_by_id = {str(r.id): r for r in rows} if owner_ids: owner_uuids = [uuid_mod.UUID(i) for i in owner_ids] rows = (await db.execute( select(IdentityPerson).where(IdentityPerson.id.in_(owner_uuids)) )).scalars().all() owner_by_id = {str(r.id): r for r in rows} # Owner's primary department name — same source as the directory # API's person.department (the primary, non-deleted membership). mrows = (await db.execute( select( IdentityOrgMembership.person_id, IdentityOrgMembership.org_node_name, IdentityOrgMembership.org_path, ).where( IdentityOrgMembership.person_id.in_(owner_uuids), IdentityOrgMembership.is_primary.is_(True), IdentityOrgMembership.deleted_at.is_(None), ) )).all() dept_by_owner = {str(pid): name for pid, name, _ in mrows} dept_path_by_owner = {str(pid): path for pid, _, path in mrows} return [ _serialize( s, integration=integ_by_id.get(str(s.integration_id)), owner=owner_by_id.get(s.owner_person_id) if s.owner_person_id else None, owner_department=( dept_by_owner.get(s.owner_person_id) if s.owner_person_id else None ), owner_department_path=( dept_path_by_owner.get(s.owner_person_id) if s.owner_person_id else None ), ) for s in scopes ] async def _serialize_one(db: AsyncSession, scope: CmdbCloudScope) -> dict[str, Any]: return (await _serialize_scopes(db, [scope]))[0] async def _resolve_one( db: AsyncSession, key: str, visibility: ResolvedCloudVisibility, ) -> CmdbCloudScope: """Resolve ``{key}`` (Helix UUID or cloud-native external id) to a single non-deleted scope the client is allowed to see. Whitelist handling is deliberately part of resolution, not a check bolted on after it: * an out-of-whitelist subscription raises ``404`` (never ``403``) — a 403 would confirm the account exists to a client not entitled to know that; * the external-id branch filters to visible rows BEFORE the ambiguity test, so a client whitelisted to exactly one of two same-numbered accounts gets its own row instead of a 400 about a collision it cannot see. """ try: scope_uuid = uuid_mod.UUID(key) except (ValueError, TypeError): scope_uuid = None if scope_uuid is not None: # Explicit select (not db.get) so every mapped column — including # the server-default ``created_at`` / ``updated_at`` — is loaded; # a lazy refresh would trip MissingGreenlet on the async session. scope = (await db.execute( select(CmdbCloudScope).where(CmdbCloudScope.id == scope_uuid) )).scalars().first() if ( scope is not None and scope.deleted_at is None and visibility.allows(scope) ): return scope # fall through — a UUID-shaped value could be an external id. matches = [ s for s in (await db.execute( select(CmdbCloudScope).where( CmdbCloudScope.external_id == key, CmdbCloudScope.deleted_at.is_(None), ) )).scalars().all() if visibility.allows(s) ] if not matches: raise NotFoundError("CloudSubscription", key) if len(matches) > 1: raise ValidationError( "external id matches multiple subscriptions across tenants; " "use the Helix subscription id instead.", ) return matches[0] # ── Read ────────────────────────────────────────────────────────────── @router.get( "/subscriptions", summary="List cloud subscriptions", responses=_example(_LIST_EXAMPLE), ) async def list_subscriptions( request: Request, ctx: ApiClientContext = Depends( require_api_client(SCOPE_CLOUD_SUBSCRIPTIONS_READ), ), db: AsyncSession = Depends(get_db), provider: str | None = Query(default=None, max_length=64), environment: str | None = Query(default=None), status: str | None = Query(default=None), owner_email: str | None = Query( default=None, max_length=255, description="Filter to subscriptions owned by this work email.", ), q: str | None = Query( default=None, max_length=128, description="Keyword match on display name or external id.", ), page: int = Query(default=1, ge=1), page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=_MAX_PAGE_SIZE), ) -> dict: if environment is not None and environment not in _VALID_ENVIRONMENTS: raise ValidationError( f"environment must be one of {sorted(_VALID_ENVIRONMENTS)}", ) if status is not None and status not in _VALID_STATUSES: raise ValidationError( f"status must be one of {sorted(_VALID_STATUSES)}", ) empty = paginated_response( data=[], total=0, page=page, page_size=page_size, request=request, ) visibility = await resolve_client_cloud_visibility(db, ctx) conds = [CmdbCloudScope.deleted_at.is_(None), *visibility.scope_conditions()] if environment is not None: conds.append(CmdbCloudScope.environment == environment) if status is not None: conds.append(CmdbCloudScope.status == status) if provider is not None: integ_ids = (await db.execute( select(Integration.id).where(Integration.provider == provider) )).scalars().all() if not integ_ids: return empty conds.append( CmdbCloudScope.integration_id.in_([str(i) for i in integ_ids]), ) if owner_email is not None and owner_email.strip(): email = owner_email.strip().lower() person_ids = (await db.execute( select(IdentityPerson.id).where( func.lower(IdentityPerson.work_email) == email, ) )).scalars().all() if not person_ids: return empty conds.append( CmdbCloudScope.owner_person_id.in_([str(i) for i in person_ids]), ) if q is not None and q.strip(): like = f"%{q.strip()}%" conds.append(or_( CmdbCloudScope.display_name.ilike(like), CmdbCloudScope.external_id.ilike(like), )) total = int((await db.execute( select(func.count()).select_from(CmdbCloudScope).where(*conds) )).scalar() or 0) rows = (await db.execute( select(CmdbCloudScope) .where(*conds) # Deterministic offset-pagination order: external_id then id. .order_by(CmdbCloudScope.external_id.asc(), CmdbCloudScope.id.asc()) .offset((page - 1) * page_size) .limit(page_size) )).scalars().all() items = await _serialize_scopes(db, list(rows)) return paginated_response( data=items, total=total, page=page, page_size=page_size, request=request, ) @router.get( "/subscriptions/{key}", summary="Get one cloud subscription", responses=_example(_SINGLE_EXAMPLE), ) async def get_subscription( key: str, request: Request, ctx: ApiClientContext = Depends( require_api_client(SCOPE_CLOUD_SUBSCRIPTIONS_READ), ), db: AsyncSession = Depends(get_db), ) -> dict: visibility = await resolve_client_cloud_visibility(db, ctx) scope = await _resolve_one(db, key, visibility) data = await _serialize_one(db, scope) return success_response(data=data, request=request) # ── Write ───────────────────────────────────────────────────────────── class CloudSubscriptionPatch(BaseModel): """PATCH body — every field optional; at least one required. ``owner_email`` resolves to an active (non-leaver) person. Omitted / ``null`` means "leave unchanged"; ``display_name`` / ``notes`` accept ``""`` to clear, but ``owner`` / ``budget`` cannot be nulled out via this API (admin action in the Helix console).""" owner_email: str | None = Field(default=None, max_length=255) environment: Literal["dev", "staging", "prod"] | None = None display_name: str | None = Field(default=None, max_length=255) notes: str | None = Field(default=None, max_length=4000) budget_usd_monthly: Decimal | None = Field(default=None, ge=0) # Cost basis ``budget_usd_monthly`` is denominated in: ``"net"`` (the # invoice/实付 figure — the default every budget was historically # entered in) or ``"list"`` (业务折前, pre-company-discount). Required # only when the scope's budget is ALREADY recorded as ``list``; writing # a bare number there is rejected with 400 # ``CLOUD_BUDGET_MEASURE_REQUIRED`` because the two figures differ by # the discount ratio (~2.7× on a 55%-discount account) and guessing # silently mis-denominates every percentage alert threshold. budget_measure: Literal["net", "list"] | None = None # Owner/admin override of the cloud-reported account email. Omitted / # ``null`` = leave unchanged; ``""`` clears the override (the effective # ``account_email`` reverts to the cloud value); a non-empty value must # be a valid email. The cloud-synced ``cloud_account_email`` itself is # provider-owned and cannot be written through this API. account_email_override: str | None = Field(default=None, max_length=320) @field_validator("account_email_override") @classmethod def _validate_account_email_override(cls, v: str | None) -> str | None: if v is None: return v v = v.strip() if v == "": return "" if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", v): raise ValueError( "account_email_override must be a valid email address", ) return v @router.patch( "/subscriptions/{key}", summary="Update one cloud subscription", responses=_example(_SINGLE_EXAMPLE), ) async def update_subscription( key: str, body: CloudSubscriptionPatch, request: Request, ctx: ApiClientContext = Depends( require_api_client(SCOPE_CLOUD_SUBSCRIPTIONS_WRITE), ), db: AsyncSession = Depends(get_db), ) -> dict: provided = body.model_dump(exclude_unset=True) if not provided: raise ValidationError("No updatable fields supplied.") visibility = await resolve_client_cloud_visibility(db, ctx) scope = await _resolve_one(db, key, visibility) # Resolve owner_email → person id (the service applies the # active/non-leaver guard). Lower-cased exact match on work_email. owner_person_id: str | None = None if body.owner_email is not None: email = body.owner_email.strip().lower() if not email: raise ValidationError("owner_email must not be empty.") owner = (await db.execute( select(IdentityPerson).where( func.lower(IdentityPerson.work_email) == email, IdentityPerson.deleted_at.is_(None), ) )).scalars().first() if owner is None: raise ValidationError( f"No active person found with work email {email!r}.", ) owner_person_id = str(owner.id) service = CloudLifecycleService(db) scope = await service.apply_open_update( str(scope.id), owner_person_id=owner_person_id, environment=body.environment, display_name=body.display_name, notes=body.notes, budget_usd_monthly=body.budget_usd_monthly, budget_measure=body.budget_measure, account_email_override=body.account_email_override, client_id=ctx.client_id, client_slug=ctx.slug, client_ip=get_real_ip(request), trace_id=getattr(request.state, "trace_id", None) or str(uuid_mod.uuid4()), ) scope_id = str(scope.id) await db.commit() # Re-fetch via select (not the mutated instance) so every mapped # column is loaded — a no-op idempotent write never touches # ``updated_at`` and a lazy refresh would trip MissingGreenlet. scope = await _resolve_one(db, scope_id, visibility) data = await _serialize_one(db, scope) return success_response(data=data, request=request) __all__ = ["router"]