"""Authorized read and explicit generation service for subscription analysis. The shared business-basis result belongs to the cloud scope, not to the requesting user. This module deliberately keeps GET paths read-only and places authorization in front of every state/history/generation operation. """ from __future__ import annotations import uuid from collections.abc import Iterable from dataclasses import dataclass from datetime import UTC, datetime from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql.elements import ColumnElement from app.core.exceptions import AppException, ForbiddenError, NotFoundError from app.core.rbac import can_manage_cloud, can_read_cloud from app.domains.cmdb.models import ( GRANT_STATUS_ACTIVE, SCOPE_STATUS_CLOSED, SCOPE_TYPE_AZURE_RESOURCE_GROUP, CmdbCloudAccessGrant, CmdbCloudScope, ) from app.intelligence.cloud_scope_snapshot import build_cloud_scope_snapshot from app.intelligence.generation import GenerationResult, generate_analysis, latest_analysis from app.intelligence.models import IntelligenceAnalysis, IntelligenceSnapshot from app.intelligence.provider_registry import BUSINESS_AUDIENCE_KEY from app.intelligence.snapshot_service import SnapshotSubject, latest_snapshot CLOUD_SCOPE_ANALYSIS_KIND = "cloud_scope_health" @dataclass(frozen=True) class CloudScopeAnalysisAccess: scope: CmdbCloudScope privileged_reader: bool direct_owner: bool inherited_access: bool can_force: bool #: May this caller spend model tokens on THIS subscription? #: #: Deliberately narrower than "may read this subscription". Reading is #: free; generating debits the daily quota and bills a model call, so #: the authority is named and resolved separately instead of being #: implied by the read gate. Member-triggered shared analysis stays a #: product feature — an owner or an active grantee (directly or through #: a live Azure parent) can generate. What is excluded is the set of #: roles that read EVERY subscription without having any relationship #: with it: auditor, finance and management can look, not spend. can_spend_model_tokens: bool = False @dataclass(frozen=True) class CloudScopeAnalysisState: access: CloudScopeAnalysisAccess snapshot: IntelligenceSnapshot | None latest_successful: IntelligenceAnalysis | None latest_attempt: IntelligenceAnalysis | None def _utc(value: datetime) -> datetime: return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) async def _load_visible_scope(db: AsyncSession, scope_id: str) -> CmdbCloudScope: try: parsed_id = uuid.UUID(scope_id) except (TypeError, ValueError, AttributeError): raise NotFoundError("Cloud subscription", scope_id) from None scope = ( await db.execute( select(CmdbCloudScope).where( CmdbCloudScope.id == parsed_id, CmdbCloudScope.deleted_at.is_(None), ) ) ).scalar_one_or_none() if scope is None: raise NotFoundError("Cloud subscription", scope_id) return scope async def require_cloud_scope_analysis_access( db: AsyncSession, *, scope_id: str, person_id: uuid.UUID, role_names: Iterable[str], now: datetime | None = None, ) -> CloudScopeAnalysisAccess: """Resolve shared-analysis access without exposing scope existence. Domain readers can inspect every scope. Other employees need a current owner relationship or an active, unexpired grant on the scope. Azure resource-group children inherit those same relationships from a live parent subscription, matching the product's existing subscription view. Pending grants do not constitute permission to run or read AI analysis. """ roles = tuple(role_names) scope = await _load_visible_scope(db, scope_id) privileged = can_read_cloud(roles) force_allowed = can_manage_cloud(roles) person_key = str(person_id) direct_owner = str(scope.owner_person_id or "") == person_key inherited_owner = False eligible_scope_ids = [str(scope.id)] if scope.scope_type == SCOPE_TYPE_AZURE_RESOURCE_GROUP and scope.parent_scope_id: parent = ( await db.execute( select(CmdbCloudScope).where( CmdbCloudScope.id == scope.parent_scope_id, CmdbCloudScope.deleted_at.is_(None), CmdbCloudScope.status != SCOPE_STATUS_CLOSED, ) ) ).scalar_one_or_none() if parent is not None: eligible_scope_ids.append(str(parent.id)) inherited_owner = str(parent.owner_person_id or "") == person_key anchor = _utc(now or datetime.now(UTC)) active_grant_scope_ids = set( ( await db.execute( select(CmdbCloudAccessGrant.scope_id) .where( CmdbCloudAccessGrant.scope_id.in_(eligible_scope_ids), CmdbCloudAccessGrant.person_id == person_key, CmdbCloudAccessGrant.status == GRANT_STATUS_ACTIVE, or_( CmdbCloudAccessGrant.expires_at.is_(None), CmdbCloudAccessGrant.expires_at > anchor, ), ) ) ).scalars().all() ) inherited_access = inherited_owner or ( any(item != str(scope.id) for item in active_grant_scope_ids) ) if not ( privileged or direct_owner or inherited_owner or active_grant_scope_ids ): # A non-oracle 404 prevents subscription enumeration by employees. raise NotFoundError("Cloud subscription", scope_id) related = bool( direct_owner or inherited_owner or active_grant_scope_ids ) return CloudScopeAnalysisAccess( scope=scope, privileged_reader=privileged, direct_owner=direct_owner, inherited_access=inherited_access, can_force=force_allowed, # Cloud-domain write authority (global_admin / cloud_admin) may spend # anywhere; everyone else needs an actual relationship with the # subscription. ``can_read_cloud`` additionally admits auditor, # finance and management — broad readers of every scope — and those # must not be able to bill a model call on a subscription they have # nothing to do with. can_spend_model_tokens=force_allowed or related, ) def _subject(scope_id: str) -> SnapshotSubject: return SnapshotSubject("cloud_scope", scope_id, BUSINESS_AUDIENCE_KEY) def _analysis_conditions(scope_id: str) -> tuple[ColumnElement[bool], ...]: return ( IntelligenceAnalysis.subject_type == "cloud_scope", IntelligenceAnalysis.subject_id == scope_id, IntelligenceAnalysis.audience_key == BUSINESS_AUDIENCE_KEY, IntelligenceAnalysis.analysis_kind == CLOUD_SCOPE_ANALYSIS_KIND, ) async def read_cloud_scope_analysis_state( db: AsyncSession, *, scope_id: str, person_id: uuid.UUID, role_names: Iterable[str], now: datetime | None = None, ) -> CloudScopeAnalysisState: """Read persisted state only; this path can never call the model.""" access = await require_cloud_scope_analysis_access( db, scope_id=scope_id, person_id=person_id, role_names=role_names, now=now, ) return await load_cloud_scope_analysis_state(db, access=access) async def load_cloud_scope_analysis_state( db: AsyncSession, *, access: CloudScopeAnalysisAccess, ) -> CloudScopeAnalysisState: """Load persisted state after a caller established the exact access object.""" normalized_id = str(access.scope.id) snapshot = await latest_snapshot(db, subject=_subject(normalized_id)) successful = await latest_analysis( db, subject_type="cloud_scope", subject_id=normalized_id, audience_key=BUSINESS_AUDIENCE_KEY, analysis_kind=CLOUD_SCOPE_ANALYSIS_KIND, ) attempt = ( await db.execute( select(IntelligenceAnalysis) .where(*_analysis_conditions(normalized_id)) .order_by( IntelligenceAnalysis.created_at.desc(), IntelligenceAnalysis.id.desc(), ) .limit(1) ) ).scalar_one_or_none() return CloudScopeAnalysisState(access, snapshot, successful, attempt) async def cloud_scope_analysis_history( db: AsyncSession, *, scope_id: str, person_id: uuid.UUID, role_names: Iterable[str], limit: int = 50, now: datetime | None = None, ) -> list[IntelligenceAnalysis]: """Return bounded newest-first generation attempts, including failures.""" access = await require_cloud_scope_analysis_access( db, scope_id=scope_id, person_id=person_id, role_names=role_names, now=now, ) return await load_cloud_scope_analysis_history(db, access=access, limit=limit) async def load_cloud_scope_analysis_history( db: AsyncSession, *, access: CloudScopeAnalysisAccess, limit: int = 50, ) -> list[IntelligenceAnalysis]: """Load bounded attempts after authorization, without invoking a model.""" bounded_limit = max(1, min(365, limit)) return list( ( await db.execute( select(IntelligenceAnalysis) .where(*_analysis_conditions(str(access.scope.id))) .order_by( IntelligenceAnalysis.created_at.desc(), IntelligenceAnalysis.id.desc(), ) .limit(bounded_limit) ) ).scalars().all() ) async def generate_cloud_scope_analysis( db: AsyncSession, *, scope_id: str, person_id: uuid.UUID, role_names: Iterable[str], force: bool = False, confirm_force: bool = False, now: datetime | None = None, ) -> GenerationResult: """Refresh deterministic facts, then explicitly request shared AI output.""" access = await require_cloud_scope_analysis_access( db, scope_id=scope_id, person_id=person_id, role_names=role_names, now=now, ) if not access.can_spend_model_tokens: # Read authority was never spend authority. Until this gate existed a # plain auditor / finance / management reader — who can list every # subscription — could issue a billed model call on any of them. raise AppException( code="CLOUD_SCOPE_ANALYSIS_SPEND_FORBIDDEN", message=( "Generating this subscription's AI analysis requires " "ownership, an active access grant, or cloud administration." ), status_code=403, ) if access.scope.status == SCOPE_STATUS_CLOSED: raise AppException( code="CLOUD_SCOPE_CLOSED", message="Closed cloud subscriptions cannot be re-analyzed.", status_code=409, ) if force and not access.can_force: raise ForbiddenError("Only global or cloud administrators can force re-analysis.") if force and not confirm_force: raise AppException( code="INTELLIGENCE_FORCE_CONFIRMATION_REQUIRED", message="Force re-analysis requires explicit confirmation.", status_code=422, ) built = await build_cloud_scope_snapshot(db, str(access.scope.id), now=now) return await generate_analysis( db, snapshot=built.snapshot, analysis_kind=CLOUD_SCOPE_ANALYSIS_KIND, actor_id=person_id, force=force, enforce_user_quota=True, ) __all__ = [ "CLOUD_SCOPE_ANALYSIS_KIND", "CloudScopeAnalysisAccess", "CloudScopeAnalysisState", "cloud_scope_analysis_history", "generate_cloud_scope_analysis", "load_cloud_scope_analysis_history", "load_cloud_scope_analysis_state", "read_cloud_scope_analysis_state", "require_cloud_scope_analysis_access", ]