"""Bundle service — CRUD + apply-to-person for ``provisioning_bundles``. PR-2 of the symmetric-onboarding series (2026-06-01). Bundles group N ``ProvisionPolicy`` rows under a named kit. Admins apply a bundle to a single onboarding process (cockpit detail page) to fan-out grants without waiting for HRIS attribute-matching. Apply semantics --------------- ``apply_to_person(bundle_id, person_id)``: * Walks every active ``ProvisionPolicy`` in the bundle. * For each policy that targets `(target_type, target_id)`, checks whether the person already has a satisfying ``ProvisioningTask`` / seat-allocation / sso_grant. * Creates a pending ``ProvisioningTask`` (source=``bundle_apply``) for each unsatisfied target. Does NOT auto-dispatch tasks — admin clicks bulk-execute or the ``provisioning.dispatch`` cron picks them up. Mirrors ``execute_onboarding_for_person``'s shape so the audit trail looks analogous (different `source` tag for analytics). """ from __future__ import annotations import logging from datetime import UTC, datetime from uuid import UUID from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.core.exceptions import AppException from app.domains.provisioning.models import ( ACTION_GRANT, STATUS_IN_PROGRESS, STATUS_NOTIFIED, STATUS_PENDING, ProvisioningBundle, ProvisioningBundleMember, ProvisioningTask, ProvisionPolicy, ) from app.domains.provisioning.schemas import ProvisioningTaskCreate from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) SOURCE_BUNDLE_APPLY = "bundle_apply" class BundleService: def __init__(self, db: AsyncSession) -> None: self.db = db self.audit = AuditService(db) # ───────────────────────── CRUD ───────────────────────── async def list_bundles( self, *, active_only: bool = False, ) -> list[tuple[ProvisioningBundle, int]]: """Return ``[(bundle, member_count), ...]`` sorted by name.""" bundles = ( ( await self.db.execute( ( select(ProvisioningBundle) .where(ProvisioningBundle.is_active.is_(True)) if active_only else select(ProvisioningBundle) ).order_by(ProvisioningBundle.name) ) ) .scalars() .all() ) if not bundles: return [] counts_q = await self.db.execute( select( ProvisioningBundleMember.bundle_id, func.count().label("n"), ) .where( ProvisioningBundleMember.bundle_id.in_([b.id for b in bundles]) ) .group_by(ProvisioningBundleMember.bundle_id) ) counts: dict[UUID, int] = {row[0]: int(row[1]) for row in counts_q} return [(b, counts.get(b.id, 0)) for b in bundles] async def get_bundle( self, bundle_id: UUID, ) -> tuple[ProvisioningBundle, list[ProvisionPolicy]]: bundle = await self._get_or_raise(bundle_id) policy_ids = ( ( await self.db.execute( select(ProvisioningBundleMember.policy_id).where( ProvisioningBundleMember.bundle_id == bundle_id ) ) ) .scalars() .all() ) if not policy_ids: return bundle, [] policies = ( ( await self.db.execute( select(ProvisionPolicy).where( ProvisionPolicy.id.in_(policy_ids) ) ) ) .scalars() .all() ) return bundle, list(policies) async def create_bundle( self, *, name: str, description: str | None, target_role_hint: str | None, trace_id: str, actor_user_id: UUID | None = None, ) -> ProvisioningBundle: clean_name = (name or "").strip() if not clean_name: raise AppException( code="BUNDLE_NAME_REQUIRED", message="bundle name is required", status_code=400, ) # Uniqueness check (DB also enforces via UNIQUE, but the explicit # check produces a friendly error). dup = ( await self.db.execute( select(ProvisioningBundle).where( ProvisioningBundle.name == clean_name ) ) ).scalar_one_or_none() if dup is not None: raise AppException( code="BUNDLE_NAME_TAKEN", message=f"bundle {clean_name!r} already exists", status_code=409, ) # Python-side timestamps so the router's _serialize_bundle can # read created_at/updated_at without triggering a lazy reload — # in our async session that lazy reload raises MissingGreenlet # and the response 500s AFTER the row was already committed # (same pattern as feed.add_comment + feed.create_announcement). now = datetime.now(UTC) bundle = ProvisioningBundle( name=clean_name[:128], description=(description or "").strip() or None, target_role_hint=(target_role_hint or "").strip()[:128] or None, is_active=True, created_by_person_id=actor_user_id, created_at=now, updated_at=now, ) self.db.add(bundle) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="user" if actor_user_id else "system", actor_user_id=actor_user_id, action="provisioning.bundle_created", status="success", resource_type="provisioning_bundle", resource_id=str(bundle.id), detail={"name": clean_name}, ) return bundle async def update_bundle( self, bundle_id: UUID, *, description: str | None = None, target_role_hint: str | None = None, is_active: bool | None = None, trace_id: str, actor_user_id: UUID | None = None, ) -> ProvisioningBundle: bundle = await self._get_or_raise(bundle_id) changed: dict[str, object] = {} if description is not None: new_desc = description.strip() or None if new_desc != bundle.description: bundle.description = new_desc changed["description"] = new_desc if target_role_hint is not None: new_role = target_role_hint.strip()[:128] or None if new_role != bundle.target_role_hint: bundle.target_role_hint = new_role changed["target_role_hint"] = new_role if is_active is not None and bool(is_active) != bool(bundle.is_active): bundle.is_active = bool(is_active) changed["is_active"] = bool(is_active) if changed: # Mirror server-side ``onupdate=NOW()`` python-side so the # router serializer reads a fresh value without a refresh # round-trip (same rationale as ``create_bundle``). bundle.updated_at = datetime.now(UTC) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="user" if actor_user_id else "system", actor_user_id=actor_user_id, action="provisioning.bundle_updated", status="success", resource_type="provisioning_bundle", resource_id=str(bundle.id), detail=changed, ) return bundle async def delete_bundle( self, bundle_id: UUID, *, trace_id: str, actor_user_id: UUID | None = None, ) -> None: bundle = await self._get_or_raise(bundle_id) # FK on provision_policies.bundle_id is SET NULL — child policies # survive. provisioning_bundle_members.bundle_id is CASCADE — N×M # rows are removed automatically. await self.db.delete(bundle) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="user" if actor_user_id else "system", actor_user_id=actor_user_id, action="provisioning.bundle_deleted", status="success", resource_type="provisioning_bundle", resource_id=str(bundle_id), ) # ───────────────────────── Membership ───────────────────────── async def add_member( self, bundle_id: UUID, policy_id: UUID, *, trace_id: str, actor_user_id: UUID | None = None, ) -> ProvisioningBundleMember: await self._get_or_raise(bundle_id) policy = await self.db.get(ProvisionPolicy, policy_id) if policy is None: raise AppException( code="POLICY_NOT_FOUND", message=f"policy {policy_id} not found", status_code=404, ) existing = ( await self.db.execute( select(ProvisioningBundleMember).where( ProvisioningBundleMember.bundle_id == bundle_id, ProvisioningBundleMember.policy_id == policy_id, ) ) ).scalar_one_or_none() if existing is not None: return existing member = ProvisioningBundleMember( bundle_id=bundle_id, policy_id=policy_id, ) self.db.add(member) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="user" if actor_user_id else "system", actor_user_id=actor_user_id, action="provisioning.bundle_member_added", status="success", resource_type="provisioning_bundle", resource_id=str(bundle_id), detail={"policy_id": str(policy_id)}, ) return member async def remove_member( self, bundle_id: UUID, policy_id: UUID, *, trace_id: str, actor_user_id: UUID | None = None, ) -> None: await self._get_or_raise(bundle_id) existing = ( await self.db.execute( select(ProvisioningBundleMember).where( ProvisioningBundleMember.bundle_id == bundle_id, ProvisioningBundleMember.policy_id == policy_id, ) ) ).scalar_one_or_none() if existing is None: return await self.db.delete(existing) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="user" if actor_user_id else "system", actor_user_id=actor_user_id, action="provisioning.bundle_member_removed", status="success", resource_type="provisioning_bundle", resource_id=str(bundle_id), detail={"policy_id": str(policy_id)}, ) # ───────────────────────── Apply ───────────────────────── async def apply_to_person( self, bundle_id: UUID, person_id: UUID, *, trace_id: str, actor_user_id: UUID | None = None, ) -> dict[str, object]: """Create pending ProvisioningTask for every bundle policy the person doesn't already have. Returns ``{"created_task_ids": [...], "skipped": N}``. """ bundle = await self._get_or_raise(bundle_id) if not bundle.is_active: raise AppException( code="BUNDLE_INACTIVE", message=f"bundle {bundle.name!r} is inactive", status_code=409, ) person = await self.db.get(IdentityPerson, person_id) if person is None: raise AppException( code="PERSON_NOT_FOUND", message=f"person {person_id} not found", status_code=404, ) # Leaver invariant: a bundle apply is a batch of GRANTs — refuse # for anyone who is not an active employee. if person.deleted_at is not None or person.employment_status != "active": raise AppException( code="PERSON_NOT_ACTIVE", message=( f"person {person.work_email or person_id} is " f"{person.employment_status!r} — refusing to apply a " "provisioning bundle to an inactive person" ), status_code=409, ) # Load the bundle's policies (only active ones). policies = ( ( await self.db.execute( select(ProvisionPolicy) .join( ProvisioningBundleMember, ProvisioningBundleMember.policy_id == ProvisionPolicy.id, ) .where( ProvisioningBundleMember.bundle_id == bundle_id, ProvisionPolicy.is_active.is_(True), ) ) ) .scalars() .all() ) if not policies: return {"created_task_ids": [], "skipped": 0} # Lazy import — avoid circular at module load. from app.domains.provisioning.service import ProvisioningService prov = ProvisioningService(self.db) items = await prov._build_preview_items(person_id, list(policies)) created_task_ids: list[str] = [] skipped = 0 for item in items: if item.already_provisioned: skipped += 1 continue dup = ( await self.db.execute( select(ProvisioningTask).where( and_( ProvisioningTask.user_id == person_id, ProvisioningTask.target_type == item.target_type, ProvisioningTask.target_id == item.target_id, ProvisioningTask.action == ACTION_GRANT, ProvisioningTask.status.in_( [ STATUS_PENDING, STATUS_IN_PROGRESS, STATUS_NOTIFIED, ] ), ) ) ) ).scalars().first() # ``.first()`` not ``scalar_one_or_none``: no unique constraint / # serialization prevents two non-terminal GRANT rows for one # (person, target), so scalar_one_or_none would raise # MultipleResultsFound and permanently 500 bundle-apply for that # person. Presence is all this dedup needs (see _has_inflight_task). if dup: skipped += 1 continue task = await prov.create_task( ProvisioningTaskCreate( user_id=person_id, software_id=item.software_id, action=ACTION_GRANT, target_type=item.target_type, target_id=item.target_id, source=SOURCE_BUNDLE_APPLY, context_data={ "bundle_id": str(bundle_id), "policy_id": str(item.policy_id), }, ) ) created_task_ids.append(str(task.id)) await self.audit.log( trace_id=trace_id, actor_type="user" if actor_user_id else "system", actor_user_id=actor_user_id, action="provisioning.bundle_applied", status="success", resource_type="provisioning_bundle", resource_id=str(bundle_id), detail={ "person_id": str(person_id), "created_task_count": len(created_task_ids), "skipped": skipped, }, ) return { "created_task_ids": created_task_ids, "skipped": skipped, "bundle_id": str(bundle_id), "person_id": str(person_id), } # ───────────────────────── Internal ───────────────────────── async def _get_or_raise(self, bundle_id: UUID) -> ProvisioningBundle: bundle = await self.db.get(ProvisioningBundle, bundle_id) if bundle is None: raise AppException( code="BUNDLE_NOT_FOUND", message=f"bundle {bundle_id} not found", status_code=404, ) return bundle