"""Resolve an API client's cloud whitelist against real cmdb rows. The wire shape (``{"providers": [...], "subscription_ids": [...]}``) is owned by :mod:`app.api_clients.visibility` — platform-level, SQL-free. Turning that shape into a WHERE clause means reading ``integrations`` and ``cmdb_cloud_scopes``, which is cmdb's data, so it lives here (same reasoning that put ``open_cloud_router`` in this domain rather than under ``app/api_clients``). The one invariant every caller depends on: **A restricted client whose whitelist matches nothing sees nothing.** A whitelist naming a provider with no integrations, or subscriptions that were since deleted, resolves to ``false()`` — never to "no constraint". Fail-closed is the only safe direction for a filter whose whole job is withholding data. The same rule covers a whitelist that cannot be READ at all (a stored value that is not a JSON object): it resolves to ``false()`` here rather than to "unrestricted". That decision lives in this one function on purpose — every cloud route reaches its whitelist through ``resolve_client_cloud_visibility``, so a route added tomorrow inherits the fail-closed posture without having to remember it. """ from __future__ import annotations import uuid as uuid_mod from dataclasses import dataclass from typing import TYPE_CHECKING, Any from sqlalchemy import false, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.api_clients.visibility import ( cloud_visibility_is_malformed, cloud_visibility_is_unrestricted, ) from app.domains.cmdb.models import CmdbCloudScope from app.integrations.models import Integration if TYPE_CHECKING: # import cycle: auth imports the visibility shape module from app.api_clients.auth import ApiClientContext @dataclass(frozen=True) class ResolvedCloudVisibility: """A client's cloud whitelist, resolved against current cmdb rows.""" #: True = every subscription is visible (NULL / empty whitelist). unrestricted: bool #: Integrations selected by the whitelist's ``providers`` list. integration_ids: tuple[str, ...] = () #: Individually named subscriptions (Helix UUIDs, canonical strings). subscription_ids: tuple[str, ...] = () #: Providers as requested — kept for error messages / ``/open/me`` #: even when they resolve to zero integrations. providers: tuple[str, ...] = () #: True when the stored whitelist was present but unreadable (not a #: JSON object). Implies ``unrestricted=False`` with both id tuples #: empty, so ``scope_conditions()`` is already ``[false()]`` and #: ``allows()`` already ``False`` — this flag exists so ``/open/me`` #: can say WHY the client suddenly sees nothing instead of leaving #: the integrator to guess at an empty result set. malformed: bool = False def scope_conditions(self) -> list[Any]: """WHERE terms to AND into any ``CmdbCloudScope`` query. Empty list when unrestricted. Otherwise exactly one term — the union of the two whitelist halves, or ``false()`` when neither half resolves to anything. """ if self.unrestricted: return [] terms: list[Any] = [] if self.integration_ids: terms.append(CmdbCloudScope.integration_id.in_(self.integration_ids)) if self.subscription_ids: terms.append(CmdbCloudScope.id.in_( [uuid_mod.UUID(s) for s in self.subscription_ids], )) if not terms: return [false()] return [or_(*terms)] if len(terms) > 1 else [terms[0]] def allows(self, scope: CmdbCloudScope) -> bool: """In-Python mirror of :meth:`scope_conditions` for a row already loaded (the ``/{key}`` lookups resolve first, then authorize).""" if self.unrestricted: return True if scope.integration_id and str(scope.integration_id) in self.integration_ids: return True return str(scope.id) in self.subscription_ids def as_meta(self) -> dict[str, Any]: """Self-describing block for ``/open/me`` — an integrator seeing fewer accounts than expected can tell whether the whitelist or a filter is responsible, without asking an admin.""" if self.malformed: # Reporting ``restricted: false`` here would be a lie in the # one place built to explain an unexpectedly empty result # set: the client would read "unrestricted", get zero rows, # and have nothing to go on. return { "restricted": True, "misconfigured": True, "detail": ( "The stored cloud whitelist is not readable, so this " "credential currently resolves to no cloud " "subscriptions at all. Ask a Helix admin to re-save it." ), } if self.unrestricted: return {"restricted": False} return { "restricted": True, "providers": list(self.providers), "subscription_count": len(self.subscription_ids), } async def resolve_cloud_visibility( db: AsyncSession, raw: Any, *, malformed: bool = False, ) -> ResolvedCloudVisibility: """Resolve a stored ``cloud_visibility`` value for query building. Deleted subscriptions are NOT filtered out here: the whitelist is a grant, and ``deleted_at`` filtering belongs to each query's own predicate. Naming a since-deleted subscription simply matches no row. ``malformed=True`` (or a ``raw`` that is itself unreadable) resolves to a whitelist matching NOTHING. Callers holding an ``ApiClientContext`` should prefer :func:`resolve_client_cloud_visibility`, which passes the flag for them — the context coerces an unreadable stored value to ``None``, so ``raw`` alone cannot distinguish "unrestricted" from "unreadable". """ # Checked before the unrestricted predicate, which reads any non-dict # as "unrestricted". Both spellings are honoured: the flag (from a # context whose value was already coerced) and a raw value handed in # directly, so neither entry point can fail open. if malformed or cloud_visibility_is_malformed(raw): return ResolvedCloudVisibility(unrestricted=False, malformed=True) if cloud_visibility_is_unrestricted(raw): return ResolvedCloudVisibility(unrestricted=True) providers = tuple(raw.get("providers") or []) subscription_ids = tuple(raw.get("subscription_ids") or []) integration_ids: tuple[str, ...] = () if providers: rows = (await db.execute( select(Integration.id).where( Integration.provider.in_(providers), # Same set the governance validator accepts, so a grant # cannot mean one thing at save time and another at read # time. (A non-cloud integration sharing a provider name # matches no cloud scope anyway — this keeps the two # definitions from drifting apart.) Integration.kind == "cloud", ), )).scalars().all() integration_ids = tuple(str(i) for i in rows) return ResolvedCloudVisibility( unrestricted=False, integration_ids=integration_ids, subscription_ids=subscription_ids, providers=providers, ) async def resolve_client_cloud_visibility( db: AsyncSession, ctx: ApiClientContext, ) -> ResolvedCloudVisibility: """Resolve the authenticated client's whitelist — the entry point every cloud route should use. ``ctx.cloud_visibility`` coerces an unreadable stored value to ``None``, which is indistinguishable from a legitimately unrestricted grant; the truth lives in ``ctx.cloud_visibility_malformed``. Pairing the two here means a route cannot fail open by forgetting the flag, and a route added later is correct by construction. """ return await resolve_cloud_visibility( db, ctx.cloud_visibility, malformed=ctx.cloud_visibility_malformed, ) async def validate_cloud_visibility_targets( db: AsyncSession, normalized: dict[str, list[str]] | None, ) -> dict[str, Any]: """Governance-side existence check, run when an admin saves a grant. Unknown providers / subscriptions are rejected at write time rather than silently narrowing the client to nothing at read time — a typo that makes an integration "return no data, no error" costs a real debugging session on the integrator's side. Returns a label map the console renders (``{subscription_id: " · "}``) so the drawer can show what was granted without a second round trip. Raises ``ValueError`` naming the offending values. """ if normalized is None: return {} providers = normalized.get("providers") or [] if providers: known = set((await db.execute( select(Integration.provider).where(Integration.kind == "cloud"), )).scalars().all()) unknown = sorted(set(providers) - known) if unknown: raise ValueError( f"cloud_visibility.providers: no cloud integration exists for " f"{', '.join(unknown)} (known: {', '.join(sorted(known)) or 'none'})", ) labels: dict[str, Any] = {} subscription_ids = normalized.get("subscription_ids") or [] if subscription_ids: rows = (await db.execute( select( CmdbCloudScope.id, CmdbCloudScope.external_id, CmdbCloudScope.display_name, ).where( CmdbCloudScope.id.in_([uuid_mod.UUID(s) for s in subscription_ids]), CmdbCloudScope.deleted_at.is_(None), ), )).all() found = {str(r.id): r for r in rows} missing = sorted(set(subscription_ids) - set(found)) if missing: raise ValueError( "cloud_visibility.subscription_ids: unknown or deleted " f"subscription(s): {', '.join(missing)}", ) labels = { sid: { "external_id": row.external_id, "name": row.display_name, } for sid, row in found.items() } return labels __all__ = [ "ResolvedCloudVisibility", "resolve_client_cloud_visibility", "resolve_cloud_visibility", "validate_cloud_visibility_targets", ]