From 72c94db1cf967745df1f198760be08f433b65ef1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:59:21 +0200 Subject: [PATCH 01/23] chore(deps): bump pygments from 2.19.2 to 2.20.0 in /api (#10522) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/poetry.lock b/api/poetry.lock index 6bad57a758..b74417c745 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -7147,14 +7147,14 @@ urllib3 = ">=1.26.0" [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] [package.extras] From a24869fc264dd42ea0440c3a8078a414515063a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 22 Apr 2026 09:01:45 +0200 Subject: [PATCH 02/23] feat(sdk): add universal compliance output modules (CSV, OCSF, table) (#10299) --- .../outputs/compliance/universal/__init__.py | 0 .../compliance/universal/ocsf_compliance.py | 395 ++++++++++++++ .../compliance/universal/universal_table.py | 490 ++++++++++++++++++ .../outputs/compliance/universal/__init__.py | 0 .../universal/ocsf_compliance_test.py | 475 +++++++++++++++++ .../universal/universal_table_test.py | 384 ++++++++++++++ 6 files changed, 1744 insertions(+) create mode 100644 prowler/lib/outputs/compliance/universal/__init__.py create mode 100644 prowler/lib/outputs/compliance/universal/ocsf_compliance.py create mode 100644 prowler/lib/outputs/compliance/universal/universal_table.py create mode 100644 tests/lib/outputs/compliance/universal/__init__.py create mode 100644 tests/lib/outputs/compliance/universal/ocsf_compliance_test.py create mode 100644 tests/lib/outputs/compliance/universal/universal_table_test.py diff --git a/prowler/lib/outputs/compliance/universal/__init__.py b/prowler/lib/outputs/compliance/universal/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/lib/outputs/compliance/universal/ocsf_compliance.py b/prowler/lib/outputs/compliance/universal/ocsf_compliance.py new file mode 100644 index 0000000000..3760549cd5 --- /dev/null +++ b/prowler/lib/outputs/compliance/universal/ocsf_compliance.py @@ -0,0 +1,395 @@ +import os +from datetime import datetime +from typing import List + +from py_ocsf_models.events.base_event import SeverityID +from py_ocsf_models.events.base_event import StatusID as EventStatusID +from py_ocsf_models.events.findings.compliance_finding import ComplianceFinding +from py_ocsf_models.events.findings.compliance_finding_type_id import ( + ComplianceFindingTypeID, +) +from py_ocsf_models.events.findings.finding import ActivityID, FindingInformation +from py_ocsf_models.objects.check import Check +from py_ocsf_models.objects.compliance import Compliance +from py_ocsf_models.objects.compliance_status import StatusID as ComplianceStatusID +from py_ocsf_models.objects.group import Group +from py_ocsf_models.objects.metadata import Metadata +from py_ocsf_models.objects.product import Product +from py_ocsf_models.objects.resource_details import ResourceDetails + +from prowler.config.config import prowler_version +from prowler.lib.check.compliance_models import ComplianceFramework +from prowler.lib.logger import logger +from prowler.lib.outputs.finding import Finding +from prowler.lib.outputs.ocsf.ocsf import OCSF +from prowler.lib.outputs.utils import unroll_dict_to_list +from prowler.lib.utils.utils import open_file + +PROWLER_TO_COMPLIANCE_STATUS = { + "PASS": ComplianceStatusID.Pass, + "FAIL": ComplianceStatusID.Fail, + "MANUAL": ComplianceStatusID.Unknown, +} + + +def _to_snake_case(name: str) -> str: + """Convert a PascalCase or camelCase string to snake_case.""" + import re + + # Insert underscore before uppercase letters preceded by lowercase + s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name) + # Insert underscore between consecutive uppercase and following lowercase + s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s) + return s.lower() + + +def _build_requirement_attrs(requirement, framework) -> dict: + """Build a dict with requirement attributes for the unmapped section. + + Keys are normalized to snake_case for OCSF consistency. + Only includes attributes whose AttributeMetadata has output_formats.ocsf=True. + When no metadata is declared, all attributes are included. + """ + attrs = requirement.attributes + if not attrs: + return {} + + # Build set of keys allowed for OCSF output + metadata = framework.attributes_metadata + if metadata: + ocsf_keys = {m.key for m in metadata if m.output_formats.ocsf} + else: + ocsf_keys = None # No metadata → include all + + result = {} + for key, value in attrs.items(): + if ocsf_keys is not None and key not in ocsf_keys: + continue + result[_to_snake_case(key)] = value + return result + + +class OCSFComplianceOutput: + """Produces OCSF ComplianceFinding (class_uid 2003) events from + universal compliance framework data. + + Each finding × requirement combination produces one ComplianceFinding event + with structured Compliance and Check objects. + """ + + def __init__( + self, + findings: list, + framework: ComplianceFramework, + file_path: str = None, + from_cli: bool = True, + provider: str = None, + ) -> None: + self._data = [] + self._file_descriptor = None + self.file_path = file_path + self._from_cli = from_cli + self._provider = provider + self.close_file = False + + if findings: + compliance_name = ( + framework.framework + "-" + framework.version + if framework.version + else framework.framework + ) + self._transform(findings, framework, compliance_name) + if not self._file_descriptor and file_path: + self._create_file_descriptor(file_path) + + @property + def data(self): + return self._data + + def _transform( + self, + findings: List[Finding], + framework: ComplianceFramework, + compliance_name: str, + ) -> None: + # Build check -> requirements map + check_req_map = {} + for req in framework.requirements: + checks = req.checks + if self._provider: + all_checks = checks.get(self._provider.lower(), []) + else: + all_checks = [] + for check_list in checks.values(): + all_checks.extend(check_list) + for check_id in all_checks: + check_req_map.setdefault(check_id, []).append(req) + + for finding in findings: + if finding.check_id in check_req_map: + for req in check_req_map[finding.check_id]: + cf = self._build_compliance_finding( + finding, framework, req, compliance_name + ) + if cf: + self._data.append(cf) + + # Manual requirements (no checks or empty for current provider) + for req in framework.requirements: + checks = req.checks + if self._provider: + has_checks = bool(checks.get(self._provider.lower(), [])) + else: + has_checks = any(checks.values()) + + if not has_checks: + cf = self._build_manual_compliance_finding( + framework, req, compliance_name + ) + if cf: + self._data.append(cf) + + def _build_unmapped(self, finding, requirement, framework) -> dict: + """Build the unmapped dict with cloud info and requirement attributes.""" + unmapped = {} + + # Cloud info (from finding, when available) + if finding and getattr(finding, "provider", None) != "kubernetes": + unmapped["cloud"] = { + "provider": finding.provider, + "region": finding.region, + "account": { + "uid": finding.account_uid, + "name": finding.account_name, + }, + "org": { + "uid": finding.account_organization_uid, + "name": finding.account_organization_name, + }, + } + + # Requirement attributes + req_attrs = _build_requirement_attrs(requirement, framework) + if req_attrs: + unmapped["requirement_attributes"] = req_attrs + + return unmapped or None + + def _build_compliance_finding( + self, + finding: Finding, + framework: ComplianceFramework, + requirement, + compliance_name: str, + ) -> ComplianceFinding: + try: + compliance_status = PROWLER_TO_COMPLIANCE_STATUS.get( + finding.status, ComplianceStatusID.Unknown + ) + check_status = PROWLER_TO_COMPLIANCE_STATUS.get( + finding.status, ComplianceStatusID.Unknown + ) + + finding_severity = getattr( + SeverityID, + finding.metadata.Severity.capitalize(), + SeverityID.Unknown, + ) + event_status = OCSF.get_finding_status_id(finding.muted) + + time_value = ( + int(finding.timestamp.timestamp()) + if isinstance(finding.timestamp, datetime) + else finding.timestamp + ) + + cf = ComplianceFinding( + activity_id=ActivityID.Create.value, + activity_name=ActivityID.Create.name, + compliance=Compliance( + standards=[compliance_name], + requirements=[requirement.id], + control=requirement.description, + status_id=compliance_status, + checks=[ + Check( + uid=finding.check_id, + name=finding.metadata.CheckTitle, + desc=finding.metadata.Description, + status=finding.status, + status_id=check_status, + ) + ], + ), + finding_info=FindingInformation( + uid=f"{finding.uid}-{requirement.id}", + title=requirement.id, + desc=requirement.description, + created_time=time_value, + created_time_dt=( + finding.timestamp + if isinstance(finding.timestamp, datetime) + else None + ), + ), + message=finding.status_extended, + metadata=Metadata( + event_code=finding.check_id, + product=Product( + uid="prowler", + name="Prowler", + vendor_name="Prowler", + version=finding.prowler_version, + ), + profiles=( + ["cloud", "datetime"] + if finding.provider != "kubernetes" + else ["container", "datetime"] + ), + tenant_uid=finding.account_organization_uid, + ), + resources=[ + ResourceDetails( + labels=unroll_dict_to_list(finding.resource_tags), + name=finding.resource_name, + uid=finding.resource_uid, + group=Group(name=finding.metadata.ServiceName), + type=finding.metadata.ResourceType, + cloud_partition=( + finding.partition + if finding.provider != "kubernetes" + else None + ), + region=( + finding.region if finding.provider != "kubernetes" else None + ), + namespace=( + finding.region.replace("namespace: ", "") + if finding.provider == "kubernetes" + else None + ), + data={ + "details": finding.resource_details, + "metadata": finding.resource_metadata, + }, + ) + ], + severity_id=finding_severity.value, + severity=finding_severity.name, + status_id=event_status.value, + status=event_status.name, + status_code=finding.status, + status_detail=finding.status_extended, + time=time_value, + time_dt=( + finding.timestamp + if isinstance(finding.timestamp, datetime) + else None + ), + type_uid=ComplianceFindingTypeID.Create, + type_name=f"Compliance Finding: {ComplianceFindingTypeID.Create.name}", + unmapped=self._build_unmapped(finding, requirement, framework), + ) + + return cf + except Exception as e: + logger.debug(f"Skipping OCSF compliance finding for {requirement.id}: {e}") + return None + + def _build_manual_compliance_finding( + self, + framework: ComplianceFramework, + requirement, + compliance_name: str, + ) -> ComplianceFinding: + try: + from prowler.config.config import timestamp as config_timestamp + + time_value = int(config_timestamp.timestamp()) + + return ComplianceFinding( + activity_id=ActivityID.Create.value, + activity_name=ActivityID.Create.name, + compliance=Compliance( + standards=[compliance_name], + requirements=[requirement.id], + control=requirement.description, + status_id=ComplianceStatusID.Unknown, + ), + finding_info=FindingInformation( + uid=f"manual-{requirement.id}", + title=requirement.id, + desc=requirement.description, + created_time=time_value, + ), + message="Manual check", + metadata=Metadata( + event_code="manual", + product=Product( + uid="prowler", + name="Prowler", + vendor_name="Prowler", + version=prowler_version, + ), + ), + severity_id=SeverityID.Informational.value, + severity=SeverityID.Informational.name, + status_id=EventStatusID.New.value, + status=EventStatusID.New.name, + status_code="MANUAL", + status_detail="Manual check", + time=time_value, + type_uid=ComplianceFindingTypeID.Create, + type_name=f"Compliance Finding: {ComplianceFindingTypeID.Create.name}", + unmapped=self._build_unmapped(None, requirement, framework), + ) + except Exception as e: + logger.debug( + f"Skipping manual OCSF compliance finding for {requirement.id}: {e}" + ) + return None + + def _create_file_descriptor(self, file_path: str) -> None: + try: + self._file_descriptor = open_file(file_path, "a") + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def batch_write_data_to_file(self) -> None: + """Write ComplianceFinding events to a JSON array file.""" + try: + if ( + getattr(self, "_file_descriptor", None) + and not self._file_descriptor.closed + and self._data + ): + if self._file_descriptor.tell() == 0: + self._file_descriptor.write("[") + for finding in self._data: + try: + if hasattr(finding, "model_dump_json"): + json_output = finding.model_dump_json( + exclude_none=True, indent=4 + ) + else: + json_output = finding.json(exclude_none=True, indent=4) + self._file_descriptor.write(json_output) + self._file_descriptor.write(",") + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if self.close_file or self._from_cli: + if self._file_descriptor.tell() != 1: + self._file_descriptor.seek( + self._file_descriptor.tell() - 1, os.SEEK_SET + ) + self._file_descriptor.truncate() + self._file_descriptor.write("]") + self._file_descriptor.close() + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) diff --git a/prowler/lib/outputs/compliance/universal/universal_table.py b/prowler/lib/outputs/compliance/universal/universal_table.py new file mode 100644 index 0000000000..e838c5e9cf --- /dev/null +++ b/prowler/lib/outputs/compliance/universal/universal_table.py @@ -0,0 +1,490 @@ +from colorama import Fore, Style +from tabulate import tabulate + +from prowler.config.config import orange_color +from prowler.lib.check.compliance_models import ComplianceFramework + + +def get_universal_table( + findings: list, + bulk_checks_metadata: dict, + compliance_framework_name: str, + output_filename: str, + output_directory: str, + compliance_overview: bool, + framework: ComplianceFramework = None, + provider: str = None, + output_formats: list = None, +) -> None: + """Render a compliance console table driven by TableConfig. + + Supports 3 modes: + - Grouped: group_by only (generic, C5, CSA, ISO, KISA) + - Split: group_by + split_by (CIS Level 1/2, ENS alto/medio/bajo/opcional) + - Scored: group_by + scoring (ThreatScore weighted risk %) + + When ``provider`` is given and ``checks`` is a multi-provider dict, + only the checks for that provider are matched against findings. + """ + if framework is None or not framework.outputs or not framework.outputs.table_config: + return + + tc = framework.outputs.table_config + labels = tc.labels or _default_labels() + + group_by = tc.group_by + split_by = tc.split_by + scoring = tc.scoring + + if scoring: + _render_scored( + findings, + bulk_checks_metadata, + compliance_framework_name, + output_filename, + output_directory, + compliance_overview, + framework, + group_by, + scoring, + labels, + provider, + output_formats=output_formats, + ) + elif split_by: + _render_split( + findings, + bulk_checks_metadata, + compliance_framework_name, + output_filename, + output_directory, + compliance_overview, + framework, + group_by, + split_by, + labels, + provider, + output_formats=output_formats, + ) + else: + _render_grouped( + findings, + bulk_checks_metadata, + compliance_framework_name, + output_filename, + output_directory, + compliance_overview, + framework, + group_by, + labels, + provider, + output_formats=output_formats, + ) + + +def _default_labels(): + """Return a simple namespace with default labels.""" + from prowler.lib.check.compliance_models import TableLabels + + return TableLabels() + + +def _build_requirement_check_map(framework, provider=None): + """Build a map of check_id -> list of requirements for fast lookup. + + When *provider* is given, only the checks for that provider are included. + """ + check_map = {} + for req in framework.requirements: + checks = req.checks + if provider: + all_checks = checks.get(provider.lower(), []) + else: + all_checks = [] + for check_list in checks.values(): + all_checks.extend(check_list) + for check_id in all_checks: + if check_id not in check_map: + check_map[check_id] = [] + check_map[check_id].append(req) + return check_map + + +def _get_group_key(req, group_by): + """Extract the group key from a requirement.""" + if group_by == "_Tactics": + return req.tactics or [] + return [req.attributes.get(group_by, "Unknown")] + + +def _print_overview(pass_count, fail_count, muted_count, framework_name, labels): + """Print the overview pass/fail/muted summary.""" + total = len(fail_count) + len(pass_count) + len(muted_count) + if total < 2: + return False + + title = ( + labels.title + or f"Compliance Status of {Fore.YELLOW}{framework_name.upper()}{Style.RESET_ALL} Framework:" + ) + print(f"\n{title}") + + fail_pct = round(len(fail_count) / total * 100, 2) + pass_pct = round(len(pass_count) / total * 100, 2) + muted_pct = round(len(muted_count) / total * 100, 2) + + fail_label = labels.fail_label + pass_label = labels.pass_label + + overview_table = [ + [ + f"{Fore.RED}{fail_pct}% ({len(fail_count)}) {fail_label}{Style.RESET_ALL}", + f"{Fore.GREEN}{pass_pct}% ({len(pass_count)}) {pass_label}{Style.RESET_ALL}", + f"{orange_color}{muted_pct}% ({len(muted_count)}) MUTED{Style.RESET_ALL}", + ] + ] + print(tabulate(overview_table, tablefmt="rounded_grid")) + return True + + +def _render_grouped( + findings, + bulk_checks_metadata, + compliance_framework_name, + output_filename, + output_directory, + compliance_overview, + framework, + group_by, + labels, + provider=None, + output_formats=None, +): + """Grouped mode: one row per group with pass/fail counts.""" + check_map = _build_requirement_check_map(framework, provider) + groups = {} + pass_count = [] + fail_count = [] + muted_count = [] + + for index, finding in enumerate(findings): + check_id = finding.check_metadata.CheckID + if check_id not in check_map: + continue + + for req in check_map[check_id]: + for group_key in _get_group_key(req, group_by): + if group_key not in groups: + groups[group_key] = {"FAIL": 0, "PASS": 0, "Muted": 0} + + if finding.muted: + if index not in muted_count: + muted_count.append(index) + groups[group_key]["Muted"] += 1 + else: + if finding.status == "FAIL" and index not in fail_count: + fail_count.append(index) + groups[group_key]["FAIL"] += 1 + elif finding.status == "PASS" and index not in pass_count: + pass_count.append(index) + groups[group_key]["PASS"] += 1 + + if not _print_overview( + pass_count, fail_count, muted_count, compliance_framework_name, labels + ): + return + + if not compliance_overview: + provider_header = labels.provider_header + group_header = labels.group_header or group_by + table = { + provider_header: [], + group_header: [], + labels.status_header: [], + "Muted": [], + } + for group_key in sorted(groups): + table[provider_header].append( + framework.provider or (provider.upper() if provider else "") + ) + table[group_header].append(group_key) + if groups[group_key]["FAIL"] > 0: + table[labels.status_header].append( + f"{Fore.RED}{labels.fail_label}({groups[group_key]['FAIL']}){Style.RESET_ALL}" + ) + else: + table[labels.status_header].append( + f"{Fore.GREEN}{labels.pass_label}({groups[group_key]['PASS']}){Style.RESET_ALL}" + ) + table["Muted"].append( + f"{orange_color}{groups[group_key]['Muted']}{Style.RESET_ALL}" + ) + + results_title = ( + labels.results_title + or f"Framework {Fore.YELLOW}{compliance_framework_name.upper()}{Style.RESET_ALL} Results:" + ) + print(f"\n{results_title}") + print(tabulate(table, headers="keys", tablefmt="rounded_grid")) + footer = labels.footer_note or "* Only sections containing results appear." + print(f"{Style.BRIGHT}{footer}{Style.RESET_ALL}") + print(f"\nDetailed results of {compliance_framework_name.upper()} are in:") + print( + f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.csv" + ) + if "json-ocsf" in (output_formats or []): + print( + f" - OCSF: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.ocsf.json" + ) + print() + + +def _render_split( + findings, + bulk_checks_metadata, + compliance_framework_name, + output_filename, + output_directory, + compliance_overview, + framework, + group_by, + split_by, + labels, + provider=None, + output_formats=None, +): + """Split mode: one row per group with columns for each split value (e.g. Level 1/Level 2).""" + check_map = _build_requirement_check_map(framework, provider) + split_field = split_by.field + split_values = split_by.values + groups = {} + pass_count = [] + fail_count = [] + muted_count = [] + + for index, finding in enumerate(findings): + check_id = finding.check_metadata.CheckID + if check_id not in check_map: + continue + + for req in check_map[check_id]: + for group_key in _get_group_key(req, group_by): + if group_key not in groups: + groups[group_key] = { + sv: {"FAIL": 0, "PASS": 0} for sv in split_values + } + groups[group_key]["Muted"] = 0 + + split_val = req.attributes.get(split_field, "") + + if finding.muted: + if index not in muted_count: + muted_count.append(index) + groups[group_key]["Muted"] += 1 + else: + if finding.status == "FAIL" and index not in fail_count: + fail_count.append(index) + elif finding.status == "PASS" and index not in pass_count: + pass_count.append(index) + + for sv in split_values: + if sv in str(split_val): + if not finding.muted: + if finding.status == "FAIL": + groups[group_key][sv]["FAIL"] += 1 + else: + groups[group_key][sv]["PASS"] += 1 + + if not _print_overview( + pass_count, fail_count, muted_count, compliance_framework_name, labels + ): + return + + if not compliance_overview: + provider_header = labels.provider_header + group_header = labels.group_header or group_by + table = {provider_header: [], group_header: []} + for sv in split_values: + table[sv] = [] + table["Muted"] = [] + + for group_key in sorted(groups): + table[provider_header].append( + framework.provider or (provider.upper() if provider else "") + ) + table[group_header].append(group_key) + for sv in split_values: + if groups[group_key][sv]["FAIL"] > 0: + table[sv].append( + f"{Fore.RED}{labels.fail_label}({groups[group_key][sv]['FAIL']}){Style.RESET_ALL}" + ) + else: + table[sv].append( + f"{Fore.GREEN}{labels.pass_label}({groups[group_key][sv]['PASS']}){Style.RESET_ALL}" + ) + table["Muted"].append( + f"{orange_color}{groups[group_key]['Muted']}{Style.RESET_ALL}" + ) + + results_title = ( + labels.results_title + or f"Framework {Fore.YELLOW}{compliance_framework_name.upper()}{Style.RESET_ALL} Results:" + ) + print(f"\n{results_title}") + print(tabulate(table, headers="keys", tablefmt="rounded_grid")) + footer = labels.footer_note or "* Only sections containing results appear." + print(f"{Style.BRIGHT}{footer}{Style.RESET_ALL}") + print(f"\nDetailed results of {compliance_framework_name.upper()} are in:") + print( + f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.csv" + ) + if "json-ocsf" in (output_formats or []): + print( + f" - OCSF: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.ocsf.json" + ) + print() + + +def _render_scored( + findings, + bulk_checks_metadata, + compliance_framework_name, + output_filename, + output_directory, + compliance_overview, + framework, + group_by, + scoring, + labels, + provider=None, + output_formats=None, +): + """Scored mode: weighted risk scoring per group (e.g. ThreatScore).""" + check_map = _build_requirement_check_map(framework, provider) + risk_field = scoring.risk_field + weight_field = scoring.weight_field + groups = {} + pass_count = [] + fail_count = [] + muted_count = [] + + score_per_group = {} + max_score_per_group = {} + counted_per_group = {} + generic_score = 0 + max_generic_score = 0 + counted_generic = [] + + for index, finding in enumerate(findings): + check_id = finding.check_metadata.CheckID + if check_id not in check_map: + continue + + for req in check_map[check_id]: + for group_key in _get_group_key(req, group_by): + attrs = req.attributes + risk = attrs.get(risk_field, 0) + weight = attrs.get(weight_field, 0) + + if group_key not in groups: + groups[group_key] = {"FAIL": 0, "PASS": 0, "Muted": 0} + score_per_group[group_key] = 0 + max_score_per_group[group_key] = 0 + counted_per_group[group_key] = [] + + if index not in counted_per_group[group_key] and not finding.muted: + if finding.status == "PASS": + score_per_group[group_key] += risk * weight + max_score_per_group[group_key] += risk * weight + counted_per_group[group_key].append(index) + + if finding.muted: + if index not in muted_count: + muted_count.append(index) + groups[group_key]["Muted"] += 1 + else: + if finding.status == "FAIL" and index not in fail_count: + fail_count.append(index) + groups[group_key]["FAIL"] += 1 + elif finding.status == "PASS" and index not in pass_count: + pass_count.append(index) + groups[group_key]["PASS"] += 1 + + if index not in counted_generic and not finding.muted: + if finding.status == "PASS": + generic_score += risk * weight + max_generic_score += risk * weight + counted_generic.append(index) + + if not _print_overview( + pass_count, fail_count, muted_count, compliance_framework_name, labels + ): + return + + if not compliance_overview: + provider_header = labels.provider_header + group_header = labels.group_header or group_by + table = { + provider_header: [], + group_header: [], + labels.status_header: [], + "Score": [], + "Muted": [], + } + + for group_key in sorted(groups): + table[provider_header].append( + framework.provider or (provider.upper() if provider else "") + ) + table[group_header].append(group_key) + if max_score_per_group[group_key] == 0: + group_score = 100.0 + score_color = Fore.GREEN + else: + group_score = ( + score_per_group[group_key] / max_score_per_group[group_key] + ) * 100 + score_color = Fore.RED + table["Score"].append( + f"{Style.BRIGHT}{score_color}{group_score:.2f}%{Style.RESET_ALL}" + ) + if groups[group_key]["FAIL"] > 0: + table[labels.status_header].append( + f"{Fore.RED}{labels.fail_label}({groups[group_key]['FAIL']}){Style.RESET_ALL}" + ) + else: + table[labels.status_header].append( + f"{Fore.GREEN}{labels.pass_label}({groups[group_key]['PASS']}){Style.RESET_ALL}" + ) + table["Muted"].append( + f"{orange_color}{groups[group_key]['Muted']}{Style.RESET_ALL}" + ) + + if max_generic_score == 0: + generic_threat_score = 100.0 + else: + generic_threat_score = generic_score / max_generic_score * 100 + + results_title = ( + labels.results_title + or f"Framework {Fore.YELLOW}{compliance_framework_name.upper()}{Style.RESET_ALL} Results:" + ) + print(f"\n{results_title}") + print(f"\nGeneric Threat Score: {generic_threat_score:.2f}%") + print(tabulate(table, headers="keys", tablefmt="rounded_grid")) + footer = labels.footer_note or ( + f"{Style.BRIGHT}\n=== Threat Score Guide ===\n" + f"The lower the score, the higher the risk.{Style.RESET_ALL}\n" + f"{Style.BRIGHT}(Only sections containing results appear, the score is calculated as the sum of the " + f"level of risk * weight of the passed findings divided by the sum of the risk * weight of all the findings){Style.RESET_ALL}" + ) + print(footer) + print(f"\nDetailed results of {compliance_framework_name.upper()} are in:") + print( + f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.csv" + ) + if "json-ocsf" in (output_formats or []): + print( + f" - OCSF: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.ocsf.json" + ) + print() diff --git a/tests/lib/outputs/compliance/universal/__init__.py b/tests/lib/outputs/compliance/universal/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py b/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py new file mode 100644 index 0000000000..39bce5cb20 --- /dev/null +++ b/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py @@ -0,0 +1,475 @@ +import json +from datetime import datetime, timezone +from types import SimpleNamespace + +from py_ocsf_models.events.findings.compliance_finding import ComplianceFinding +from py_ocsf_models.events.findings.compliance_finding_type_id import ( + ComplianceFindingTypeID, +) +from py_ocsf_models.objects.compliance_status import StatusID as ComplianceStatusID + +from prowler.lib.check.compliance_models import ( + AttributeMetadata, + ComplianceFramework, + OutputFormats, + OutputsConfig, + TableConfig, + UniversalComplianceRequirement, +) +from prowler.lib.outputs.compliance.universal.ocsf_compliance import ( + OCSFComplianceOutput, +) + + +def _make_finding(check_id, status="PASS", provider="aws"): + """Create a mock Finding with all fields needed by OCSFComplianceOutput.""" + finding = SimpleNamespace() + finding.provider = provider + finding.account_uid = "123456789012" + finding.account_name = "test-account" + finding.account_email = "" + finding.account_organization_uid = "org-123" + finding.account_organization_name = "test-org" + finding.account_tags = {"env": "test"} + finding.region = "us-east-1" + finding.status = status + finding.status_extended = f"{check_id} is {status}" + finding.resource_uid = f"arn:aws:iam::123456789012:{check_id}" + finding.resource_name = check_id + finding.resource_details = "some details" + finding.resource_metadata = {} + finding.resource_tags = {"Name": "test"} + finding.partition = "aws" + finding.muted = False + finding.check_id = check_id + finding.uid = "test-finding-uid" + finding.timestamp = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + finding.prowler_version = "5.0.0" + finding.compliance = {} + finding.metadata = SimpleNamespace( + Provider=provider, + CheckID=check_id, + CheckTitle=f"Title for {check_id}", + CheckType=["test-type"], + Description=f"Description for {check_id}", + Severity="medium", + ServiceName="iam", + ResourceType="aws-iam-role", + Risk="test-risk", + RelatedUrl="https://example.com", + Remediation=SimpleNamespace( + Recommendation=SimpleNamespace(Text="Fix it", Url="https://fix.com"), + ), + DependsOn=[], + RelatedTo=[], + Categories=["test"], + Notes="", + AdditionalURLs=[], + ) + return finding + + +def _make_framework(requirements, attrs_metadata=None): + return ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider="AWS", + version="1.0", + description="Test framework", + requirements=requirements, + attributes_metadata=attrs_metadata, + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + +def _simple_requirement(req_id="REQ-1", checks=None): + if checks is None: + checks_dict = {"aws": ["check_a"]} + elif isinstance(checks, dict): + checks_dict = checks + else: + checks_dict = {"aws": list(checks)} if checks else {} + return UniversalComplianceRequirement( + id=req_id, + description=f"Description for {req_id}", + attributes={}, + checks=checks_dict, + ) + + +class TestOCSFComplianceOutput: + def test_transform_basic(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert len(output.data) == 1 + assert isinstance(output.data[0], ComplianceFinding) + + def test_class_uid(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert output.data[0].class_uid == 2003 + + def test_type_uid(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert output.data[0].type_uid == ComplianceFindingTypeID.Create + assert output.data[0].type_uid == 200301 + + def test_compliance_object_fields(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + compliance = output.data[0].compliance + assert compliance.standards == ["TestFW-1.0"] + assert compliance.requirements == ["REQ-1"] + assert compliance.control == "Description for REQ-1" + assert compliance.status_id == ComplianceStatusID.Pass + + def test_check_object_fields(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a", "FAIL")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + checks = output.data[0].compliance.checks + assert len(checks) == 1 + assert checks[0].uid == "check_a" + assert checks[0].name == "Title for check_a" + assert checks[0].status == "FAIL" + assert checks[0].status_id == ComplianceStatusID.Fail + + def test_finding_info_fields(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + info = output.data[0].finding_info + assert info.uid == "test-finding-uid-REQ-1" + assert info.title == "REQ-1" + assert info.desc == "Description for REQ-1" + + def test_metadata_fields(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + metadata = output.data[0].metadata + assert metadata.product.name == "Prowler" + assert metadata.product.uid == "prowler" + assert metadata.event_code == "check_a" + + def test_status_mapping_pass(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert output.data[0].compliance.status_id == ComplianceStatusID.Pass + + def test_status_mapping_fail(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a", "FAIL")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert output.data[0].compliance.status_id == ComplianceStatusID.Fail + + def test_manual_requirement(self): + req = _simple_requirement("MANUAL-1", checks=[]) + fw = _make_framework([req]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert len(output.data) == 1 + cf = output.data[0] + assert cf.compliance.status_id == ComplianceStatusID.Unknown + assert cf.status_code == "MANUAL" + assert cf.finding_info.uid == "manual-MANUAL-1" + + def test_multi_provider_checks_dict(self): + req = UniversalComplianceRequirement( + id="REQ-1", + description="Multi-provider req", + attributes={}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ) + fw = _make_framework([req]) + findings = [_make_finding("check_a", "PASS", provider="aws")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert len(output.data) == 1 + assert output.data[0].compliance.checks[0].uid == "check_a" + + def test_empty_findings(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + + output = OCSFComplianceOutput(findings=[], framework=fw, provider="aws") + + assert output.data == [] + + def test_cloud_info_in_unmapped(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a", provider="aws")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + cf = output.data[0] + assert cf.unmapped is not None + assert cf.unmapped["cloud"]["provider"] == "aws" + assert cf.unmapped["cloud"]["account"]["uid"] == "123456789012" + assert cf.unmapped["cloud"]["account"]["name"] == "test-account" + + def test_resources_populated(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + resources = output.data[0].resources + assert len(resources) == 1 + assert resources[0].name == "check_a" + assert resources[0].uid == "arn:aws:iam::123456789012:check_a" + assert resources[0].type == "aws-iam-role" + + def test_batch_write_to_file(self, tmp_path): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a", "PASS")] + filepath = str(tmp_path / "compliance.ocsf.json") + + output = OCSFComplianceOutput( + findings=findings, framework=fw, file_path=filepath, provider="aws" + ) + output.batch_write_data_to_file() + + with open(filepath) as f: + data = json.load(f) + + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["class_uid"] == 2003 + assert data[0]["compliance"]["standards"] == ["TestFW-1.0"] + assert data[0]["compliance"]["requirements"] == ["REQ-1"] + + def test_multiple_findings_same_requirement(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [ + _make_finding("check_a", "PASS"), + _make_finding("check_a", "FAIL"), + ] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert len(output.data) == 2 + statuses = [cf.compliance.status_id for cf in output.data] + assert ComplianceStatusID.Pass in statuses + assert ComplianceStatusID.Fail in statuses + + def test_requirement_attributes_in_unmapped(self): + req = UniversalComplianceRequirement( + id="REQ-1", + description="Test requirement", + attributes={"Section": "IAM", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ) + fw = _make_framework([req]) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + cf = output.data[0] + assert cf.unmapped is not None + assert "requirement_attributes" in cf.unmapped + assert cf.unmapped["requirement_attributes"]["section"] == "IAM" + assert cf.unmapped["requirement_attributes"]["profile"] == "Level 1" + + def test_requirement_attributes_keys_are_snake_case(self): + req = UniversalComplianceRequirement( + id="REQ-1", + description="Test requirement", + attributes={"Section": "IAM", "CCMLite": "Yes", "SubSection": "1.1"}, + checks={"aws": ["check_a"]}, + ) + fw = _make_framework([req]) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + attrs = output.data[0].unmapped["requirement_attributes"] + assert "section" in attrs + assert "ccm_lite" in attrs + assert "sub_section" in attrs + + def test_requirement_attributes_empty_attrs_excluded(self): + req = _simple_requirement("REQ-1", checks=["check_a"]) + fw = _make_framework([req]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + cf = output.data[0] + # Cloud info is still present, but no requirement_attributes key + assert cf.unmapped is not None + assert "cloud" in cf.unmapped + assert "requirement_attributes" not in cf.unmapped + + def test_manual_requirement_has_attributes_in_unmapped(self): + req = UniversalComplianceRequirement( + id="MANUAL-1", + description="Manual check", + attributes={"Section": "Logging", "Type": "manual"}, + checks={}, + ) + fw = _make_framework([req]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert len(output.data) == 1 + cf = output.data[0] + assert cf.unmapped is not None + assert cf.unmapped["requirement_attributes"]["section"] == "Logging" + assert cf.unmapped["requirement_attributes"]["type"] == "manual" + # Manual findings have no cloud info (finding is None) + assert "cloud" not in cf.unmapped + + def test_ocsf_metadata_filters_attributes(self): + """Attributes with output_formats.ocsf=False should be excluded from unmapped.""" + metadata = [ + AttributeMetadata( + key="Section", + type="str", + output_formats=OutputFormats(ocsf=True), + ), + AttributeMetadata( + key="InternalNote", + type="str", + output_formats=OutputFormats(ocsf=False), + ), + AttributeMetadata( + key="Profile", + type="str", + output_formats=OutputFormats(ocsf=True), + ), + ] + req = UniversalComplianceRequirement( + id="REQ-1", + description="Test", + attributes={ + "Section": "IAM", + "InternalNote": "skip me", + "Profile": "Level 1", + }, + checks={"aws": ["check_a"]}, + ) + fw = _make_framework([req], attrs_metadata=metadata) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + attrs = output.data[0].unmapped["requirement_attributes"] + assert "section" in attrs + assert "profile" in attrs + assert "internal_note" not in attrs + + def test_ocsf_metadata_all_false_excludes_all(self): + """When all attributes have output_formats.ocsf=False, requirement_attributes should be empty.""" + metadata = [ + AttributeMetadata( + key="Section", type="str", output_formats=OutputFormats(ocsf=False) + ), + ] + req = UniversalComplianceRequirement( + id="REQ-1", + description="Test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ) + fw = _make_framework([req], attrs_metadata=metadata) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + cf = output.data[0] + assert cf.unmapped is not None + # requirement_attributes should not appear since all attrs are filtered out + assert "requirement_attributes" not in cf.unmapped + + def test_ocsf_no_metadata_includes_all(self): + """Without attributes_metadata, all attributes should be included (backward compat).""" + req = UniversalComplianceRequirement( + id="REQ-1", + description="Test", + attributes={"Section": "IAM", "Custom": "value"}, + checks={"aws": ["check_a"]}, + ) + fw = _make_framework([req], attrs_metadata=None) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + attrs = output.data[0].unmapped["requirement_attributes"] + assert "section" in attrs + assert "custom" in attrs + + def test_ocsf_default_is_true(self): + """output_formats.ocsf defaults to True — attributes are included unless explicitly excluded.""" + metadata = [ + AttributeMetadata(key="Section", type="str"), + AttributeMetadata(key="Profile", type="str"), + ] + req = UniversalComplianceRequirement( + id="REQ-1", + description="Test", + attributes={"Section": "IAM", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ) + fw = _make_framework([req], attrs_metadata=metadata) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + attrs = output.data[0].unmapped["requirement_attributes"] + assert "section" in attrs + assert "profile" in attrs + + def test_ocsf_filter_on_manual_requirements(self): + """OCSF filtering should also apply to manual requirements.""" + metadata = [ + AttributeMetadata( + key="Section", type="str", output_formats=OutputFormats(ocsf=True) + ), + AttributeMetadata( + key="InternalNote", + type="str", + output_formats=OutputFormats(ocsf=False), + ), + ] + req = UniversalComplianceRequirement( + id="MANUAL-1", + description="Manual", + attributes={"Section": "Logging", "InternalNote": "hidden"}, + checks={}, + ) + fw = _make_framework([req], attrs_metadata=metadata) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + cf = output.data[0] + assert cf.unmapped["requirement_attributes"]["section"] == "Logging" + assert "internal_note" not in cf.unmapped["requirement_attributes"] diff --git a/tests/lib/outputs/compliance/universal/universal_table_test.py b/tests/lib/outputs/compliance/universal/universal_table_test.py new file mode 100644 index 0000000000..7598c43c8b --- /dev/null +++ b/tests/lib/outputs/compliance/universal/universal_table_test.py @@ -0,0 +1,384 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from prowler.lib.check.compliance_models import ( + ComplianceFramework, + OutputsConfig, + ScoringConfig, + SplitByConfig, + TableConfig, + TableLabels, + UniversalComplianceRequirement, +) +from prowler.lib.outputs.compliance.universal.universal_table import ( + _build_requirement_check_map, + _get_group_key, + get_universal_table, +) + + +def _make_finding(check_id, status="PASS", muted=False): + """Create a mock finding for table tests.""" + finding = SimpleNamespace() + finding.check_metadata = SimpleNamespace(CheckID=check_id) + finding.status = status + finding.muted = muted + return finding + + +def _make_framework(requirements, table_config, provider="AWS"): + return ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider=provider, + version="1.0", + description="Test", + requirements=requirements, + outputs=OutputsConfig(table_config=table_config) if table_config else None, + ) + + +class TestBuildRequirementCheckMap: + def test_basic(self): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="1.2", + description="test2", + attributes={"Section": "IAM"}, + checks={"aws": ["check_b", "check_c"]}, + ), + ] + fw = _make_framework(reqs, TableConfig(group_by="Section")) + check_map = _build_requirement_check_map(fw) + assert "check_a" in check_map + assert len(check_map["check_b"]) == 2 + assert "check_c" in check_map + + def test_dict_checks_no_provider_filter(self): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ), + ] + fw = _make_framework(reqs, TableConfig(group_by="Section")) + check_map = _build_requirement_check_map(fw) + assert "check_a" in check_map + assert "check_b" in check_map + + def test_dict_checks_filtered_by_provider(self): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ), + ] + fw = _make_framework(reqs, TableConfig(group_by="Section")) + check_map = _build_requirement_check_map(fw, provider="aws") + assert "check_a" in check_map + assert "check_b" not in check_map + + def test_dict_checks_provider_not_present(self): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ), + ] + fw = _make_framework(reqs, TableConfig(group_by="Section")) + check_map = _build_requirement_check_map(fw, provider="gcp") + assert len(check_map) == 0 + + +class TestGetGroupKey: + def test_normal_field(self): + req = UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={}, + ) + assert _get_group_key(req, "Section") == ["IAM"] + + def test_tactics(self): + req = UniversalComplianceRequirement( + id="T1190", + description="test", + attributes={}, + checks={}, + tactics=["Initial Access", "Execution"], + ) + assert _get_group_key(req, "_Tactics") == ["Initial Access", "Execution"] + + +class TestGroupedMode: + def test_grouped_rendering(self, capsys): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging"}, + checks={"aws": ["check_b"]}, + ), + ] + tc = TableConfig(group_by="Section") + fw = _make_framework(reqs, tc) + + findings = [ + _make_finding("check_a", "PASS"), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + "/tmp", + False, + framework=fw, + ) + + captured = capsys.readouterr() + assert "IAM" in captured.out + assert "Logging" in captured.out + assert "PASS" in captured.out + assert "FAIL" in captured.out + + +class TestSplitMode: + def test_split_rendering(self, capsys): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="1.2", + description="test2", + attributes={"Section": "Storage", "Profile": "Level 2"}, + checks={"aws": ["check_b"]}, + ), + ] + tc = TableConfig( + group_by="Section", + split_by=SplitByConfig(field="Profile", values=["Level 1", "Level 2"]), + ) + fw = _make_framework(reqs, tc) + + findings = [ + _make_finding("check_a", "PASS"), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + "/tmp", + False, + framework=fw, + ) + + captured = capsys.readouterr() + assert "Storage" in captured.out + assert "Level 1" in captured.out + assert "Level 2" in captured.out + + +class TestScoredMode: + def test_scored_rendering(self, capsys): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM", "LevelOfRisk": 5, "Weight": 100}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="1.2", + description="test2", + attributes={"Section": "IAM", "LevelOfRisk": 3, "Weight": 50}, + checks={"aws": ["check_b"]}, + ), + ] + tc = TableConfig( + group_by="Section", + scoring=ScoringConfig(risk_field="LevelOfRisk", weight_field="Weight"), + ) + fw = _make_framework(reqs, tc) + + findings = [ + _make_finding("check_a", "PASS"), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + "/tmp", + False, + framework=fw, + ) + + captured = capsys.readouterr() + assert "IAM" in captured.out + assert "Score" in captured.out + assert "Threat Score" in captured.out + + +class TestCustomLabels: + def test_ens_spanish_labels(self, capsys): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Marco": "operacional"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="1.2", + description="test2", + attributes={"Marco": "organizativo"}, + checks={"aws": ["check_b"]}, + ), + ] + tc = TableConfig( + group_by="Marco", + labels=TableLabels( + pass_label="CUMPLE", + fail_label="NO CUMPLE", + provider_header="Proveedor", + title="Estado de Cumplimiento", + ), + ) + fw = _make_framework(reqs, tc) + + findings = [_make_finding("check_a", "PASS"), _make_finding("check_b", "FAIL")] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + "/tmp", + False, + framework=fw, + ) + + captured = capsys.readouterr() + assert "CUMPLE" in captured.out + assert "Estado de Cumplimiento" in captured.out + + +class TestMultiProviderDictChecks: + def test_only_aws_checks_matched(self, capsys): + """With dict checks and provider='aws', only AWS checks match findings.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging"}, + checks={"aws": ["check_c"], "gcp": ["check_d"]}, + ), + ] + tc = TableConfig(group_by="Section") + fw = ComplianceFramework( + framework="MultiCloud", + name="Multi", + description="Test", + requirements=reqs, + outputs=OutputsConfig(table_config=tc), + ) + + findings = [ + _make_finding("check_a", "PASS"), + _make_finding("check_b", "FAIL"), # Azure check, should be ignored + _make_finding("check_c", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + "check_c": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "multi_cloud", + "output", + "/tmp", + False, + framework=fw, + provider="aws", + ) + + captured = capsys.readouterr() + assert "IAM" in captured.out + assert "Logging" in captured.out + # check_b (azure) should not have been counted as FAIL for AWS + assert "PASS" in captured.out + + +class TestNoTableConfig: + def test_returns_early_without_table_config(self, capsys): + fw = ComplianceFramework( + framework="TestFW", + name="Test", + provider="AWS", + description="Test", + requirements=[], + ) + get_universal_table([], {}, "test", "out", "/tmp", False, framework=fw) + captured = capsys.readouterr() + assert captured.out == "" + + def test_returns_early_without_framework(self, capsys): + get_universal_table([], {}, "test", "out", "/tmp", False, framework=None) + captured = capsys.readouterr() + assert captured.out == "" From 29a2f8fac8be33e1dcf7c607d7ae6efcb1ac161e Mon Sep 17 00:00:00 2001 From: "Pablo Fernandez Guerra (PFE)" <148432447+pfe-nazaries@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:18:39 +0200 Subject: [PATCH 03/23] chore: remove legacy ui-checks hook from root pre-commit config (#10834) Co-authored-by: Pablo F.G --- .pre-commit-config.yaml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c7d3f86f3b..fb83db5864 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -138,12 +138,3 @@ repos: entry: bash -c 'vulture --exclude "contrib,.venv,api/src/backend/api/tests/,api/src/backend/conftest.py,api/src/backend/tasks/tests/,skills/" --min-confidence 100 .' language: system files: '.*\.py' - - - id: ui-checks - name: UI - Husky Pre-commit - description: "Run UI pre-commit checks (Claude Code validation + healthcheck)" - entry: bash -c 'cd ui && .husky/pre-commit' - language: system - files: '^ui/.*\.(ts|tsx|js|jsx|json|css)$' - pass_filenames: false - verbose: true From 97a085bf219053d73814f77cdfc5c9bc3f8334e4 Mon Sep 17 00:00:00 2001 From: Davidm4r Date: Wed, 22 Apr 2026 09:28:39 +0200 Subject: [PATCH 04/23] feat(ui): Add user expulsion from tenants with JWT authentication fix (#10787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Pablo F.G Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Adrián Peña --- api/CHANGELOG.md | 10 +- api/src/backend/api/filters.py | 1 + api/src/backend/api/tests/test_views.py | 283 ++++++++++++++++++ api/src/backend/api/v1/views.py | 93 +++++- .../expel-user-organization-modal.png | Bin 0 -> 63494 bytes .../multi-tenant/expel-user-organization.png | Bin 0 -> 59037 bytes .../tutorials/prowler-app-multi-tenant.mdx | 29 ++ ui/CHANGELOG.md | 4 + ui/actions/users/users.ts | 255 ++++++++++++++-- ui/app/(prowler)/users/page.tsx | 38 ++- ui/auth.config.ts | 6 +- ui/components/users/forms/expel-user-form.tsx | 88 ++++++ ui/components/users/forms/index.ts | 1 + .../users/table/data-table-row-actions.tsx | 71 ++++- ui/types/users.ts | 8 + 15 files changed, 832 insertions(+), 55 deletions(-) create mode 100644 docs/images/prowler-app/multi-tenant/expel-user-organization-modal.png create mode 100644 docs/images/prowler-app/multi-tenant/expel-user-organization.png create mode 100644 ui/components/users/forms/expel-user-form.tsx diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 213ee215de..8787c7cb42 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.26.0] (Prowler UNRELEASED) + +### 🔄 Changed + +- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787) + +--- + ## [1.25.3] (Prowler v5.24.3) ### 🐞 Fixed @@ -20,7 +28,6 @@ All notable changes to the **Prowler API** are documented in this file. - `/finding-groups/latest//resources` now selects the latest completed scan per provider by `-completed_at` (then `-inserted_at`) instead of `-inserted_at`, matching the `/finding-groups/latest` summary path and the daily-summary upsert so overlapping scans no longer produce diverging `delta`/`new_count` between the two endpoints [(#10802)](https://github.com/prowler-cloud/prowler/pull/10802) ---- ## [1.25.1] (Prowler v5.24.1) @@ -34,6 +41,7 @@ All notable changes to the **Prowler API** are documented in this file. - Attack Paths: Missing `tenant_id` filter while getting related findings after scan completes [(#10722)](https://github.com/prowler-cloud/prowler/pull/10722) - Finding group counters `pass_count`, `fail_count` and `manual_count` now exclude muted findings [(#10753)](https://github.com/prowler-cloud/prowler/pull/10753) - Silent data loss in `ResourceFindingMapping` bulk insert that left findings orphaned when `INSERT ... ON CONFLICT DO NOTHING` dropped rows without raising; added explicit `unique_fields` [(#10724)](https://github.com/prowler-cloud/prowler/pull/10724) +- `DELETE /tenants/{tenant_pk}/memberships/{id}` now deletes the expelled user's account when the removed membership was their last one, and blacklists every outstanding refresh token for that user so their existing sessions can no longer mint new access tokens [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787) --- diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index fa31289964..c787d714b1 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -330,6 +330,7 @@ class MembershipFilter(FilterSet): model = Membership fields = { "tenant": ["exact"], + "user": ["exact"], "role": ["exact"], "date_joined": ["date", "gte", "lte"], } diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index b2e5271bda..b81ae0c677 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -32,6 +32,11 @@ from django_celery_results.models import TaskResult from rest_framework import status from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response +from rest_framework_simplejwt.token_blacklist.models import ( + BlacklistedToken, + OutstandingToken, +) +from rest_framework_simplejwt.tokens import RefreshToken from api.attack_paths import ( AttackPathsQueryDefinition, @@ -47,6 +52,7 @@ from api.models import ( Finding, Integration, Invitation, + InvitationRoleRelationship, LighthouseProviderConfiguration, LighthouseProviderModels, LighthouseTenantConfiguration, @@ -746,6 +752,39 @@ class TestTenantViewSet: # Test user + 2 extra users for tenant 2 assert len(response.json()["data"]) == 3 + def test_tenants_list_memberships_filter_by_user( + self, authenticated_client, tenants_fixture, extra_users + ): + _, tenant2, _ = tenants_fixture + _, user3_membership = extra_users + user3, membership3 = user3_membership + + response = authenticated_client.get( + reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id}), + {"filter[user]": str(user3.id)}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["id"] == str(membership3.id) + + def test_tenants_list_memberships_filter_by_user_no_match( + self, authenticated_client, tenants_fixture, extra_users + ): + _, tenant2, _ = tenants_fixture + unrelated_user = User.objects.create_user( + name="unrelated", + password=TEST_PASSWORD, + email="unrelated@gmail.com", + ) + + response = authenticated_client.get( + reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id}), + {"filter[user]": str(unrelated_user.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"] == [] + def test_tenants_list_memberships_as_member( self, authenticated_client, tenants_fixture, extra_users ): @@ -803,6 +842,7 @@ class TestTenantViewSet: ): _, tenant2, _ = tenants_fixture user_membership = Membership.objects.get(tenant=tenant2, user__email=TEST_USER) + user_id = user_membership.user_id response = authenticated_client.delete( reverse( "tenant-membership-detail", @@ -811,6 +851,127 @@ class TestTenantViewSet: ) assert response.status_code == status.HTTP_403_FORBIDDEN assert Membership.objects.filter(id=user_membership.id).exists() + assert User.objects.filter(id=user_id).exists() + + def test_expel_user_deletes_account_if_last_membership( + self, authenticated_client, tenants_fixture, extra_users + ): + # TEST_USER is OWNER of tenant2; user3 is MEMBER only in tenant2 + _, tenant2, _ = tenants_fixture + _, user3_membership = extra_users + user3, membership3 = user3_membership + + assert Membership.objects.filter(user=user3).count() == 1 + + response = authenticated_client.delete( + reverse( + "tenant-membership-detail", + kwargs={"tenant_pk": tenant2.id, "pk": membership3.id}, + ) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Membership.objects.filter(id=membership3.id).exists() + assert not User.objects.filter(id=user3.id).exists() + + def test_expel_user_blacklists_refresh_tokens( + self, authenticated_client, tenants_fixture, extra_users + ): + _, tenant2, _ = tenants_fixture + _, user3_membership = extra_users + user3, membership3 = user3_membership + + # Issue two refresh tokens to simulate active sessions + RefreshToken.for_user(user3) + RefreshToken.for_user(user3) + outstanding_ids = list( + OutstandingToken.objects.filter(user=user3).values_list("id", flat=True) + ) + assert len(outstanding_ids) == 2 + assert not BlacklistedToken.objects.filter( + token_id__in=outstanding_ids + ).exists() + + response = authenticated_client.delete( + reverse( + "tenant-membership-detail", + kwargs={"tenant_pk": tenant2.id, "pk": membership3.id}, + ) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert ( + BlacklistedToken.objects.filter(token_id__in=outstanding_ids).count() == 2 + ) + + def test_expel_user_blacklists_refresh_tokens_is_idempotent( + self, authenticated_client, tenants_fixture, extra_users + ): + # Regression test for the bulk blacklisting path: if one of the + # user's refresh tokens is already blacklisted when the expel + # endpoint runs, the remaining tokens must still be blacklisted + # and the already-blacklisted one must not be duplicated. + tenant1, tenant2, _ = tenants_fixture + _, user3_membership = extra_users + user3, membership3 = user3_membership + + # Keep the user alive after the expel so the assertions below can + # still query OutstandingToken by user_id. + Membership.objects.create( + user=user3, + tenant=tenant1, + role=Membership.RoleChoices.MEMBER, + ) + + RefreshToken.for_user(user3) + RefreshToken.for_user(user3) + outstanding_ids = list( + OutstandingToken.objects.filter(user=user3).values_list("id", flat=True) + ) + assert len(outstanding_ids) == 2 + + # Pre-blacklist one of the two tokens to simulate a prior revocation. + BlacklistedToken.objects.create(token_id=outstanding_ids[0]) + assert ( + BlacklistedToken.objects.filter(token_id__in=outstanding_ids).count() == 1 + ) + + response = authenticated_client.delete( + reverse( + "tenant-membership-detail", + kwargs={"tenant_pk": tenant2.id, "pk": membership3.id}, + ) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + blacklisted = BlacklistedToken.objects.filter(token_id__in=outstanding_ids) + assert blacklisted.count() == 2 + assert set(blacklisted.values_list("token_id", flat=True)) == set( + outstanding_ids + ) + + def test_expel_user_keeps_account_if_has_other_memberships( + self, authenticated_client, tenants_fixture, extra_users + ): + tenant1, tenant2, _ = tenants_fixture + _, user3_membership = extra_users + user3, membership3 = user3_membership + + # Give user3 an additional membership in tenant1 so they are not orphaned + other_membership = Membership.objects.create( + user=user3, + tenant=tenant1, + role=Membership.RoleChoices.MEMBER, + ) + + response = authenticated_client.delete( + reverse( + "tenant-membership-detail", + kwargs={"tenant_pk": tenant2.id, "pk": membership3.id}, + ) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Membership.objects.filter(id=membership3.id).exists() + assert User.objects.filter(id=user3.id).exists() + assert Membership.objects.filter(id=other_membership.id).exists() def test_tenants_delete_another_membership_as_owner( self, authenticated_client, tenants_fixture, extra_users @@ -882,6 +1043,128 @@ class TestTenantViewSet: assert response.status_code == status.HTTP_404_NOT_FOUND assert Membership.objects.filter(id=other_membership.id).exists() + def test_delete_membership_cleans_up_orphaned_role_grants( + self, authenticated_client, tenants_fixture + ): + """Test that deleting a membership removes UserRoleRelationship records + for that tenant while preserving grants in other tenants.""" + tenant1, tenant2, _ = tenants_fixture + + # Create a user with memberships in both tenants + user = User.objects.create_user( + name="Multi-tenant User", + password=TEST_PASSWORD, + email="multitenant@test.com", + ) + + # Create memberships in both tenants + Membership.objects.create( + user=user, tenant=tenant1, role=Membership.RoleChoices.MEMBER + ) + membership2 = Membership.objects.create( + user=user, tenant=tenant2, role=Membership.RoleChoices.MEMBER + ) + + # Create roles in both tenants + role1 = Role.objects.create( + name="Test Role 1", tenant=tenant1, manage_providers=True + ) + role2 = Role.objects.create( + name="Test Role 2", tenant=tenant2, manage_scans=True + ) + + # Create user role relationships for both tenants + UserRoleRelationship.objects.create(user=user, role=role1, tenant=tenant1) + UserRoleRelationship.objects.create(user=user, role=role2, tenant=tenant2) + + # Verify initial state + assert UserRoleRelationship.objects.filter(user=user, tenant=tenant1).exists() + assert UserRoleRelationship.objects.filter(user=user, tenant=tenant2).exists() + assert Role.objects.filter(id=role1.id).exists() + assert Role.objects.filter(id=role2.id).exists() + + # Delete membership from tenant2 (authenticated user is owner of tenant2) + response = authenticated_client.delete( + reverse( + "tenant-membership-detail", + kwargs={"tenant_pk": tenant2.id, "pk": membership2.id}, + ) + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Verify the membership was deleted + assert not Membership.objects.filter(id=membership2.id).exists() + + # Verify UserRoleRelationship for tenant2 was deleted + assert not UserRoleRelationship.objects.filter( + user=user, tenant=tenant2 + ).exists() + + # Verify UserRoleRelationship for tenant1 is preserved + assert UserRoleRelationship.objects.filter(user=user, tenant=tenant1).exists() + + # Verify orphaned role2 was deleted (no more user or invitation relationships) + assert not Role.objects.filter(id=role2.id).exists() + + # Verify role1 is preserved (still has user relationship) + assert Role.objects.filter(id=role1.id).exists() + + # Verify the user still exists (has other memberships) + assert User.objects.filter(id=user.id).exists() + + def test_delete_membership_preserves_role_with_invitation_relationship( + self, authenticated_client, tenants_fixture + ): + """Test that roles are not deleted if they have invitation relationships.""" + _, tenant2, _ = tenants_fixture + + # Create a user with membership + user = User.objects.create_user( + name="Test User", password=TEST_PASSWORD, email="testuser@test.com" + ) + membership = Membership.objects.create( + user=user, tenant=tenant2, role=Membership.RoleChoices.MEMBER + ) + + # Create a role and user relationship + role = Role.objects.create( + name="Shared Role", tenant=tenant2, manage_providers=True + ) + UserRoleRelationship.objects.create(user=user, role=role, tenant=tenant2) + + # Create an invitation with the same role + invitation = Invitation.objects.create(email="pending@test.com", tenant=tenant2) + InvitationRoleRelationship.objects.create( + invitation=invitation, role=role, tenant=tenant2 + ) + + # Verify initial state + assert UserRoleRelationship.objects.filter(user=user, role=role).exists() + assert InvitationRoleRelationship.objects.filter( + invitation=invitation, role=role + ).exists() + assert Role.objects.filter(id=role.id).exists() + + # Delete the membership + response = authenticated_client.delete( + reverse( + "tenant-membership-detail", + kwargs={"tenant_pk": tenant2.id, "pk": membership.id}, + ) + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Verify UserRoleRelationship was deleted + assert not UserRoleRelationship.objects.filter(user=user, role=role).exists() + + # Verify role is preserved because invitation relationship exists + assert Role.objects.filter(id=role.id).exists() + assert InvitationRoleRelationship.objects.filter( + invitation=invitation, role=role + ).exists() + def test_tenants_list_no_permissions( self, authenticated_client_no_permissions_rbac, tenants_fixture ): diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 2f74d0283d..28b5fd49f0 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -83,6 +83,10 @@ from rest_framework.permissions import SAFE_METHODS from rest_framework_json_api import filters as jsonapi_filters from rest_framework_json_api.views import RelationshipView, Response from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +from rest_framework_simplejwt.token_blacklist.models import ( + BlacklistedToken, + OutstandingToken, +) from tasks.beat import schedule_provider_scan from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils from tasks.jobs.export import get_s3_client @@ -169,6 +173,7 @@ from api.models import ( FindingGroupDailySummary, Integration, Invitation, + InvitationRoleRelationship, LighthouseConfiguration, LighthouseProviderConfiguration, LighthouseProviderModels, @@ -1330,9 +1335,11 @@ class MembershipViewSet(BaseTenantViewset): ), destroy=extend_schema( summary="Delete tenant memberships", - description="Delete the membership details of users in a tenant. You need to be one of the owners to delete a " - "membership that is not yours. If you are the last owner of a tenant, you cannot delete your own " - "membership.", + description="Delete a user's membership from a tenant. This action: (1) removes the membership, " + "(2) revokes all refresh tokens for the expelled user, (3) removes their role grants for this tenant, " + "(4) cleans up orphaned roles, and (5) deletes the user account if this was their last membership. " + "You must be a tenant owner to delete another user's membership. The last owner of a tenant cannot " + "delete their own membership.", tags=["Tenant"], ), ) @@ -1341,6 +1348,7 @@ class TenantMembersViewSet(BaseTenantViewset): http_method_names = ["get", "delete"] serializer_class = MembershipSerializer queryset = Membership.objects.none() + filterset_class = MembershipFilter # Authorization is handled by get_requesting_membership (owner/member checks), # not by RBAC, since the target tenant differs from the JWT tenant. required_permissions = [] @@ -1398,7 +1406,84 @@ class TenantMembersViewSet(BaseTenantViewset): "You do not have permission to delete this membership." ) - membership_to_delete.delete() + user_to_check_id = membership_to_delete.user_id + tenant_id = membership_to_delete.tenant_id + # All writes run on the admin connection so that the uncommitted + # membership delete is visible to the subsequent "other memberships" + # check. Splitting the delete and the check across the default + # (prowler_user, RLS) and admin connections caused the admin side to + # miss the just-deleted row and leave the User row orphaned. + with transaction.atomic(using=MainRouter.admin_db): + Membership.objects.using(MainRouter.admin_db).filter( + id=membership_to_delete.id + ).delete() + + # Remove role grants for this user in this tenant to prevent + # orphaned permissions that could allow access after expulsion + deleted_role_relationships = UserRoleRelationship.objects.using( + MainRouter.admin_db + ).filter(user_id=user_to_check_id, tenant_id=tenant_id) + + # Collect role IDs that might become orphaned after deletion + role_ids_to_check = list( + deleted_role_relationships.values_list("role_id", flat=True) + ) + + # Delete the user role relationships for this tenant + deleted_role_relationships.delete() + + # Clean up orphaned roles that have no remaining user or invitation relationships + if role_ids_to_check: + for role_id in role_ids_to_check: + has_user_relationships = ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(role_id=role_id) + .exists() + ) + + has_invitation_relationships = ( + InvitationRoleRelationship.objects.using(MainRouter.admin_db) + .filter(role_id=role_id) + .exists() + ) + + if not has_user_relationships and not has_invitation_relationships: + Role.objects.using(MainRouter.admin_db).filter( + id=role_id + ).delete() + + # Revoke any refresh tokens the expelled user still holds so they + # cannot mint fresh access tokens. This must happen before the + # User row is deleted, because OutstandingToken.user is + # on_delete=SET_NULL in djangorestframework-simplejwt 5.5.1 + # (see rest_framework_simplejwt/token_blacklist/models.py): once + # the user row is gone, user_id becomes NULL and we can no longer + # look up that user's outstanding tokens. Access tokens already + # issued remain valid until SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"] + # expires. + outstanding_token_ids = list( + OutstandingToken.objects.using(MainRouter.admin_db) + .filter(user_id=user_to_check_id) + .values_list("id", flat=True) + ) + if outstanding_token_ids: + BlacklistedToken.objects.using(MainRouter.admin_db).bulk_create( + [ + BlacklistedToken(token_id=token_id) + for token_id in outstanding_token_ids + ], + ignore_conflicts=True, + ) + + has_other_memberships = ( + Membership.objects.using(MainRouter.admin_db) + .filter(user_id=user_to_check_id) + .exists() + ) + if not has_other_memberships: + User.objects.using(MainRouter.admin_db).filter( + id=user_to_check_id + ).delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/docs/images/prowler-app/multi-tenant/expel-user-organization-modal.png b/docs/images/prowler-app/multi-tenant/expel-user-organization-modal.png new file mode 100644 index 0000000000000000000000000000000000000000..ed3b190c61d27623bdec575be0e9e120e6fa746b GIT binary patch literal 63494 zcmd>mgL@ra7jJCaPGdA_(Ac(Z+qToDF&ewEZQHhOTle(C`+m>8egA@co|(*?nb~{x zUfk=q)=9XWj3_(|4h#?w5WKjUkOB}8SQroxup1Nv;0kX@w>JfhNc2B7?cp7#Gy1&3<{qF6rJBKJvA{jl0*bFe_teROMoRN za_J#;SU?F4(RX-fYuayZ3>ZrB&i2};!>LGoN#1*&H{Om19Uo~=2WgH6>ztrKZQmIZ z_H$BzcEzuaKX<*C=Ix?k9AS3?Lr42VGakV1D#$1)vHKY}zInQP!xIhPR|hJbWWIgW znL!IX00W7k^&SZ{5g~480(EMFE2#4T2~q8ineIs@xY6};FdY*0vPh&Ab(cw`m3L!8 zT5lUPBJqOwm5v{bFab{<26C0~MMFCuGHPs+A%7!Bdf`8fOJWm*x*`d)&Vo;iE1xwj z@Xe2VVjH_fmgeDg1S>JXrriaq(n=Z0yz%|D3PkW<%#p-{ttNaJvKWWh%T9~G@IYSl$jVBbk!EZBL9Si9{|XQAu7%PqB_ z96vtgQ+9Q-@F?Z0#*8l;ydo?Z4RQM-@Jl=IIjef)wh;Ddtwb(vR{T6#Y1H1WLMepk z1=pw*qx+#EMXy~C{&t<JyJck(u*meF8$$*K#=XQ3_?W75bnh` zSuoH7Hi%Jxp9a}Kx^$2!{ay>?OJh-^?21c32eFP7i+BA7kPiGAqhT-510`aS5m7ox z2g5$LkO@_QBZx6oj6XUKLYEleAepuVBMjU`FuWIIvBcnrLXZN(>KlR213kC4E{`Eg zn8*9MLv|hwWZk3=$+(EL$eoaFWH2#@MBKk(JMq6|WxwY6rW3BhSTt|HH?k>(4zr`O z0U@&F)gXe@_Kt?w%?--97 zUbv0%auoFtnV5K^ei2v<`BC57-h-f5P_?tDUW#rBLVHR%A5mu*n&4*C$Bm<9-&4~E z_Z$c6EK-roAghur-lUkW7&RlRtlMtp(td5cJ03kA6V6<7t&aCNfK=s%;=jY5SQA%+ z3pD%@b$oMA?Qz)=z_`+3C+P#)`EuCV**T%xIGGFddWgUa9olZkYV~L-54t%@>@T2_ zwQaW-4Ag<=^mu;+YN&@`rN=ei3GW8Xiv%UWPZkC`q=$3M16%Hh{S$oOzb^|c-akPP zzYt8b(?t&SKHx12x;9{E6Vr}B3rw}ky3wP#%W)D?ClC=TAQ}m$o9`AGwHKvIG!2<` zI}nUyOAIbHB9AyZ3{0NrCYoFfyBeoRsGhiPFlvx^m)!y5NXUldA%c#N$5 zeI!+Aw4ibNu$2m>cqRSERTz(H42Ifj4EG2W+i+>YoSo=+0RuZAUZ2{5bVF3PS=>l{ zP&y(u23{^Fz;L>v^<*!}E}_JOq`GMZptI8|_uf)<2GV#I4dHTSrFPW~yK zCaEQ|Dr7}OK`MG84`mu+nqXRfnp}bHh)(^7m4M49$QZ>z z6bIy{wANIwAg}P(pjZDK@v1yG1rjQOVZ33OVFU@-J&^p=YyV>5x?diOJB3TLeX}qY z+;?bq2zLo7DJjV*p&GR*YSu9t!OJ{#%oY!G>qV9&D@BfTigW14BXh1rgo+(S@83jn z)u&#~sA|2I9W?09z|IKH${+-LRO#>?f2sCH-j^cFdLozd}m1i!#$TxWkOaqPqla<%T@WJjYpDKj#tz-!6)ur8+5gE zoM>4uvCAY-5u(k|x!>aldITMQi|;9&;Oyi$W4~SbmP?OIj}NI~t{!R86htnnNL#;@ zA@7dWPF=rfgr^kNC!S9Radn?Wr8BCW)bUj4O9Gr@=S(fka14qW*G!i6pSwnvY1c~! zOS?|Xzs{vuXXv-jw~n<=boUxm_i=sa_^z3VCRuNsxaTo4lW1F7T82{AUT0D5VNqr} zRzqJ~Uej(USaVgEY_4eS*q3NFQa@I;X5=}AQ0G`TQe9Hrq8GK?vh@9oZOJu~G~xz} z0$Y{)yg9HLbRfGkdwi>%bmsGF)nL`oRiE4UE2%zO$OM4+SJ=QdA^+QFJp2piH4bleouNl3a+Wi;hJ+e@(%{N7qJwV>b~P zZT&e@6|s0!)nceS@H9{s)`D+~S`lU|nMINJY4%gAIGmVP`eMQt9&?}RC!;_kdk04t zDsp0DQ;CoGPO6h%P6f@s67zIYEx4WUXKQBQe(lL{a;5X6^S<%n5Yv6Oc3l?rA?rxl z$Q+m4__u$`=^fiKbE&12jIOYh`(Tpjox}-$0788$gEw&n2j87|6qPvQF zV^17*xf|t`@?N=(x|@n}%9k!G-KC&4btaM~4HH$VHBRGk-C5m)``eD-jw}1gyq6mu_2%Oy`p}~%X3#iN(=fa=N>!e&KA^T&Jo@Ol_g_b%+t9F9 zkd?=mw$=~3Dvw!|%>|bbmM6FVvZu4OK3eXWm)3QyU(NR#@i_MAdoKTW`E4H_16Rf7 zQ7u{7y>7UpSU-2GKOc9)3dN?mcFET94I`J%SaaPL-L|cY&P~&>PTBd%W&1whEXtMa zl>H6#C3HC*f^)>hXPJCyP8n6vv3kC(-lbvd9NpP0J*V7M_Bi9aC+I!v0_Uu)r5f0gg=?vD#*hc0WhY9PFy zKHik8%*+?}G>59(ZB|+o#a= z*o``$jmPf&K3U(#x2g4{*@ZWSdxy;l_J?t=xgKGt}-Bqc+pfip!Zd9AUbRy2rf2-g2M68 zKydkVb4cVzSvBVnJX*U01;mHjsWv-TM|?kTPN2$w=UXM995bL^cOa+j-WU$AL|iUc zYFOk9_3fkcWL=_@s4XxC)oI6dOfEX2GE#5oCD_~SU`LMW+*Nw2L}%XYy-M; z0r>vSSWVnSS{jHNa1I3o28;s)4mbk_>^#7@f1Qf}Qv!kh>pBP!P^dW&*nggp0UUpS zq5=EwHvc|?#)bev0=}UDcDF2$|2_>CmIeCXb6_{XJs^HXL2+@wQPI%B*x1I=%+{&- zrBw%T0oqPX-4O@~gZ%dnEUrLy3Fv>pTuIGIO(AJt>-^kX$nBL9W?sq>xTy7kI zQ)^=(7I9YO&s7cEa3EDas6S2}W(le6qzz`7; zaXA>7a3~0g{HHnK8#jrWlan0>1B0upE4?c-y{&^O!{@JGzcMf~F)%UF0iK|9bhmNR zccZg$B>i_M|LsS}*wN6z+|J3|)`sYJzxoEY&Q9DUB)?zu*XQ5kGtE0RKX?9qHWkc;8>)c=PR|EBZ5t^x?n1H;Ae z7t;V=JePO@e8e*sl2rm60aEs_Pb=UrC1C%31njwR%hAL2KtOyz;zImNZosGMkgapI zm;;F4VC}O>V5$pF=_HqM!Il*9{FsD|5ryUWz|q)Y2HI5Q*R5|5x4sK z>}AH@*Sog5`ad*wT)NjbNKfyskXg&hjElRBu?+15`Gbl1{b?gIfhNoKqcpkTrjY?5 zrK+@-9l#KBqi}z2y~=;G5a`jylKqGiwOT%xxs}R<%Z+g zRxmkkE;bUEQGx{E{yZZ-kfcnY(GRtKzw)+qYB)4cnRE_p2J0LkU}P(}d@1)5V_m2e zfsfa8JC^*Bs0i{u(HVuvpC%3n&AYsXC)k)7$K{($`tw+|Jes&QJ_w1C+s#;mdUrOn z>3c%Q2M4;iPK4w?(8VqpO3PPJVo&Q-!x-Z*Esg8RQYb|T0eFh8A+I_yL7v?y`J9a5 zA6i2bWd#eV+1$TebOfcx(Gg8U6jha;PRvmBjQb&r|55z>z)%qV(b(3l=-9sZ+SQR0 zbx&7o+P19~Lk9^Wp?v-`;envZxIv~kCGyu*;!{1zT|9Cq|QDv8oTDIo6)H2 z^E7v@C9*Rs{;^*J@n;zW0$EmbW~aZ1%a_fhI^Xd9NKo{BEuPQqiy``xXanTE6P=Gp zz1k@D`qxT)+mYk-Nd-l~Jf(Wd7jwFzNkR%=Xq*^|f8uE@hJUJ!j4}G-L1q*r9Xq%X z|J5bgpWj2K4T6S5zUDa)8}~;g<5PkHSQzw((V#z*GnO7;o?_4242S+a7$RygN}_Kt zBL8Fw#FK!)g~T7$t9JiPI3#qy_=LepnTh{k-2hsbC+P>0ljnJ%=J02E34;NQFDD=- z`OhNBH%-n5EI-}mO8fQC^braMj8DF6Q0gD7=n+bmALY?HXJXo)>60S>7+;~D(WgI8 zFWL!Uz~h}R@FoAj`pXjo#;3%{g!<>{i8TYrE7ANAN&aUC;9rOY7#}rOGW4IP_y0Hh zwTi$Tb_ey9tsloM1$&NAtM_%3IJo{X^S*-${zS zp03gJ)W2h>q^Lq5#y-L%lQf%gktp(^A(~13(fuLef=K}*Ti}I9527Iu(}4?Rb^gik z0|Z7q+D~&5H8Pr#$DE!5#{x-P=DC&iPmJV?B|-}Cq=;<{&%Xw!b0TM#!yo0H%mQQ! z%BMR#4vR({Sl*zOh8FdY-Q|!WNdcjqYUPwh$Wy|E!C|eE|5^Ryz*@oh#d6yc^m*|( zg~O86K){8<_hc&Z{wVI^A%2=-Mi^X0(4lfJFkz#dgWi7@Yp6KCq-aVim{2)wn6U2F zqX>~d`^!%N<(9%({Ri2{BHaWmD4=3=7 zAx|22kr1MFQCB1iC;1V^8$5|pa=z5z?QUU(h3n}>{Hw!mi^mg&4zZa}Ka2(<@m1}9Fev?a+XaC= zkNK}la6^R>1wxa-yI5tonEHv1G*_al8v^f3%71GrmH8>1(^*Q6KviQK6A~_7RD;_> zmF9f)>(I|FofkG&6iokl>t(S_o;RUb@*7&UvlJGin|T3ko~E|YnP7$`MUCa_*y(amT3X%gh`OA+Ii2yJ{DU_3#A;n)3Rof4o z-$L`H98D$?GkxF1$J4*ZA&Nvfq#Z;JLg@|d9oL=UhwJ#5XSIH;w?rE5M|~$I5_n(p zyp4A`<6KO_;W{*Se@JgQ+gK$Ame%VZT~er&BR)9*tkWr^aaiK(EEf)E#4TF9_Ecku z1tD5SB{3KhRibJueOp`?WmknBQ~#?dKwu7(@E@tSi=7lf-qt4mM1?#8`(_!&70;F29v8*DbH*fzXswDrv5(de|qphL!& z1{&iSLBhrPf>QLJCIb-@6To$Ss5~Fe#ntwHK7S*(n^RUD3N#*(*dJ9l>W?_OzLDi6 zUuqViCK3={l8rq*GwJ%zGGj9bnGswl1}fCaUy2wRTqs3?WZ7eh$fMdCH<|6>Mo4Km zPZbTYGU?}TId48On&2r2t)BSgL%r6_lk|l;T1X;!5ll!N4EMV@h&(A0A+Y=`{dmM* zKE9k11PO(>SM`M=`-lNt$^r>Ho{ES`uh%gNB*!hFrFY1t^-5%^YNUuC#JVxNqRk^* z(!p3a5+sM_<-OV^JQJ{H2>&3c3i`_mhKB+!^|y%?`W-GEG)w%FN{+x24uQv|hP=OU z#hc-^X+HXd&3H*Za#qW8--F0I*a^&VoT-q-$M2p~HxnrTfvFRXO5qb{{z>k`_UemE~y7xU#>xBEi~U~C^nW=o1^JweAeN15P7M_ zw1J#Fm@Qw|_ic_obwQ>UObAI2&b^PS^e3}vWEPxHwn4sxLV=9-z)`!m2PB>2KCL>f zOOwN1Y}!{xYE5+Py~QfS`D8#os;fnQMUwZCN z<~7_QMtVxl34Pc_SSd++8a8}dxZZ^Oo|{wISgbLPB6{l%h6Q7_$ibILq$X#voKL(xx==Qjp)j3DPqI6(CQpkV%7gdFmpQ?n6D<~PTV<`=l z*B#8x7IOnWO6A85nsD{jYdG@lI))d5$4iPu4#pc^0qLENV1|yBepjeFF!ky+MhTuu zD}4$Lh8Agy&BouQGH#Qr6YgtFG4|C|CD%+{8DBQI?xdcyxHX-^YBLX=)MvS^3j6Cy zm#W2=J*OqUp;68LyiZqN4~E4kG)}(}yhne)vW)B+tk@$h%QLGonD>NeMI_+#klLpK z-L5eikDfVJ|KGJ2G$0s=;b+VS$w85*WV)&u5{WeMcKTww+-ooO(J5+lgF$yEYq=ug z{;dl(6Jf4+A^KsRu*g?q#Q`xfX2a)Y?3ynak*ebIVZ(RCvH~GSo;2OOZF?JQHQn`a zap_s?>ibPe^~4uFKHodwi(5Ox{-{JhC_9@Nv_ z*NMPkvmnxBwN2FT(M>9zD~2@kmGPFlJ6qB0kHR%g4`e*NV-um&q}c=6-UfPBi|dCs z9=JctI0(eoYVpZkbzac!%2_amw4n87-BZFYX6&Ta+v?@sdOF|Hl$>wfm{4*Mpi6#|3gFc@~-A$dNGntH1zGYloP-U^~7`Eb~eU#Q-CR_HgK%GR%Tax4eAS9$~9$hFV^;);1a-p z<%jP6hDo!H4UbBp-ONQ3#r0fhc-@PS-Cd(Dk+oJ~OZe6MIDW^8%rITkktx(Mn;3-U9FMa45fdu{Sf=sao8*T2~;Fm4fvl7e%6VfkX-O#@w&GHFu znwbJ=c#3A*G|_mM-Wv(jgX5C8m;TSPn>sFv3&F_76`SNl%dO^daPo$=oPg1t+mGVw zeyn%+W^&Edmw7eW#qbzezkvx&P6i=i@7OeMS(6ia>?IHbPqjUFlG3G=n2I=?ujZNa zc@6kCvLCHB#dV?NMK8`(#&E|CPlbB!PGgYvNw z<}M+)o7gBC-8$nyt7kTG({4f=DXfOS`0Oxf`P7!x+*2K4M*{M2c@z4sJR04V&=Ax1@SIm|1jOYhD5S!JU@S z<7`fKHMH+7AQcN-i0}u#DYWKjcW&BByb1^p5pnNe5<{k-TWI5?Sr-$zYbV@w5-g_V za6HvoLFrzw@7r6_Zo4e>&8)*GW)PE--iRM>^elCAws4%TEmtJ?KdAI9x)- z3x($SJ=bUzQA+XRkhDAglfsY>ZQS-3?+6p`EX;E_8^0Qae@Yy3xBmhzq{hLrUDmL+q~4Le7D8^$w(gc)QK8jD!siK4 zT(Gk!@{vSNE=ZD@I11Te`Z_R_2*>?q+3AfEu;X$M7w>!QFvT^L~G#G|=VoiTfD+J`hI>*l(&2`*rMqQn=1uUMvT~618>Ob_fiw;QEE&64^sgFKzT9m$vfs@&1-hMgR_GH0j5kkU-!<89{k}el+7d z977s5W+rN5Zg zIm&BQ^m9wp*fZx8G#}IoP4C|E8ja<|)++)}ftAPO{*?Y?zPwIHstOI3u;2|6&T04i z$(&pYR?F-_4fZNSW4JUQm?ws0JkSK38uj$AJ5&T|^u_0{>2`x*)*lE++X>Y2T%A1Q zkFq=oOvVJ0Q>AoZ;+bAdUG@5oFZixv+WFj*4Vj9mu=R`_g~t3Qm(Q0o2l^8h<=3?h zE@L>B&}cj>p&<%S`ssxP|VRw6oJSW3YtLaUhhPc6&pa{M6?-_ILvv`dVs|#VE%Z-wWdjX7sxkO#uD%nV6VuK>W`tJOFmhyUXw`r zT`&|ud~O&WwDx5cz&ogJ|73|1C`n4h;w$<&>mu3}Wvq#*Pl*+3=vl}h@ZIg+&uZ2< z0;05}!SJLF^@Z&rNq&Rh&rW(mH3L8*3OMxK$-d}Sk|{ziiaIbT-=Bbrf%-!qH~RE= z@N+?JHoIOBvr%%gnshRQ;ZU1EsqzJVhIfaArLqwtHsrxM`N`-XUFK4jg3zKl2wI;( zwD=h`QTkq4QrQ`|^XuX>Ht##?$fuad?>?C??P(d33ZyIw_%gg*ZY*1I;R?l0=GtJE zEiy|#Pu6M*+IFuYSj=ZMpv%gxCfvpi;EI)VHT+C+Hn6^%KH3>i*xemh>@`*^LS=h3 z*l>d+F_-XYC~)WMiAMcTVsKj!fE7Q=DVkS~7MsV3v=@o`GKyedmKtOZ(As0O^hAU%Sf2vzX!_Mtu?KXl^j72>kyBjCTPVE5lK<_C}60F)yi_&oo;Bl6^73(^ymJ|f5I^m9GuXZE@?ET&{<534Y2zr7r-5(%E^R;Qb&|dp|Q~Uy-~7?U}}( zo7QJ`>l18;M}usr0fQ>~R0K^Rb^EicgyH8wlkDGEY1i-MO@vIeV^} zmdowC=Y__Id1a|2`PJSZ1B? zxABMR6O-Pe?ScSaGChl0BCeOl&3jO_L}O_NsBU3kV+0g(@Wp~kB9B-DBu5WNN;|_{ zCFA@$BG+t^)!uUtMwAasn!qh*2G|flNz9q@@RyW=*=f88+kr@exjs89eLm0eRd)o# zHJxq?2H==XMm62do+us{+KoN{TxUssDX@pG@T*mvjv3|)-US9y(~JQgpC1Kv#U_Cz za)H5empfN5wHTZEHCpHyOT+{qe04Be4=Ul$8`)|Ij=A9RPREJs z3OYFV(u-Id8OPr?9*idLWMu#HLvM8*pFYITY*Ug!@_S+wJN=xBdA3hB9+Dfl8H)F3 zUw@-7t!V;r#aTErchin}j=ZvVaI9dTO+128B;u^M^Qa;fZE{y4<6imy<}~qdPJ={m z9La%@{2gTVV()zdlayU1E6JU89>%3%z7^Gdr1(~ zs$I-`vqik4io_4973v3!PN#K7qv_BQDJeSjdXyM7#~g0nkHItRPS1Ar0QT~?fN6a4 z0oLhW4XG2XtLYa0HduQkW6idR*^MHH--`8vkXMH8;xIdxz5c&2=0GJFy=tUY2H_#2 zLAy=Q)1Ey?mmGO7>^_RlfTL|LF;PJJFD?nsUfG{=!tT2`MO==_*^UDU!2PK`$VhE( z<1H3Ny4K7zme`vm!=wgYtxaEh?%WQb z)0Xq)NneI-QV=GkXcP+l-jABjCadC{&h}^3oL~vpCS;9nqctK6l1`eLL&kc_R$Ms{ zAaNn_k)Hzp3(fk0aRFFv#J5yr74ef{xWCA6?QD2{I4emPhTVq159V>VXeJMUNbT!; z5{`U6o-N;d*$NEB(&>)nq}B&JZRtaTNg7`=wTpX?@?F{k07h>P zz3+SUFZaHnrL*QC2sudpkUvrW!LCec^?=FqdPC2^9g@yU@62E0jC*Lcs zE(pD_CGip2L|Ul_{A$`u)HE7lEojShcRU!5vu42BxHCYLLM=^wX0)u}nRM!O81|CL z+C;zfZ?~Vp7$_Q{BAPBQ#|LNf>AYWzP?zbM*3LMmo%zMV0^5y@Ww``vV1qKHig|;X+in01mHFUCk=;e8$QRY^WNIPQ@2K* z9REsWf3&I&{27e8DTdL>{m>Xr*b7Z{m-x1tsYUoklI1*|0DQKn%c9W4ARbeF20t3H#*-6;f9qld10> zFuTyn4lEnA-!^s7T91TNZ*~=?RIWhYshOrJI_>J9Gxsq|s&G<=pS&VPd-kAnOkV*#2b9dDtcZaFiJjbCDGDsf zKFKUuIY5ra1|pxbxHl2I4DfnARyv(8u5Y1m{Jfk<=b62DaWH8(gY7Fg&%Soe>`(&9 zf#0yIZVfy-fn@e5-O+0?8&VibVM+BKJhR|Y5J5u<56b1w^A}tJCYa8uLS~|*3#~Hb zt+vX4qzk+m>9NGNq2%NrgeACG55Z(1t+k%fX#dH!z=KlcN9njfIt>2?!^!O;urwlo z$B?QFkS{l}7o%-c1lwpaLL{^aP)Gzd4d(r(JR+j&l%G(dq;PjZE(asSd|a)`!7wim z7jd-e)f_q6)W9(djfBG(xG*q=Pq-aRrWfKA$ z0*}@M@km?>4_c8Q6iAviCq1paAPWXWTWdv-cT z>{@gcU^H=FuH4AIXbva|T4~>ktzsX+8Kfhr6hDpBXwxr_ev1d>24i5uRv#b*BDI>% zQ@fVqwPzII#e96^`VCyOFBtr38`iJDx3*~Mgk-LPkU)9;n)taV*6cE7ag?)Mo0ML2jZRHQ1&n-pDxLOasfrAa z0AF7dK4VS*yteF2g59;vgXh@qc7kU{SZ`whUm;J-ehEy7a>T~CWSI6OW345DZTkA$ zfWt?S*k0#%# znhr8*r%N7!qX-)rDsr?q2)W!QDTji!0c+eO$pcs_p=oNv0$uO6el9Z22Ai0axss)U z;Y2zL!+{?SzJ?TdD}<3?1iSzPbQ1I9F1zZZlfUruVu3y!u1M8ecYyGBa?g9b$e9>G z%|@?I`L8wOpG6U%m~tti96^?bq5}`hBGj*!!=$rSzr7e!$opb$)F8mRv^cHaXclfX_;ZTO3Y%oT-8ywgt&-3zlo1MHLW!f(%@
$dW%%(es&tVbWA!hT7m5^O2~Wi;qTuh8&GI-O(Q;Exa^PtXxr z=mN(;|5&L}e5$pY%T;{{B>VLT4(sJB5kO0b z*J>uX=oCN6&Z{qK(!xIN&P;%X@QiKVcLy<*1M-^EbU?HPZbI0oZven>H1D2=>x}E> z)X@UE*TfPVj0cQ(lS$A^$8PL)|NYmPCjMTa<@NO*!EKs-gB5oXwv}R5P%8jfDSSE@ z$<#e>VQ6UPZ7M_j34pULb>`O}Qs!K6n}bKp%#aTK$VsJBSuuKVVADCA6XPYm!%2#F zPM7f`646rz-~5`drf|i+S{bo+ z?Rma4`Y|05PS*N%?x~=NZNn;fp~G$T+GxK|>`C}`umk{KHx{p^Ilje{Ui+`HE`DO} zLEP|VP%crretmqH))3NY@Ef#FrHChT?=C>y+1uxd|CODTULS;2)Xy{tD53zTO+T5d z36;XS@cxjh_vqm73AqMD_`kic5@W~f>x^uq;j^gd97Kj=9k4F&)Hh`WeFMq~r|f6w z;Z0*Qd>y`(22TmhsVX|1SD$1s&7l6E(4=|!e>teHayO9wt()Ql`HfrxMI`zaYQG>% zw~5Mnr@g6HlAxaAQ}NhTaR2U5!XL^b6%va{fhXGv!adcou3J=+SzbRd`qCF!WAAatGdd*TrR1Aiby*Kh=O3j zcO%atfU%m1NUj0pA>vJD)VV}1;%*=&utMy*w5vOwyP83O#iWlOAhb6A=$FZhpilCH z66oiCx#xt-uF!4~!I|}3lqb9q%?tPefOVLRhmFklwersc5mMWqMD$h$tSR6hU~s~W zhq=N3hnUh7p}Zy@mWiF-#4cHo+?(*Qzxqp z8I!S;BLMOActyCsEBta=C*=j6+NDy=|0$6RTI+qi*ChL{FjC$ozz*;Z)vDfg9Gl5A zO@@%k*%{d;eq$tN$z75e*boSZ47nnaUl7IrEk#Jz|F;wcg$%0`o?(-ww88#JotNdZ z`RGSDE%3V96A10pd#cW#&T(hvTvt}rHunB3{P@6!bqLu;#P~Suu;Y5y z9So!{&@qy_FQ1Z|Avs>(UoMzFzPqcWyB2!g>`hRROT^dm0O1o7^AP6f&0IWTI=Rea z(03+L`)^w01{zx-ny_O6?9V8}>YF4L(Qq#UJL`I+4rui&uaAFJR}*bC4RUL#p#mTo#2!9@6$h$+?-Ew2^ zI%%Gid9~UusJ1>)u`HsG7&Ht=2(mG`y!nDbC7NEaFs)Ge0xh#>QP zAuvBmi@8TZ>Kc*dtt%b6y*ApnJ||Qq+<5G~uW|Orv>9DbW#t>a^?3mlZ^aZR& zWZoVH7~!NbSBC4WO~5HmfZR~+YMs*vrH@*RO@*V*a=H(8nP!7Z)|Cmp@Es~r0;Rmb zCm)Q)SG9vP7Z$>DKd7S(Z`(ZMTI~b$aSZQ#owjCRoHnr&8&|dJw9h?l{5AOy@^V~U z;{V0*kca`1JPDYIh%0PZ3X3f<0A{uliP0C-jbn=s@l(BYV2M(nD^iRv3;2L!0Oafn zHuoNl^Vcsjq!*4VgEjTc=$q;oM%fobD=l^lr()@~FEQoQZH58=g~Q+j6D)=894Ql1 z%avf?cJ`v}L9{sej(T5zfX5{1ghxqpOrQYTj`WnAC_wM*h<<_+(ebSN8L40L8Imv} z(H~UDi8h=G*5Bd&bWXF9i!2SOE_(544?w@`I)T8Q5G<~1V2)!O-`W7Ni)^)24nr@G$1XW5MJT4@hKyngv zp--p#*Z^63tRzU9A9>GrxL{)_aRe-uN;Qc%DHF83z{pI2+Ej~4*D#a&C7K7_LHuMO zBxazliq^fiD#y<7-c}Fg+SftHG#=r#Jmn2n?mh~9c}Mnt{W1p$9z=uh_M*dou*0N{5UQv5e@ z$eoX8@6FAgl>kY2*1;1Bq5yos&uXWO`U_u#e0yVkpxv-Zf(!QGzcrwChGw89j)Cu*F^wE9){q=i>kFChsR-!1^lFl zC99D_Cav=Ps4;4QBT>7F^!L&cpyV#J;pQv%;^t-VlMACj{Gx#BoaImwbSoiEzMz0j zL;MgwLXW~oa-h#=C|g|pKhrr~i}Ovt$NL{fkvLdK-TDC6{{Ykg5YuV$Y)|&q?v(I= zWp51_4#051K)}X0+z!Kbw+$;7Ls=27d0w>5yERMB;r8#b0TQedvA?5jFrhH7K!+Wk z%lxD=a*uOkQLGYuBOGhO5%;wW$_gjon0-V}4=lkoR?jSXb{*_?pSuL&d2he720L(} z!4{RX<>@-jD4=YNPgnpfSP%At6dCD=X=iCh!dzUXOl3{61~VYrey3j}lKd4cW$@qe z+^zQJU!~}PpxQu%4-MWei6lx708n^)oeFXi?@3dE*P~ai!rX^%<6av0OmII8t{szt zr{H(^rhG|m=X#G>-1M_+Wj$c$5+Wtf3srH-f2mE@_cw7uL9 zuXB5%^dhllGf|EOw4e{9#uHrv3fTjU-Me@Ya^$1(Vj+V}v6+nGYll+_t;EtFuSCDz zuYLYnx?GWo*_Nl!Ah#D3&Fmd=3o`a)QTJKLJdS+d&l7ybqH@FeI7(51u~yJW>EXlx z@^;8`X2Y@$8EfeMe9;xeIzKbPj`}k=gW-J{fxHYf1_Oe|XzkIo`m&H2Bo?k-+@XZ+ z3%`G(%>QBUtpehTvTaca1W9m$y9EgD?hsr;5`q>GLIJ^D3wMX$?ykYz-Q6KTaCa%> zZn{tRJ^k*vfA8~t)u!0BbS|57jIqQ$QOvpVeOL3V_17aij<}{`sY}um5(X0JvujES zy_FcB!)I;bosIev#3pkUX?b#F^o6p+Nd*X1LwHKOIk5h<5r;w6e)TfuF2=k@zYGuQ zS`$iwx3DhUd0%C9YrDsVy*SRJKfofnC^Jaxb-b$90lD0DVu^M8b-^M*@c#|{eR&|K z#{^h2QEN>MP2 zAjLrN;M0V|gtN`R=YfB$z46|F6D!`^=2e*(?59_M*yvx%EGbH^ReN~81n9&TAENFGpmkuuP)&yxsov7-#K_FYTYo%_hi4E*x4iR|1;(&9tR7w)US%&A)64 z`cE+ads49YTclhrp)pL~n6q3S?}u0>E5sl}1s;;f1d0DU0s*uO7BaA6VGV?xG9bK` zbpPS>4;mCRsTbf8ky&dUlLtV=-XQq2K4IYC;Ezu9p^c6L6YhU^8PE>YUtbt#=K=wS zC{n@i>|<{Xh(T)iMCl9W=gTv3a<) z)@X7lEzxWcf4bd_V92N~dG+7Y7cBu`Pjs_dFbDrWwEufM1Uz6|ywKC`-iyNgN1OcX zl}aTbBCl=y)jGRD6P^WPu-U#0)gHvNA+{U7%Aj|TdG=KUYTdS_rM z^

5FC+T@hw;sjgRE^o^HsILN`IloHa>;_st-rCxW70GAg%|6goFrEe14e%>0ccl z$Zk5&Y(ZmUhDb+dqX5j}Kom!QPAf-^+s%sOZ=*2_VWVv11n2*vWQkHzVpP4#;mEhsehYFqS!LQ8O}z+>S4Z2}GJo>+i{>)rzJr#x)r{ zb*nbZtA+#&tFBwXS-#GF?D4_&?qW>j$ky)-PV}(G zZ@SDyle_P$+E6E6fmnubQOTAK8fc_~bO&>#Z)uS?U)l?4maNO~Lh^w71-X8McZsK^dn|V4*)1GolXYY2UXdI!}V!l%T+hI-Cte7{08@S zYr?**vo%_d4FD$f$V{uqG1PD93=sUD!X%Uo3sxzjS4kwPA zq>-QR>srvL{!vI1lPtK$;e5Pc)G6D^`?3akU-eo25iK?tswJ@js3%$z$=oK?#cE|J zwDgCV@D zdk=Pa0z5irfLmM|?R=|*^xkYsK#CAF{l{nmMW#B7+ ziNGrL=AzY|UBCHqe}U@Lo1hmalSY+A%d(S!TroGvQr$0&D)1tg56oT|rsZNAnQKX` znR?sm6NKha^sVvzWund02#@UZPI~-H9I_b4Oi!U-9!B@4dH1p`JbV(T`FkOk%W65* zY8i?&Uvy=M4R6Z7{xAakDWw`sph>#YWjN@~ z(8c5Z;5Xe;)e{1qLBMuNuY3H_Jaj&&70>~sg4*ij7OZm(mozz542-LT8*jnY#+nD` z`|7`gi9&xI{B7Idum})=AdMci_lnBcg``S%0*iR9*@EHVvC z9oWS4b{s{|SCsay$|<}QYi7%OYr6pLliAs3C+fhmzC+kQH7MPkQmD~UK1MtXI7F)d zY#`#XK*4zWQuIaRW5nHfnf!|{eI#k-`h>x7XVJ7A2WiOt>CQ+x)*ChS9R{DunKah> z@%@RuoaYd@62c!t(R;s<1Gz0%nCn(uiktrtv^&j!$?CW_5dBMz$hu7;;x70pyiY8c z#5TX;@CFc*Q2ug-R##5l`&;_$4?0-P_d0QmIu<$>ry22ED~_w-bGd6|d~!u<#bv9N z+;XV_Kb^P5U?-dzWyM5&xBN-?9aK*=L}W8x-N79j@!klKM_B_EhWki4k9sAj3@;UW zN2zDB|L{755zT$bBl~I<=c2*@3D_|2gIToeMM*uXV>fGcAy+f1vI_~$7o~Nty%9+W zxe8c{zj>5;ybm%jK?;1Z_*mf;^@>vDr(oJNk}q_^wekRG-;Z70qj}!Y;E5XMRZwoZ z_GlBPoamPdJ+VYK)9|m|zRfqjRIC}EyKeF_Zvek-068_k%Vl892kX_^?1k{IhBdI; zjqqzg$T0rKv0&$Ji;1Kh4cMzzl4zpHe3+2US(p1nx zVRSw(Hatmd8SKAezPp;!9Wf4neDP5U-7FiBK;;P%W;^qjESeP0w}x0WeuHDlJOnLF zUf+#$b{6OY*uX=#o8u)0=?LPe%RLSmT&}n3l{SVZtId88QVyu;Tnf+k7A1mj*XJ$> znd(tlQd_HbH*#{hu@}JXFi@t`#Q4=#AREaciAvbt;Z=6h%TaSFc6&uxjtE`TEC4;gQk@+Dp1evXb6kT&JX}sGpC5U_ z&D!`{wM@`(Ld0*W|E^{*{T;MV|LTaZv{rh201yMIYLB8sJYZ!3zTee^R;@PQ0coxC zhP9Rt62gIU@fOT+;;jyi#?mAv^As7j6ZQg6pUKqs{cHVhMd+AtFH^e$(4~9M zV0w-5%@p%{Wf*q^vd;a$;EG&`;|CTM=P%9}aZFl2Vx9dAxFQD*yK9Rg2c{j{ ztk$kBS+>x#;)m+h2I$QJGwW~fvooPwu^d$Gx}VNZLVwpdp7{^`fhYs8S$LRt8sVDL z+!UiunK>vx96$QW0mpJ>(b3KE zyHMm&FDan?V-3fC8n?!RRCey$ycqk=WmCK6ZfPO> z=Db>=5id;HUe|fCE?Yc#-IjaqbB;FD@wSPviL_vbJ}!-~&wK#GerVx!Ge;ww8udGR z)oFgWKkZME2vb)VTf-hkKemE#jt@KUNsvVz!w}Jb;g4bV7UVtxY5`T(HwBh-ZkFVd zn=M0wyU))(`N|6??`S>Z!-z)97rlHkZMFu7bmXYq0Q#MeJ4^E1&QlL9MwzF~WR5IR zKpWBLWhLv1;3$(J%(h94pZzm7`ZzbM}P1RvAY3nl(Qc z=TZbL(~MFM7pfMl(_D}F1eESCZx;K21yi3)x`B}Er*ZmF8dLG+@2S1auB%)1d<-SqSamrltzWWe>EU5IH<`$iTF&eW zfSdH8Ii6Cax4cwL=a3Gs-{Szo=SUHNM^kX(S#&{b)nJn5Ys;3c?RwgDGa_KzT(>`b zO-|=BbRqn(b*3+ox3Je-ZHGp9dQb`2yN}Tu zN9T`Mnpnyljx@y%j#|m4@|Ds57;S45=yiBLU@bDQ7}9;tvm0j*CE{6S_GuCkK9?D4 zul!sSq%86{UFYGuUx$zWu=o^=!+0A?z{b*Ex@dEI@|CR zjKu4$`1!TiC2uMek5Sz?jHA5yT#mS`c*v8fw~|!I#VN1v>an*!7-vx6obBRrkwvHI zM?2T4S#QqvsPdr`D$OQjhGD63wfxqNM$A4M@{W`O933`PyzbLaaM@zm=-?@I=g(YH7`Z zntudQDN)}n=`df^L3PlsI-s85+`6T@lR(?HgT%XC$n@61OflSKwugS`g)7h8rf!8} z*ARq7r4JbQoD40fTxbbnwyEza#-viEE6lwf?lybFALlP1);DQF*2*F)3lpUFAh0Vy ztM@qDur{45duxooOSJGCZ2i~k41N=Bk+>HbhzjCbv-y)-R9MQDSM%bMxw|xvLpoc` z=gLxbz!!-=C*_B$w-LccvkWS=9(i8XRN^ zXOanQkR-d~ck|b*aGVCSW~Q^KXBjh|waHY{XblunZ-{?`wOO;9zV|o_V9$22J`+9N zBRSWDgc!I7{RvtO)<{0z?Zg0Nq$z$hxK8w92k+|*w1mXOywG5yZ(Cxbq)WAyzhVZ* z8@I&2joi}HP3qPd4hPmth)>bRD7&2~zUg_|Ji}&WLYYPerOtjR#jajk9z0G&TWPYd zF-zN9>!>~b@VVYf-wPoaZM^dy5SuGWH(wH5-z`QqkH06}#;qDR#zq43ITUJW`T~dK zeos8878LB!T`3GWXW6;39aT(f#HwI8E*l{pe9v!zDX$i`%BSO=358qf_U~uYk8kiE zq!y@`D)d`=Zb)RRFb0VFJq>9I*u#$=Lw z_88M|5jp}|uZzNeniCZo4KeH!WWzPv952<4O;UPqrxv*TbI&)MV4LE8?dwwGSiKzK ziR;}oh!D|tX7+whzn{!(&d(sUv2TQSJ@SX=qxgYIR*_bHrcKYAIPS7=51-%Yw|C7f z#G6Fbl6t&&ga-G{Vb>QwmbeEQrZpH$s3AS&hm9!6FT0c7;?1MzQjNt@ejhw-~&vW?4z#JDnrsq<-hc8(gQvcgKsJY z;U6*VQ+XWA?Dik3%kemj2RrafE!K9MUVyI9nD-xgRp9a8dDI_Eq;Z@jT>vaorqXS_ zlw>Yb%7a+{27zfvecBA@Z24!#s+4`;U-tt}kk*Bc#UIb7$wqrEQ^tU~>Qp&b_7E50 zgljQf0(SL`^=+KlVWc*_(&NNKW z@q8C;T$S1+3vF(N>A1o0?LJ3%pCJc1rIQpsn`B7zcd}Bi`%pHB)}Pzl<|V zJNUUE(+%YnBs3aI8gZxXt!i#1j30KK_1bqWks6XN5iWe+w`G*Ob&T^8b~|Zxd|Dj; zlUAN46yH+_R#ECv36h>5h#LECzw(@#qX`FWA2_}l&fcgTnH zd|C;e)uN#-$~tZR93una6OxH#SqdKsmeMeJB9@|zv0@<;b#?+yufnM>GBz=X#>I{Y zLB^T>PYnIpi`@wWYmp}>O0*>YN9au*_<(gvA7pU)rz4#G)&wIKaw=BLI26a?m=l2e zZZNP-fq-M)xyEvB6%$=kjSO=6NqemovbpT6r`Drj%6_&K;Hx{=#c-5VV|sKExRWaj zo5vflAbP>S&FJN-Ho(NkGv&u2|D%`Z=E^K39sv{lp{Dm8m~=3n!DtN%APBql5tOU& z*_{Hjnd?9Hq7ZCcLIMy7!O+VZwk0TVB45}WieWZPmmnUN6!k&8Gq}#SNaeS7TuvJd z_R&Ba&eB78x5Hz(fE9l~DnPt&cQxz~!vJP?*OhWmE5! zpef&vDXp1g`F2?NzS$c_5(SGAi;JdjfdGu~ot+L`zJH6L_gy^+>TP<`7Ksr!6u z*=S#hK(F)VR5Xp8Iz+9n1J|&3KBW0HpptO$7q8=mBK;<7tpo!Sp`c5OwJC;v%ETfU z5w}^W>mM)n@bh={+WoT@-M1DXGH4PNC#i~BOo!R80v7ODDSJ_(v~B35^J^k1d*X7Y z<7CojKt2aor4~?^Spxg)P*hji3D@HR2ei7eIp<;+ zyRnt$7fp2`k2MhO^uFu6opcdhP^@%(etIn@gB39Zc??6F>`KgoUah1c6)}z0Ww;aS zmzdvxend7xrONcPS}fIJYw)Xeg4f*DPzrr^DWC6+h|b50#reLH=kU~~l8b{m%L-1j zD_fEikqE%7Q)WZ^n!rU%7N`Xw+bE@AmeGC*Q{=3aWA;BhoIYgcGlSof8B(A}vE{D+agFsNdN9KC@NxG?7U_{4S*S+D7+p{W! ztZn3B_}bSYB9_qUdXLvwxxDSH_wKStsO5gY_?oY~L%`>_Nqr@As-bSYSxnHFW3Tln zeDg*i^WhEN2jln&<)KNp?e3cv22IGD6KuWLrGJ~9ZJ_K|n?0NHAB*V}lP(zgyiL&8 zwTGG6ICQhdGoZ!j@GHD$lbO2Ttu3YtK3(&XAa=el_N>r_t|o*7_k+oyhvAT__R zdfT;FZDNqx1({><;wP!qJ6naGXblORQW&QlOyx(!;n+XG50q)7xEE}OZ9xvxTknnx%UTMgyM_&PO|GFGLI~2Azr=hHoa2Dqjo|6|QR~No zR_|MP!-$W-DWjv)FXYvJ;6XB^-R;C`j;6J3#F41nXxb1Hqp;RLcN=8i$w6k0d7^!s zd+jv3PWtq|%rGNg4c)d!z>#Y#i1%0)wrO%KXWoLo z?!3pP;mf=GxpC!Nq3VJ)*@z`vcSoH+)Ft7ICkEyYg|>bAoIL-TpiO$0WplAfN0IBR zHHKDaM#2Y59E_fS4uh1(Rdhw}3)8sb^x+(KW@;WYte%!2J`OTSkLAf6j*y^$YAY+jaa9#8*{!DFa zc+DV?7CkGGWtN6sQ(oiS8%nS#ldo$=Zs)X{xj4&(F*l6P0)M|~(GhTmQ4b)-Khy0R zt-)P&`Y12Cc#HOw0LLAjqegL2~AgmP%uFm~6{yeXwT*69l2tiK5Q%i~LIT!*20uwYhxiIG7``m~Jw z3cRwi8Iu_Q)Zd9%8tKd zk)r~%f1~o*6fuGG{pVxT+sD~j!}0KCYw@dCy?>gd7%v27)4vG~`7ssHDltBBzhXbi z`lHhxib(Nck)CsFc=H%yj7vefx~1Sn#~ExV;t9?dAURCmy}(#)8bgGd=N;vlq1^RI zIajyOF_DXljL+R*yB)Y))gzB^h6dd8);5ax4=o63m~3y=mG_n1wth=EvB9WqI?w~* zE&Ba=r60$anMF%%jYc>ECbT6xdB1283ibIK;b8%YbT;56UG%tr^mR#--fS>l{dIe| z$(NH3Jr)Mn(y}PBL4Z@lnNFD4h<8?0gU7?7yDvT%iqez^V9By7Q0vCA>iLZW8k z03FLPH3F&+Kz;8}nVrPuob{l+L{x{rB_%|E%)a&y7;#C0Zig@p>c1%(c5;W_=e|Sd zy6CVZNdJ7dP6dZ@_A21v6HjWqT30t52^kj965A5(kL!kU3qo{vE$|7>(4&KRrklPo z=t)HNV0b*PBj1X%2}KyxZ{(rhR}@Z@;aKCdBj^@Y5q3pMe1)hq=;EkD0xb@7=kJPA zRX$Fwo{0i`V3Y8n4SaWBlpC_XUA!s90dc$QzFEycyQhISV+hJ)U>tBPxM5KUKIFi$q4^LZ-BpHXKW>!c7gv)t`tMg+XdH`gai` zq&tD8vm}JE?svvVc=)MTAwHXVanb-D5Gl2yTG6!HYo^QofWIu4K!O~%BeR6 z=HfN{z3_aGC2X9Z=N-yjVtrSO-jKNAL`GDq(w?BoeiEK9x?*l^zooOu5|7AJgki!! zjwD$3S|TCVC)gnfm}vp-SDllf7#xoXk?RDiwBzJ@9``-#<&@Gs>I{N!B2~@AKSb$# z4!+Cgr~5}G#y6CQ@l&;ERT%bm3HRc5+rAAvE$Os=XrS#|b~dKK-)LN;wNFNiuhN=Y zf-se@X{GJV@*y^B0Dqhn@TnIK4M$^q<73x99E!fl$3AJNP zrp8|N5txk)352%2JHFsI_zQqJ670QT8^MaBM5ElBdSnPVJ1puy$Vl(^8Q8yf6J4LE z2v|i48ZO!~BymTg#{XI~Tm4B8UaV8)zxC7MI%SL|-DdNCxk~59d5)}_8^kP*y>Yfu z!nH3~LBftTgU^2OHC#cSb#3$wUKPsJUUw!6=Drh1%ly>(JZJ0+-&hme$7W^WM+Lc9 zW|BV$vkVcWLaqa!k4EQcwBi)d;wK(;iS-(o=l1j>&xy9gNfG$l9&B38mKwjN|E`k? z#*HnU8YRl@605(yerpnAZA%i2%SChGu@DmRiYQA_Dq2T z1OS!I;-|JBEpNVPc|{$|h>K52hbX%pqwWW}3x2Kfs5U2Y|7!7Vu2hQwa22b4loP>Y zaV+Mys4!Zc2@U5Y{3v;})a0QbJB_QBGq-EJ= zO5#1CfuD|wV?*C{__g?JowmKv=iY(!R|Jq@6p#|8_!Umh4TCG^{U_cd7{hVM9|1#! z8ebO&DGn!sLhcKeg2fY*nQ%-!mBk^}YJxS(XUNtz=?&5tGrs@L8~2itdaLJVlOgT{ zm5Ae&zf;QZ)uG_EZZEwtLHwrKqwHTU0SR{=aAh)xREJ}287O-Uo3k|XtCmdxAD>M9)}VErHh@on_U1 zN)pOj)j=AUyqe)Xb(ndI4y_eYB+t?>=#prDN3G@+ z;rS|bCrlxpSwsBPLf`*Wwn!v^x79qsGL(z0PzE$F#Nx^y2JcsjjnJ0{d`T$!m8x$O z*9@6rF`ofU2ZA=&q)|Au<>32up=am0Xcjr3!u*^bJ27eGS`22@H$!m%Ef?KHR)}%U zgdfBQj9J@m&rbuKOohEvS6vqAPJ2bD2=WZM&D?8y2VJdae{=k*)u^(%o|+6MAHmCL z_<5U7OZ{L5(ZlyLtvcjkk801K%IH`CVG)j6CDK8k|FQ=}l8CEcy(%m2B^mzVQu8`>)sje#fimf6vK)n% z^k?tHbefgvW#SzWkeS}tdZl=g> z#xj}?ickYxOe~GVUf0Ft3ZOG~O>FscGpAu6^KM%%>oR_tG32Q;_D~-zU2?7F3bboQ zCRpn-K`baghD$KyJ|GKw1Q=59N>cTZqguz5>9S{%ycT$_?yPFZ?DoFzcr{m;eQEl6 z0%m+&tkT*H(P0tLY9w~oDisU2?qdlTMspRQYtr(eixwQFF(0p(a5f-P^5Eo?Fl|>L z8+GU83S3V5eteL8lAV`qJHn9Skg?gmotoIT#_@57Wc0CF{f07W45?}q1no%M0k6sT z+fE!bZnL@hh-FKyG1!JwVveyrmZ0Z}{e9wQE&tT!4iWHRK_$3YnRvex_(`#lH&AqZjAfMM`mEJmf>bGqQ<2=Za($1rf}QS zeR2N#2ol*NvxQm-i-s-S1B~U%D^#}Pk|}2G>T!oPi=Pxy+}4>Knsx5YAf06IzZ$;_kpv&m$2 zA_}PuD4Ajm!ncLKHHnuQY11bR>18w}J5Dtk<}FtQlOS!j`R29ZyLGnkfM$H;k2iWw z;(;Kw{M{QIcnecZG!j9&Scb0^T&OLRz*%-OxE}v#QU~E-)?i?3lAL`A{-#bU1gGj= zu~0QP=k^f~`~)tlos+HUu`c9M|?lkf>laIu;xvVdU)E7tx*zCp2UHO;wn> z;(DXS43UVPNNKS%#Ykj6K64XqSg$37TmxF6vlpm~l7L`VHS@~-Ss&wwq)xb1U$5^^ zjFklTT%bMXY%Gs=Y&7GMwTwOgBoTaFz|1(~_gEDiGH2%Osdngj!Kwx9PsfrBGPwd_ zm(u$CHWkS0Gv@7EQVW3$gs4^nN_RrZK-eSUOZ#fSc!adMtmeo`S9FfQ7V$GDEh2Iz zSYI5CpIguOO(zSivAuA=(dTOT?r2eGO7tlKv;jwkfyaFvgfW@6jP%m+_dHyIi;lZ= zD~&f5(PGJ1hBt2kG2|FFvhW(E&Br&IzxZ!!OdzCMzb>|B$pR;={La4KGYJ}3(V|hk*WVYzxrseYSyqCY?}??2;KJp z_`e@BMPCPFp=k2SJC}P?YUecvJcB^QfQBrTl!oQnqhpkPzRn961HO~wa%qAMB^q_0 zZgvX) zBMse1SAL%j4i5ce!jV*JWSvq3!4DxSE8Vh`S}n)VwsL(v+-xQoCO(N45(!}Et5$AJ z6zI2m^#Z14cj1d+j_XH2$xdB>35;Nue0lMN`Qce_C(}OD+)hlsU->QE>VO^Jum(<5 zsXu$|-q&!zhUeY9SBT2*6Z$f6V3Xe_5uuPJ?MNI2Op-UwAF0N(%!g8KNEQiUlA%)S z;NIXKn~zi~H5EGcUEG(3B}1Jvrc5$svY~-X{vYe@=eXB1QnN3v7CWhuEm$m2DMMp< z5m|(dwLp98Wm@^tATV(ofgK0hHFl90ynrGdT=n~m()H1`Me>dR}2ZGZ_XD@XXh-}EFa_foc7 zvqBc~I}LlB@X`X}y`}02CD{(Ph36Gnb$*D?M`<*GuY=Lo(X@?SoLd2o8Xn>mj+17od`68RJlT8?H#3f)6 z{c3`!)PbOgI1@zYb0y7T`Dji2A_S78=S@~WIAe~_;h@MU=ATF6vI? zx(rSsILjn{kApk`Gcv^%4J(E`S}MyEJ}7|PI~d9P4N_o%_d{MIborIcJs9rSZa zded-(LfQJ;5U+t}YJV~}#Lo{R=$ad*5U^p}GyJ6>B&<|}yUvjzyt7wPpS5B=43blx zAoZQt`3O-E;csxF$Y$p#Iiavy{c)d4LAV5MT`Qafx>t#`H-&E(;W*-@G%FrwsS1Q? zJi&1Y{i)(|@Djmbkcly-*nlLc!T|wumPe2!0YUE2B=do{M3QjcX|b)TRAbmGVX3}Z zc7)uU5`5t~z(~7lO+sY&T9JF_%W$^YE;YW6wLx0bM4Pcmh|DmigJ7yVV)7Uj8w>x| zkb6m4dr-J7KJkN9dd--l*EIq$nKBCs<;5U5fH*ch546q$J31iIB+gUf~i=P%8*ep%Ccmh61>*SD*Xae;^ zPx{WYGx*OPoD+nhV({-n3CM_@1wgOQcdC?HFmWdZ$oYxUw(wc?Jazf8X_cy5ZF$R; z<5CFNY;u&aT^tUr@BX5?AUj#Z3m&bX3XD@@7_%MMB3TV~9q-i6d%r_wVP|XLXG3F4 zY!}C@=oRm^7zBBI4pC+8O-Z0rN9xRB*;umwxXvtGSLe*K#wc=+HyF|SLoPc}KV?<_ z2w`?1QWzggNneiy*)gI4#Q`CTUgrl7)6iaK>H<~_WZ0KTMB8m+iF-FYavO%(Vwh~o zm-${~bA2nQN6_H7n>dIt0VcCX^J{*Riy+v2)8-wB(BDf(;T)twIEd?7Xca$_v@dCuGg;G z4<9uWHG-{zM#ww2lTDDhVL^EFyk=s~XGWu+z$A)QT}nhDdIqmeXs97Ki|9@$^=yH9 zLB%>v4&iG33W6!C*z$;&T9D1pj`_dq_09dc2=D+a+9oDV=1pk_ri?Aek~*u3*L(at z#Tm_`8jW^|5my$&Z$GmmUy9#oaOEdQGix)maz-KEm`?k(o&BQOz(BAIe;nBvYH`sp z;br?M-oq!1LdkMu9=GshQMRS>t?AwS0G_@2DQzEnp$#ABAG!e9Y#i}T9`WVUJX7s! zO`?wyT9V(x^@v{y`HbvDCE&qrjoH27A!1cJ)L%D}1JP3ZAUSiu;a;#DWWSZx6v4X9 zWMMtB`Xz{NKWeS%Nb@)0cU|#=k~TOJdzDfw=Y8ZYdmxN)7XoUDA2I@+QiYY_n{K%G zzIT#HYpi=qc-8NHkcQu{YU^!=PwD6M!KJ6Gn3-{dSrIUm^qkJRO*|{~VSiXe?oSt* zGtKi6CxE8%$11oF4kJu)w}ZkoNLfzuY`{5pMLq+zRwZMFuW@#ebBioYl0)7w5)WH> zAsJ*yc_#W#xn}g>DwuL$V|^peFvHAauapfiv5gBCUrE7dT4{U~`DFwIX<9dWg7;A0 zu?8eGHi)w24yY)ZRliqAzz9k1M4!(ROprN4&1c&Fei=kd2wmU$@@*;tuW7fK?ycpa ziU^&>$8;`ea;li`DVf`;7c*Icfx54R9sU*^2MWiTZG|+)9J{dM`R+y44~A z+u;U4?FdXaC9}lG|X9?oR+}RFG?h;|1Y|-8V{Q9TnUJq3hQ}dWP+ebSF|I z6z5@2u;AJOUUW{~GX$a}KFULQsFmkWROh}tx|{MbIZ3?Jd0 zq6!tui4g2CgO!ND8B7;+9z1KTm}{i6WWA0;yTOyO+|YcF-`NiT5^k)%5+sA_M6@}!uSM_f^bizSe>wYG-OG6s z>T2`QB1FYkMn_(*n2P*i`_oO%et?nAaNK+*E+QwxaiannGC<%&zwpvY;>Va>KK8X~SZnj;)Q-|~FK(W6KMWSuL&!cgW6?IL~Ya0UM)w0!ktKFGo zWdc6j5?Mq6=KZz+zU)4z)l?a~UVoU97$phjn>Iz4+Zz*vYFZ9$Yx5}+4w4U}XZnEB zmZj^;@Y=R!_?f8d86m7a!bQkjuO18^3Gw$){pR)`eT1QNP3MGYCi9BPO(Se{ zrq$I_^|ro2O-rs7cf@C%sdy{r{vKERGj{3HZGCQirpT;b`hPx_=g!pkI!Pt?f-!o! zyLa6#K}T(FaP$zgFFa4U%O6sG9YW-q7Ru9f-Ec_z#RIx@~b_DOj$$%&(1R3AfB|1Ra)(k2NS z78#DNqJ%io!Tawye*W~Gm1ZW;D5<)~(m^L}Px4~B_XDcGE}%AT(v4(T3rcjbw?#xd zoC903fSPx4sS7;(Q3#A5dq>pj?*|`_xD65Jj&T_^6<)u2H@)ih4i`aH5>JMrAemC! zl`q8|@$;>E<#(DSLHiFYR$XEI{4wN!OntiDP-&g!Kp2sL|0+J``27e=T@h=#+jWDQ z-HmG7$ipr`vKizaPyM2;c+{n|eE)Ts2$Fl~9NLCul)gSV-v@M1& z6R3GZ0Dp3p{kcyjkswATy$$;f(!ai$J#qgm$qezdPQY8^&&% z!j`j|=D#2!a#A#81WV&vhCPWEaX=f7(rMr;_0OyiG&UFnkn*dk3`)H#q zG|LwnPpgnOn!=R~_=a}y015owYImIy0yQ_JeBFDR!*jU1G1VA zIM@~=VH+=V8_x%3sN>3+u4l}jHxmlnz-~S-7k;KKGzdV`tYm{aLmWIR5$%tzQ*eln zu0&$zt_B5#h|fdz#?#`Mb-w$pS^$vh)dxFouGFyD*r(HyvrGhez=C+4z& zM}_Q|Kb7=u!g1=*507OI!*)GhjICV)7wTXbm*>gKvALhDvP=;TRA|4`^Z38HJc z+*e_lj?l>;9aAX>v1mb1s?X9ro-2E2W|_2hcPq1jIn6+tW(L$NCqC&|{^;4~`705M z#>O_-FQ$u%9E$VqL*!UhCafg;9K2F5o5Jh%^@N5t>BKnQTDR97-GTz+4#RH~(AT~7 z!&vjqe@qPvwN`Q{4tAl}Ke%kYS#h4zA@+4Ro)=@%5+oR37DSP58#hM<9%czxSvgE+70|NMy|(e;1LJCujKWWgD~2NUF8HM1 z8iD?DrlhGKgb@fRB`3_S%`{(St6rkPi3vJXgqMBF&QrrpRgo)dFf06=pU1T1U`&N| z>ht|!dnD<6$4R8C2hfCRoU$Ej`xF1%kJ)81KkitqTVNYL)%X?}SOU)(njd6rfJ>Tc zHh2T5#JW?sWw7`XS9-VzWZtd6!3*F@&x6Oz@jpck=D)kg;N7_GLZ(z|)6ncvph|$p z?A*A#r7>*8p`}lGpQaQoz$@$T8%cn6Qt(#ktJS^fHrbk^8m?(lU0wc6xvoetF)vf4 z$IjYz;b%@64C@9f67O=C1Sjzvg5bQ=#b28nF>kYqck#g5DOlo@)RFE~IbKsf9X^Pr zvS=K_Ilrx_J>CsUa#3>uu3U@QL>k^h=Rebgb4#}$2=cwo z*ELbfl;<}S+PcEX(1h2kQI$F=utv5A8dhniYnorb#rxS402Y#P4xq-8e7K_~Fu75k zLpwC_onkpM;d|`8DkBka)P%s$R=1<*Q&~sj?AqZui^`#4MY|RC<8Lm`#n68ij~o3x zhlvG~F_6M6Lb0`($3Yj!l_-sCRv+T%7}R`G@uB&)H^E{RqX33c}j$?gduz~63Iy44YwA-)H11Tva_!s22?2lD{}%)qs!e1esmt$NtAF%5c+9hbLEw>R6I==rIky|I*jRIv@4Do$a6wtY1~9 zU6@nu$EJN6*WGe>4Bgx`k)smxo_oL@71-9E&F|+G9VCX}UPjR%i?*$td--B-OUFfS z+L2cZn$5GycXrad7x4B;dn4T;m|Eulu1s%;>$>gyDk`%nffaoaywfQa;uiZs&8{bC zs>=obqEtxOcmue?3ex_oD@mEi#@}dk*oI+ z214p`d79eiCjOyt`*&qG*&uA8^ELVn0h)UTnfk$*3ydMuW|X+tEOV4k2}@GD&6oId zN`T-mQ1ot6EV&%rEU}UZ`v=+JX%kg!g@DcO*?itri&17V-_KkQyg>tLLWrq+7uYX1 z&!v8N>`L!+$x}>LOO$PsPKNWp^iKBS>r+T%pFFC|&m&WQmZ6P7))zjr4WW7(Mfy`s z58TPM7?NCUzlTg{ZHyxxus@<@Ao;$Rd1ED*D3CafUXWGMNk%5T4I^?0b z!bT35iU98Ej{JU{8gYep@?&D!1`M zw2z^YSL3%n_szr0`;jqsFW}czs0CbG_HN|%TOy~(v7ArmQM{6KvODh;A_-sVE+X|j z*3t?<%8#KkQfgF=g?%KYjfk=_7D=cZiDARmX}PB*`Ifh-OwXU1fOdpp+vl{HYc_0? z5wSC-D=KTfTx!LeF+^YtV&D7-73kIV@_vnT#9MyQ3yx@sTRKojjsb~BP}Mb4Ap8Bg zw90W_nN&i02DnpU>~sUQL-EXo_F>RPs^w;{x5fx$_Y<@Mh`2D0YhM06iC5)W31CN(`dV9ZB~cE@x8{j1#r6MlzFLnu1k_^I~NYCE;J-}H;frD;}Gj1vqoyPAR98Szf%(9%y9=2V3$}i13GjzOTD6HJ? zjJe708crM|q~&0~@pQT_foUq?sss0UoKFqARBfI=S4b0r7q;6_Xp5+nIE2lM2mqA?RgB}kf zj6MMe2x$$b9A0nHb5U|YfALF|8WZGo^UUW`FB?2~i;p zUiayt?8>L7+A+qDMcqk(p5ITKfq@L(3tl`U|99ZrT_UsE{&)m@vAckSq}okntXDJA zD@;+*?LZ{YT?%A%G)A@NdK{ak-}TF7=BA~6Gv_gQV&r}lKxWhyVU*q3oYkvlA+6^giT~ z+L)d}y8x$+A-5{t7`W4x(_}-E-|^VYrZhYTN+=GL#Mj)6F3jCySiSU=ba153VnyK> zts>Syr>~!mYQ(FT>c0*nmjX=f#RM?my(v6LL~|HdUJuE)zJ2wN=AdSQ{}C<&I-<$N zfzQH$)yI%B9g=DIulN6l4;kv04wey>=l;jfk^Y&=e||_Zyy_$mo{fvLLH?iq{g0Oc zGeYvAi}H*!`yVp-uL1|Ez0{zTiE0b$Up4qYUM7#iz(Dv!3CpzbpJn*(e_@m&K@_?C zU|=ZQWhKSF0QqWaeH^3#M%Vvb2T{tGPLzD5`5W{9p06e*kZ*NuXpH=ejsLTK|34pI zlH$NX41%xzLG^#ntC|wX%h;TNJ{~a6|4WI2d_7qMv5jMz$vGM#}sDXO%vM1Ha5__R7k2IuhtsnX?$1*d9qPFbKH51lZTo z@)O>1+1hvKm)8Okd%%)2y|;(Y?{KI-&P&hKKEku65HV9)TwLQk_5L2v5WsE;oRYL<&Jia(e@k1cfj$A`c4wJt=e?eRJ}whS0E^sumX z*4T>nuDh85drx*ZQ_A@sSzM-S($&TTnNLwgR=}}r&!4C)-}2@#6JWA_{GcH#{m(`v zmwV~m7*0t}JUH5;rfWB8;L@;(9=G5Bi@mS@ifdWICAb7hAR#y*xCD0w2@u@f-Q9K2 zpb5d_T`(=L5HW+lD%j+^c~4KRdGTCVh$8^Ohu2N)9``4fT<98+aX$1#6q2n!W4 z-(p0gya4bbrhpv!BK)>4!$18mg)W-F*6&Q*w0B4u<%;DqYo`-|EloxKlgmD@#GYO7 zKAhCMnF0(QBeY&Gw-WoDM0`M2f=Bfh(UE zG_uZDnjPj$YfghC98SLw2|1m`G7dh~wM4~h3VTpEB9|?Q5oe(G9>I&H}&i0F>2Z?z>cMJPvl5N6u8Z70@)GJ&+-Bq@oOh3;9ZZp@; ze4MXVTkdpPq2v6JhpUe>G2AGFCGtG!D?AY)o3Ya?Z=YK#tEyIQvC%Oce+0VM`giwa z{_0+H@neBy2mr9+h&>3DP))i>x$#NjC(7{E-&NqqY5yzf-zJ#fU(!kd=OO z#pC=-(^&x81ZHsficUGfd3#g}s88n36nw~6@AQD}D=jqkKOCaJBAwpwI9`_8o5(IE za%hi@#Ag-(psR~|!?1vIMU1V!LF#@}2`~nD+t3)nYbjD{FZ*3tETJ za|i(Q5m*-v9nIv&2IUMtN6Ww@n!f`Bd|;6qr@}%27Od=GG(Usn5JCA zET8?=rMh|m%x&;DP>!1mRH7@AQi;HvPX+l#qz=624%EtEcY(nu`?Evww-23n)5_gJ zNEiH$w*UsnMcYgl;8rR>d&LEC9RL_x`1|4X4mpj&guqC0RZEoV`X>hWflF7urTZJ` zp(`NX{`GDnulXDe{rjk@a&SwnH)VQc(UKt00e^{V~{ZBx&p| zW6?TBpiGs2&fDBEf0sh8OhtHPgrMSi>1g1~g%E zxC3;=u8{$+mq`*t_l8qteBO}&pdzU6nxe91-qh~xU9Lz4p~A4XiWPHvV0UC)Gl35% z9}d#F8Tz3BuB~J$o5g%R5(hH6iGPQ4f5n?;4`c3oyqFvm5??b^3oKu>0=yUXzfLd@ z7uv=-FQ4a}mS~>+N&xw|&n+n1ct^5jsnrjSIjwl+A`xGnGbeLqil=?YHQ|ywn6>s> z69-`IevhC(pqtTaR^w#yx_q1fr&M(`D?%cqpo&GK)N z`W0-!WA?oj)rNv4900^GA9*v=nJMni1u%90Nqo5UAmlDlBT7&`Bxwff?oc?te!qFR zxH5o^rE&7C4VI9C5UAwIPM$GF!52VOWcqE6;fX{q&Bt;`n z+~j}0;@D(Ar|eJ0c?*(>g#u!=pDM4Qb5=chvQi}kT53l!GBIq8wiqiA>@X;L>(C*J zNyJIP*h~qkz`62g8ZN*gbf&4Pt_(4H%wVnEWKVLjKb0w;#bX5aDH^GRDNpFnJKO>Eubm$ND4O|o`E+)lYZ(0o z&_Ow%HXt(iSft70lh!i$YKL zav`sJFNYHietk;2nuDj;Z4SS5n1`WBJeY`vVHSUk%2qt9Qk?2RVxm?i|Kce?QxJD> zwB#~*XELY-Mbf}Zuh#IsDk9-u#YV+x1mLzK^#x@-7(TW_6en^|+bgU#fS)k&L%+3e zq} zTp!R2Kvj^IK6&bSgTD)NCJR84x2FqbzJXV`maE(|`Mk4`wt8Fv@@DmVwt>X6R|ek3 z(*n9|F@ znSWwo51tGfEq@4#!fG*DG#;)=0#p46V7V_tD|GB*T zjOFhYYBb9eZil5a-@6uWFK0e`UtTLo9qOv#+9xq;)#JaB_J2GpW(Y>5DO|)cdVv^* zI|$r*OxrcBee^otZg?$*fC7u|rDPkqU1k99hv56{BhhoU8Y2Gu^q`)IwW>t*Y(|o` z__|Ndr_{e4$79(ww1=FHfIH*z_lx{*0G`S%&FhiDuO#^@LK%SJI_sGIi&A5# z^*-w%NB?VUjnyu0bT?;n^3P9`Qoer8tiO$qs7$`%aa6lspMFv?rqjfL)>IFhyT7qU zSf7%9hx1V&3pY~alOVe-aH&Ck`w?D_r<{^ulo!R%Zl%$^0dFbKOlEGtLEBA)--P(X zbQnp(1-E}KT?;DSn6ykVL z0ISPY-w?;BvhOVx774i&Djf!kiqZOz(mxg*wv@QA#uk_WDi8SBMfq}*x zalEJu>AN~{417!KSX>ikVu4<|UZUVITDrNHeozd{w=#V zdzN2o{ww$8dPpamnY4}(fc1Vv#BZPRIDW&V**tms_4R-wH(vMA(+%_H`I_*|GuBDS`?L%7jPTG9mef(NKe0{m%ptHe~`QqKj z{Zl}y!l6;UJfK+at#;=*`VBpg^-cUPpi)V_)4=yEgxTWjk>5r-x5LRDWR!r!{71Uz zedpV*m;Il$&TQt&3HJDVIIPY;y+S^=PXhe^>1*IrS=E(&yD4EX_?5MjF_rz0<$IxD zhj-@%5!e1H3KlVJXCirlS#Wx}+A04L%T^)lwtJyk1Yv{3N?B)fGFko!PbbWwQ_JQi zeFydGVRG|b>+`Cv$5xYDb26UdTghrc7Td*Y9!QQnfLStgLoDd5YSL?he2jh%z{-?& zovt=8jBBFKVy$+uB(W3$KqE{5y)n|Sgtu?x0k}I@CXbVHagEy{*9LHTw4IE63tC3= zD%b89=uEZZ???wf0Vook4qYpUWh%vuoK>dd z2~+sExF%C*zFk6g%RF9&UsyrJ-1hC*51G)Bc$y{$AZD+gneDYpa7Yw}2IDvk^FlUx z0b@}aSo9);Rj+SF-?20X(%l-|IPd)0j%BdG`n;R3N9z48h*|Dn#jW`{Wafs?<6s*B z^8+D}KoE|by)uBo^t1X7n|QLm*!#j--TOPjo3{YaO6OT1X+YB6kE}6tg!@0$br^NN zUJnrd&M;AhQFn=Szxy*=gT`{L@_W_|Cf)iU!(=XD9Z@~~>@UrP4dB0C*G+8d>Nh#5 zTy~>WH0uGdMJhsHCtNY@tj4qNL6f+yX(QZj2OI{F>B4effJ%Kgox>{b@oP<(qrrHp z33$)7mqf?@LgIF_F&E#P(4I8Kpi{r5r>6#hS`pM|&IOT{DEBu3LCubV&9gsvIXpaU zS?96@K2%l`RuT||1#>dP&Ivj0wS5ej2MK%-=jF%_23?ToWdz^ar;|4s)^lY#8i(+; z8qbTlo81wwy04u$9KRo`<3Q_QDZg>Eo^x$aLz3Y=eam#fqSL?Nb6G4GSG@aYYBq$Gn-QPkvxD z?=MGKFSVigjI(j?V(dk9(!VNTZ7vkKG2D9JI793F{N?l*EEjTvPaJNbyAd-ezr;|d z-r-TrNiKgyxE~d)7KMEGZnr1#)WKRH$_7}$nOJ8&NPVc#(0Dq+(qJVYh>pLjFqRuNa8(=AcnIUVajX9v@8BC`U!#n?PAqY^;5 znE;DidqxAZ`4lo<3=n@qZBDJWdF%Xt?cEA$)Uw1wuRr*NNaOjBgc#~#HFlW*l9p(To)uD5X`zom74=*8Bk6X41VRcJp%B>@GvYf7+r9Z?>ihv)bck_m5hspe!7 z3YGRTRtsIp1=S_=Vw-p+5r5GW-ZE1MC2}YdNx!ajDo#?L*P2)+Dt9r zv96IsiC6hSsAPb=P4Wv9cWa32<^&PU=_mBNW4 zV75rMXj6_(_O?EyPh_}-EN+HuPDnSYOi_d!DP1_I7$`mrlFbY^ksu7ru;T&A zJAwfWh`<)PY!hs_O?SJM-Y!oiQ<_NjZccKY-Y$Vj&Xv{!=ZVtdgW>l;@!8V{O&=ns zd&$4z4KBN53qx~N8VkyinAqk5!pS3|)-IS}J$bAJY$qmM4V@g%Bt7GP6q8pA6q^8q zi#U*by+?s)>Zchv`x)^3lRIB-I9Vn2LmpWBo5>#5-#nB!_lGee{r>$u6z6t$(hrC% zP7kZL3Cu+7jy?Ei>NYzocYZ)xE=W+}w#V}cNrY}b?ut!#3amg{r6hWda76Z2WkCAd zdDozBPXs8o#>c6q@4luQ1|%Z#M~F z>}RaJ;xznfQ)*b7M5{jA8Wd%daX1@@nt|IJIuh6$Owx8~31)Yv#%aRrLC!}qUo?)q z~8UrDZC5t{}N}|*vy@6>ke=zM!laVNUu{_M+3Kxpl!8?ABlz-BXbZ4ZsfUCU^J1nDOMsTi6fUPjHn zOKjFFyL2NPzAXw`>PxkDZI=g+JuWZ^O?FCluOD`Z$aVv^fNpMvncF~LX;@roK%O-B zpt4ri{W->~(d-5E-FYn3?++MX7N@B`>o`%|zz-4apC91hibj`oVp%M>J+l(heB)|N z?@1>ENNmTo`>9}s5a!E}%&f09k^j=tZ^ZQPA~W?p36j+V_4FM}v8mxLiSb6|v+!gT za%fDJ78p=pvW1}UE{vMk3d;>(xW*kVLo<`eok~Uj3J%}}@0%6wU%V?USQ^75yr*uk zoKp5jj!&u(!z2<^mKhXjlGdV{+u6-|XOpe`0h>f(GJ3CQbd_`?gJzfh!EVA`X%via z!FDQJBIRQ57B|ltZ$h_T$h9W4%B64lEDZFp6|MS_Dp-nvMS_3Lu&F0e)^sWI+KS?D-`*A`U;{I1+FkIzk?#HZ7;q2&s8a z;;C3*&-X^>`&)FFyvP9~-c{u^gWA=Zw>GxM`YNy9f@Tc$ivRBzQXqR(iEQ1(>+a?*(wgC;9ggU-)O5DURFma#ZWnpe$WwC27h6`_Y)T z6cb}{DH1g?qi@a5I~P`xeU8+AzpJAqIe-PSn&$K>z2Y3mqIGQ51tNoX_OE{}^Cl`Q z>l&GaEN5tvBP-?9&X=pD4}ATs_R`8=75YJ7u?W{J;LSOJ(h5^U)ikx4-$oOX!h2tQ z^C^X=GWOMbT}_7K@i@-Ug#xS3@uiSoR7(5adL6mJ(^iL2=x~RP!BN&bx(&Z~+ihgw zmz&{th&CnaT_Zw zY;@-~J8*TwJSm9dq08_64o`ajGlTLqywNsSR>!n&X325((v=hV4afQzXs$J?aUkTB zI{vaZweH)8kTBPJ;VtZEp3j3`AcZF2Tg)>r4ooC?9+wp4n$wlgHe1ZC4x_x%A3?P$ zl$QSRMK7muljR=vlI!L3yz5EagehAO{;@5-pJ<0RmLB|2=9iwKI>-sky2vl4uZ2n8 zsHvbxxJ$i%a}eN4uX~#8GZn`EXh5j!gM5Qxj+FA6Wn8gCdoCb1^@XKHkeIP;%T$zP zy%EbF|5sCoRNaNF5)$|VJG4DL82%8Tlir``-v6XS_>1{pH;6cEf0P;pSMKwYtX1AR z6x+9g6(7l==)o;1SoIM>--HSj45!f4tz_*p4b-uwL}Aq;Dia&b(A5AL&XMnH`l5f%mrzS;a4hI4Z*A|NG7 za}s2Q5%ER)u7`AB{*}b9;lfzz@!w5Ta$i@2XFnumuJpuoZKbXfh*7JXcLa3^skbE+ zZZJ%K>>v5atf_xb!WViA^O~EE|NdA#;3}y_x7uKX&uNXvAY}-ogs%DVgPT0MupW1R za@Q01d_CMrT|B#yhGS$_FHxPM&(mOxM+p8Vx8goq^>mn2kHa`1mmv^Rst%rwqh~7& zA*DBv*BS9XZqUp$sQ#eUGTr-{>;5GW@;^ID+;t({ojJOro*8`JdkS32=Q(W&O$ym8 zl#2McpU$5eFzFUlO*`>0E5z5lW^C6Iiid3BVFPSw38Cyv7U!!6c!OiuugReF5N-Fs z<WGct>g1Y&1}G_?6kCqS<4zqE(9SdfV6_}g7Imi>lcKmO4xAajIDGAs0#r!K&D z5v1K<`+|j@J^tu9h5S21(-n!kMdy5?Sj%3+=Xs7$RI?MdRH+{FS~qkc%i>Qo5MAVt zs;H?XsefBphiGgV!1^qc-civyJOn8NSmr+RG(Nk~r0X+^_Y>ZnK%3%IAzTGIybpgMW7{5GX z`##5T{uR3EQVY}b8~&`JOcB$Ac9Asz)YDvj;(}!QSr67 zUTZ;>pZ!vM>SU9A22bv*z7J`ZI|qwSt0IsA?Yzq7YJSh_Z|VTPaegx&VX4gM^{8*- z3`Ujad%eeoPG@cjx}D?F1ZeGN#E#!=?|Hj~6`?}}zNdV@U{_%ar;19csj)m$ z1~a+cH`j1j{tV#JLhSirC*adhC2v05dvwLu2``mUu@@k9) z`f|QJTaP&g)LAmo-_B-pU~RKn{Y>xXA`)bsv7xx!p&{j8jWNqb9Gb1)6WU9To}86( zOiBjm{@>4=o`2_i`J93nS;UBkQc)IdY|dt0`L$UAJo-Rx6TRx-)-Rt1b2unx%u2Nr zd*MWkNY$1s)m+9$o`1VDQ!|p=&mda5z3f#7d`4*f$2Ri=(!OZHKhWNu;r_`!AisI% zD908q8v>B4vKH@tDtx7>8_3R0xogaa6Kt8IoVBP-r}T+IyE&(DRK@4b697Of+SV1N zN4di7TV0FP@!qleJ#xsfJQg2!P7_VZ8?oIv)HWp2;&Wrx+PyZg>j+a54(!VHixt^m z55}(syM?UV%fqVpaLQoX4rOPIGGWI{8AAv3oi1Ar1X7 z@(!Rm=WrpO_G`vMr$(x5S=P&W-2F?{`{-ZWU4k_&g9@p+2W4G|k%eoK<0>7?;T{g= z%Cxgd3Q8y-qqp5T!gq~!y7AluG%;U7J~0wAqJGH6SjClQz5ZB8E})A4&F;v`3ylaX znD);Z%*XeQSnPN9RW>-NBujV<77T5`)i-TC3nbI*m!UvsVxhfS4#5?{PKXIz!z2;> zAoD3`D*Vj2$#7tEOq8?@@xOk7363~Lv>OrEGy=Bi7Zcr}v#nw;OZw*&AH}o&y1Xu6 zLkb3$Gz~ICTp{-%o9s z#H#=Z(22nw@;xGQn9&JDz@O(hWVdc22vRU`_d*4COu3qZpw#szFN~*iY;XCagkaWWlS2U&G4*5@sEeAsEI^3r7VQv4A-D?ou=`z!w2!tJCmZ= z@zGzL{7pz~r&@~&3bt!U%Sg_B{E*s`o*om+<|PwL)%u?}f6944%#ue^+MM;n9w*w` z2G*H^-*?C=tEd!0t%`5kk=wn9JyxBjA5*YuJ~zT9t~5D#MDsFKXg4KsZwz~N0DN&HY_2~p#^2HnyYxRj zfE6lwsRj4ExHYM1;yvR2k_AuYfR=7C92_#h<^ z1({^_*5w5$puEKNbiIR13y7%$ETdhq&DWlHf(54%-ARUKqh)sYPg*4lE%+3=+udRy(gsfi=zEI#y_k#-T-3IA7TSixZa;!J< zJ1yR99{3c2EqVI+ZgBx-Rh-4>fB)EaxTWWmRn~gi9f50lzAd%hTmCKp4j#2HK-lrEF-d)_LJw?6}TG|r2|7~&^ zh>86palqzR`Kdvb{^bU64Lm_4;OY3?g^$ET_i`Yrt8K?x(ceGYRa?H8q^f1tI@@I2 z&HJkJhn&k|ZrF7JKuZwrV|%n%BRH4=$n%A26ZkcwW(>m^zD)A60IAPWjqYHEz-%Ag zUSTjYlH6Va^HF}Q&Ev#)$_tj$JOLDKlBsE$s?FZ87PrX@VY70reD6l@a-FWXKEL`R z1g*}k!`ib90xNNCHvnqmS~DP%tZ1Sk6BdPKoAPjjZ_s?voFjpq?KmP5Dx1d^LmIkh{@ACIgAEe3lOHwS<6z^;;@lV}-}ekrX5DvM^b{cE{`C zg4?%pYdbeurt5yXD3#BxR3S}qL!tfn}|G&TF^c zXqT>QHtUS+vv$3(_i!at?FHFb!A@qZIn0-c#!4xEY6)z-U=M^6h-_e|bJ%AvR1Khz zBy@O>5jJ5}b>5~aq&c=Xo|pQ6v(t>Zno+EpYB(gQoX~OTc=fx^Jo$ZQU6ZYp`wYaw z^oh%AzFz8bPQxG(yl51*%eR}Ibi8;iD9%6qRRG2Kl=o*J_~pxCi@gix_etJ3Rvew(*2zvSg z=O|g5DLCo4S)R9}j2d(=q;)yQ-B=#@xMJ=%_XOl~u8J-rAFq(T12%6`?7Pqr2{NXd z3&lEIc^;*cj#Ix9<(Asx<3gI7EAmM0i#Y=?hE$FX@Lk0hQjoOY!w&w`1CRpLxG;2x zKik~QbFa^URlD@%hQGho)nS#hNc2gq zTbToMt6>*$Ya$23%>}u1E5w|41om^f&>w)=4tKDvfJ%5rJ2>f?(-&`Nz%%HyyXP(^X#ve{}=1=uFf-iiCXfxpK> z!-B5aM%x-mR^s0q8;t4oeyh?u`|eP#s{QujaOCOnDhBLl#oUp<(v*C%Hj(@c0cFVr zcG{h~HvVG{L&3*yER)-yRK3F7CqdBmd~%_+v+4jUn(ZT1L{oa1r_Ids8%m-M z%S2?8U%D*z5Hboe`GgR+sJ@?9AXvzDx*)=pAIi9rUX})d6m%T5X85JuU#=i4yr6)hYA$)wgk5ti69@$!O8omfPie6)rRA??cpdmqg6$*nT42J&F! z=zm#IafnF41?C((vDxH=SXFxxRKK#L{PAis`0}*uG0M$*?T;F0(lUS&b}Y23UpV;b z&EV=hxf5?dG_$ih9{0AJzzvW!{$<$t+)fx-CwF#W(a7Ct2ZI*%DKPVDmF?%&Qw!rj z_9_)75h?8>C>++VXrSXYCp62&_~Xv%+kzs(abjtQirZAznv1?~ce}d~>uHDE_VXDN z#~;<3mk!dHGdeBGh0-nFIFL?exl>=J&z(Z90=xSGAkAZ>YwTx;IKt%CNa)MKw7c+P z%M>Un*x!LQX+3ffEax8qz@3i594zien3JWuWNsIksm2AtFu#;>W`Jy%h}1KOkwt`p zgzqO1l^xM;v7Hodc^GG%ZgIh%vbk)X^hJB}n8Kcm20ebtFbaC*&PwJDB>0&=p&sHt z)64R_XN8ytX-AEPT|;&;HZ|Etd$iEC=kpN5+q)U|yuCnv=AE>#mTL;`p);Bcas@%o zHO`GTi)h=m#yP+688JRt(=>v#;$B%-tGV;vy1k?yS6P%pqno=)JsxL-=R^TuXm03T zKzZi`NJyrhas1blz~ozefXQm&!c1~z@1lEhh`9@Hs{MvpD(vfE8g{k6OxUFaZ}F>Z zB?MRIP9BXuM5l}$8EAiv#stQ(IpEViKBE|utjNkd%OD~AsVfl{PnGgOSm2U`Z;-;o zy^*kOyFA#woJ(#1^JT-RovJnx_41py<<}L9H#I-0>wR|7P}!{ywd+HC;ZS3s2)j;9 zHlRN3Y*W+#C&hA84=F;87PUCul25UNpE=QMgWpYCfHEx}w&Yah3gZJ-c4FVRb(FIT0D&wWA%#+{ zc#$pGfw3pMcx~p(=f`wEckYdQ+82SDmV=8xO(|r&2S*x%;D;w5IoG-0Ecq63%f)QF zsaK~HB@A&rRZjZOo8VH7Bo@^=x@8 zl#he|0wr$6fy8dT$vM=JA98L@L=y$ojH%B>4F3QYI&1V%LJ-qlP!#A@LVj@ii3Gjs zzj^!PYyqwTdEFK5vejvtFFGP3;{2la)HXom3Vc2^INhW$ZnGk#vMBM;Rv@IG;iK~$ii~O`WS3kd!fP!yCHX^6@BvTv+ zoMAKGE`2blTUJ*g&9oNcKHLy`;NNFl&mC!12s^W4`HJ*s1Le4)B|zX}x#qgVh$*;J z^-*pQ!)MBbuMSa+k1O*1;TkkTpME{C=*7UrW>tas#zCh75KH)H%kRW}UNQkhI9?A? z3&;4WkO=`I7U6=u17=A=N{E9u_I_2EYMUDf{0LwZAWLcVC9H=!ue|*}x>Q>r>!7$>BekyT zL*|eZ7?7*BcUXxG5k`0^+b&VcL41?s?#r<=$*u0!4dFwLj`@K4mg*W_9L3_&2$SyRV z-in5;tkcP}eDFyWxc)9igqMEN^SLou-w7%F+(!?Y(KROX_VIojR(w3g>aqTILI4EP zkUkcH_MT_kru*A^Q(PYUNL6x-fBlbX;$8SH2$-fZY|vuolS1lkNVpBIy9h zD*WNQh^94BG#o45$OD2(Hp{AY9f!7FM9p!JVBd8(k?Mu0q&5GkuB)|)QzH-T4!`Pl z08{i)3caI3gb_S3*8+b5O|07|w!KYD1KAWk5XWi+NKVil*p!i9Fk2H@x1itp5e{?0 z_z1txWVv*VZFrnwQxMHpC2vyGmV^4bIQ)e!!qgn@4qIAzvQ6Bu8U*pLuy{~(<)t4b z-~gEqR}W+(BX=hJ*!7*8wZp-Bu9E3b;lMsi0BAQkK2CRcG?RId)FT5CJ&$K+5?f>p zOHeb7)6N`0cKPlH^Ns;FZ*V&HHOMW^FG_Jk(|Oi}gh(n2JI)DHbJFGe z+KhU=S9*Je^s0{?v$C_YsJl{g!@8-lG1Za5di#!2cKO2HCx&|=HvT;N$(!^t^*j09 zVe@v&i<@uwIMl-;UzwKx_)Q#acIlSztArVIdmoLmx5h8-5yZx$@L_K2rD_a!W=dU@ zyZG(S=PsldU_Ap-x5IhPojXxKVA7OtxMfHHMdu{@!Rt$XF)}^CR%P0GZLyT&qm<>~ z9S_%`6@VpT>#y3T3#u1An>vSl+FG`81$fs4xwUC!J2!ppZVb2+xES~EtY{Y|&ksJ` z@Dc0y>jIb$;6K8FczUL>ZOtN)4u=JL$vbDy%A<1 zz7(2T*vKcPpWpJo#wCv_N{~FdnznKElg6N%ObwwwLo^m6*9@TjSSrDpCnoIw=>`U6 zwvE=x+&bW5AC37-6#}x6ClLUr#M%_{m{!>9RM~RLH>JaV_&Z%VK2)Fk+PlSTmZ_10 z8>soV(GN9#PrW3en6n^`KZJn&r~o8pq44gNv%k}P`92B`-b@W>NXzafuHx$FVc6p4 zIiK&{m-m^$;|*%Hg}s7sA%>N4DBLGgf-?#NxchR_o9hb;Z==9f?>&d+br2sw$g7z7|1wr((%Egiq*h9%cHf= zP}b+@&^d}Vr){Y7ZL>|)^;;sB;__%T5&5&rgy91BUy=)}1|3i3ib@c$IOb%Uwpzs( zU24>4`|H0e)2N7>5WG{(kP?xpmed6n2zcX}PgA8tk!-_D;50pcT;e7FNnDubAp3T4 z>(V}kPwIwy!`JNO!?kNk#y0^*a?_wca~L3;fPq6EX@~&%UB}3dmHqFf{L|cDZ2}v< z0O+sefs5VW|Nrkd!sO_Ps5s&x?-UXLFWi_#{IQ%nsF{MdV zyl4L&(tqFo|AYJgEB9|93jBXhEOU;~h;`|dTIcwL`Kaa-cN1+SGe1R|VFwbv!`Xq? zVVgF+SSLBx8aDT`Sjj$@v6fCqZr<;IC#>qqKQ8t^y9}KvCone`6D#@Tj*}kNk^yMw z*jJvMUiq@?_4L#T;(#u=Tm5zLx9a{&e=CL}-p*Q4Y^4*cg<8w1y$F{J)R||F$8Ri; zE*XA79$B(m!1mAQaVp@0i8zuALM6m(&S>%BQSp=we~~e4Psx>Gc&WpvX@v1Q>_mtD zy_|nGJNZY%eO|nGm{OcLzj4D0v&0a??+KIT{zvA?GvLQYGekdYyP;VmZHK_YD^WV$ zo?jUo%ToYq16Ghn`#+uVfsaok?fo=cvrs;Z`bjH3Cj zUD}q!E7pw8rK7)7oJVXlEs(l9-C&%XqZU>zO^@#RE71OLqsD>R*4M(gF&Z9E1Nw zxD=p%S(nOH|F%+pPd=(1pv?C}w*!Q&{xqbm9qIhf=HcL}7=;7Aed=x@hJS$vmTlcp_p02imo=q8pA7-v8rGa8siEuV_*O z4fLDVDbpds16@S_(Ifvq-@^ejD}aJS%MZLfEU%-Y@;~3^C;}g&_lp}NN;d!_RbKPO zBmJj5;m`^OPQNCXwp^f4`10jceb*}^7&XBRXw$6aqJ;mSb4?x-CTxX{Gne98hDRw} zZ?XvTnoSW!P0;)EaMN)K@BcnrENq2_qt*h0FJapc5;*)tyb3*nUxJRpTf|Es;#>o( z@j?OVAAQXe&iNQcINc(HZw1gc5-(Qg+DgeM8O&EE&e^Dscx?C)AyIvKq<|eQ$qXkl zSYYrutm_U94@W+Jknlp2kqP)V9+z5aT21OgG)G+=Nk_1*8m?DPWP8^pG${qG2rAP`9!4pJVg z@V3ww#?2qE?N8rgJ|Z5HY$Jvzi21xQ^F8DzQ*s(tP~F;`igPp_`Oq*h?@|?8p4o=( zCSOledG?cbiaho`e>}D1ZL*MCwXq`>~d6NW`%cN`brB>Cj`u$}JEIj**X{)+ww0=6J!hmV16w3)g2a7l3n zBTz0eoY+`?3T){|3*maC@cPhoeX-nessb;Faz;lOhrdIBHn)SVM)SMNJo*QA}1WR!{$`E~*SM|gvM{6$VI#yFhZiJFw8j&iAcFw z$&PEWstfn3qFr$Gr`u>R>06Mjw_@*nkgA=*;9_;x?MGK$Pbkw3_NF*ujDPCPo^SQ?))=0vV%Q-hGxRWf>=0*Lc-jOV)sY%F z2+{ff5NW(mO@E9XHSos=Nz~SXr;(OvrPoHt)<^kl{0M|9la4?oFE!qFkISh&R#V44 zjz#8NpsR-dT9gR*#-lof`(I5JD1d0*^*b-a&)Xv1co{c(35Ft;ndd`W+u(PcJbuF}OXTWy;);b##H7yIr-=b$Avjum!Zp8WfY zcs+hlcpgYt6>_9fuq>ys7&^AxDB5@-6L+NN<4d?azh?yH<6`E=6tpFBtuuYdw4G6f zEa;W$E{#;4X7R0D8kP#0hf@-RKPiwdt*Dq@u`^2@Fr3~UU2S~x9RdRU6H6~OI?_(x z;=-*%d(vyKZZxR)nXV|$#4&NG>iJZy1=84`(vZU%4fqGHTA%FIPUhrF^*1K$3*UPx zxXY!*GGy{mt$SDWEtF**E_OB$QJSrqa4jYt{_NuKQMx)#j(UxWu13Nt$+1{4l2yDn zl0{H;@00jL05Eu8_%+1;;{?*?!*klq#Q~M(X>E3iP4M^^2Xrz?zA06qmlS;GKk`>y zA3K~X+YhV>xKBv0JgXB~O-^D4i{vkOkAQpqGnw}ghA@)(Rhp=}8oL#B-qUs$vna^e zR@Xks(z@@V9e}1%^7JkGkz6p$lvTX41Ec`>K4UCQ9n z$>HpnR+*U!i& z=k|umG+6V3URQQpT5J8DmJ!^;B|qOp9r?(C9NrG{zxTm`uy!1GJXM^yX{Nnje^~d; z>p+Q30{K1h0fcLJ&Y>}>4oxdn9V(X<_u|-6i6ie5=n-7>uitu~=fg*Pm){m+7C0j_+GR*@G#wbwpi`ddaYL z-mk;Vml@DVv_D_|r1MZ5$Elj-emzndG1F0ehu9)5Ae^A6f9Z2vlacGa3wDf#&?R1O zCdb{ZP_LvHPkR;16uQXQ?5tVHHX4A#6qb6mJf?CI(3A^1o>EPHyQFJn(@j%1?e6Bw zuUgEd943Rm74)}|{>Ld2PJPXDwF=z^5_)tHNNVRQ?G9r=d~OGn$*^M@U><`ohY{1V zEz=0tibM$Y>aU_Mdz@Z71V~p@F z8`ay^8|U5AO?`BguZpP9iQh@Nlo+YhYg?s64A+gUu1IKvi)Am4@tB4fy1e$fx-GKP>#y<5Gv~SYoO92)=f2<1y~$0a|GHfqV^afxJF4^X)0H~4 zDQc2U$(WQRGpu+y?yu*_E#|}Fb^B7i< z=?VI_!D^ekBmoa^XvM@9iu>*4gmBb?vO%WxseAU{fWi&w$f_%HGuw@2I+%i~fxeR-aAMhxn5L;-kwnO)PmmXsu;u{P2p1V?UQw6d!p=;XURMWS!#VPtix{re{OSb z-pzT5a}?p5XKlMURI&bY%+ckY?u%@|y71a=qCmFtvHLp@*6%xF%5gGsiw~g$vP`37dP`xp}do0toG>W30oM-nsG(Esn;dC)7hl%IC-S!I+`a zaYp@1+EeprC&5FTro29O;V%7W%n&@(#sy<1CA1-}USJ7-Wif6&eR62k;B(Du!3_ni z0?UNehDz*gLT8-}6qOKW6QF@SdR42PIk_Ol$nDg*b}6Olc(IL3)$?gvwX+uA0^F7g zMvO!@<^N}`7<)aiVcKh*MtB7EPRuqm^GEd%z+I4{YHRcV% zQCezm??Gy;jJUWywRCm<0;Y`bGr1Hmk0z|QYuy;utKseXo>GdHkTZePS%^5aNp*Hn z+2G0Pd!(VAZIuZT)Do-cr`GFE-Px?F+O+38)Vy}^WA8zZOXGAS!7D3RzPwwhY#_XS zF@}5bS&4Hr3(aG(7}KiKd0Dq)$gGzCvSPn0@&PVKWUGnvRm%M+O4>NLfmZa#$ylC= z7woo6K?t5vxl8XA>=Cb9ZVk%YEp1EE z_#h z`u;ldcv3S#t4~)8s27*ucg)65UEq5_U494L<|{JQ;20Q<@sr>kvzey9X+4P8T{78- zu&YRW`gu%cjO`pkn4)?|mavhnV`oYVV~6`RB@DFY$n1G1jr!v%IB}uI&uIWt6VMy) z%d>4*vMOD8Dyk4!+;n1snn>+ksMf%5eUbf3QSLfwI<|l9)o2lXt^7=kfqnK-n$UmK z*`2oshFufpi$7{+bnQUyc0?==+wO)?;fQ;}D#LGc-cJtumVWXmAU{bmBcB~sN0?P#W+{%~e>x8@MsT_JU4?z^j9Cz^ zpTMxe)x|AtHm*Ex9xS*%NU>$Lj<$&Q#YwoXXdDeKhp<2jZs3|38~5+J*3=0N(K`*+ zM{ajcoM@{>b{LM#6NiSICGH$pu2?2>Ai%av31xdNra<}pL~$Yy8T~$M9=}roc6IQ0 z$jqCoWoQ^b;x+Hk*4p}VSn5&T71st#XWrMPtj*ce3PS#-0y23Y09{eD#FD9vOP{Sm zpotZ-qQh2%V4wHha@4tUZ0-4NvznWapH^;Ms2`~b8%ZTJlyn;tR@_}1=Pn@*C4vN3 zKP0#k8nBmNQvAL z4)P+JvTcO+IXFlC*G!8NiHW-cgV~uDf_vLT zoIhr-TW(|py3$=Q2UbLqHG`i;yhONaIl!x%uXfc?WaoB>#rV3RJY#M0riquhOc3cF zOsk}XSvN>|k-uU;A@L4LT@@UZ^WE7ko1?2#p?CO__7*8#0~^%SnMcu*#Mv ze48I+TJ!uFdU{wO9zOebkjETzI#g(~3I;bMs$#mC=W4KR-s=MkU#aGXWFNYqe9P-y zQ2X?+PeDrQ&(U-ywR9+^x-M9FW?q?g4o+H%U-;qV(#MgvBaSut_F7Jo4Km`3A z=<_%G&Od!MM(AqRm>s~>qxn45tRKq^S&@}3hLMQ(Y-qwWvDzVc&Pyj3ib8Rqt;8&0{TJwS>xr7`hc`hwQ&EV zPm{K$RI(Xb(W$%0uqb(FW!?wX;I}Oa&I(u!c#{OCH7Y*lw=%Vva0F2U6Wi?ZTJ=8P zoc9LJ;c<0!*8(jz_KeUY3S0L8OFVMizY4*QZ0h8gq%1*BW0*Y#Ozgp-bR%cX0eyc~ zzTd(PRN%AH72WIM9*8{@2vb(vYm2=skQKD{Byo(vTiHRqjBBu&8-Z{#BO1;{jQ&K_ z@P*5I67wY@l6tUAeOb-ZvYnR3ZnBMj%f^d#Gy1e3#EX@6a`z(=y%7W3**%YsrA9>0 zO}MpU+@?=nQdlzGFkBk*j_e)Vz6^V>-F3SG7g`d<|Mj+0TZ5Ivdw&ls%&2@YC;e>!!jji6RpT6B@2J}d?ex7^xijuT&ZD*cqj^I3 zvUj4Z=pP!u#Tac|$EJ6w_1K4^ttN$8Qu3`D8>lG3$x8QUJ_s%KzOq&a+*dhpz*+bx zE3ku|Plw1e+kZn-pz$);9)Y|RZvVZ_ysDF8UqYh9ZuW_IjRTNS#6A_%B|laJw@;GT zzJMx1oIx!Iv1QR)BAKW{{HiCp9Q$SVORoWk4GOJ)JB)FMw^L*gacnN4%6ss~Z`XJOX8F3}%ww@?k+VbZ0D^?9$+ z&2By=8?T$tltrVFIqJ+_&#w|Mw7TqDP^*+V{w1suBO+Ge0UJ#`2xW|pVyr2>mtIBZ`(0(cjk94Bp zsGrBw-flo(l6G28%#Lo~cYC97m7Rjl7u8=uD;66|)0E)6Gp#7k!|)4y_E zkToAXaHMUEPkKBHm%NsuE0b!LSDIw4p75w7p(7${qsBYd$v2=+Y?UsFsixj`TatdK zC({P~g^Jb|tXS|l46tc9vOh(6?XErKPOP~jn0BYUISCSFad|1zC1LKC#5lyQDN5wR z12fH_8l6gwq3v`Gq`l$xsT{D@1j|=vLhNy-X863QDN9z6(c&irvC&*OZaZbd#&$_E zes78fEEFl1o3qHZpBYB8eggE0$_1K8wDfr0(VT`#4Y39j(WLhrncDD=@aOfXY&yoG zINPu^>$71DZMCR4(P;)SYoNTme!zCb^crWBv&c7epJ;8ZQ^Yrqmc*PLzx9@E(+eYp zNH6OxojRWT;Db5Fe}6aCLLm~+hvT<#r=W;&c{C0R%wty=n6U{Jd14(g`x)-H|G1~> z5#8ch1_x%@b=R7$WTzvn9vhBOL8>#mD;-drf(3`tgoc)sZ49OI^|ToN``;J002 zec34UGEy2Wc9zr#nbd@Q%We{2J;L;kQdz5JUlXy%y06Bd;|%R@p}vis`>I$xEVCnI zyF(wn7v=_itw*FLQ&+fkUYvNL+AC00VAjeP>gWcIYDQhgbmDnse*Mv+Qt%V01uv8n z3MXBn#q7duc3#RDC5QIQ8)=5yA0^EC;?84qAblPa{s0IHHDAJoCJW2DCFT7DtFbwG zd2K@#PE+}LX|YUp-PGr+p2Spxt)^CH`dXZCy+^)4vbM*0PH51}6+oeN=UaH8#XzH$ zWp>({mufYAtVY!7=;>3xU|J#-_cYjimI*gI*Nz>loVnj`WZ_p~?uD5GF9qtS+sJsj zt`{b)ygowDBQL+P!$BPCTisN8g@s5>He_@{+_MnEpzAQ$MA=c>&--{CxcHM-2odpB z>`Wtf z7@TZ3V&J2hS8;@m3{<@aIAq$eS*vtPJ>S*dgu74_ySHU&bd@Y>wB-qr8ZT4Da~~IW z&GWTab>t^5pSjs>d5UsqbwtRkQTzc6lyZ#ODtTNvbGH)`QLmYG;SFUD-r@~bJSe`W zX=cM-GAOwG#R6Q-F&L#R87;#RL6ZZ>nBECO*4(R&%10dUD5+vfZOg#o=NnIg)>%Tm z4?E>dx?uC$9N5&QQlI_;^VQ=F=hF2)UM)24A-!|%R?ge5wPKs$u9vTZL|OQIYeE2< zFH_z&F8|^F&SjFWhn^RhLI((nEXD6e`vovEb>a3s^>FP9R8l+KCp z89UpxC#*~ld~@%Ap*hX&uQf1T6fCIXTf{;)o)OV(RK&S`cQ{s@8%;Sb7RQ;m{44B(wSfo~2Bxuj!g z;6o+sNRw@B6+{|(qy9@@$uDf{;7>9Z$kgBg8fI@~mvpY1DAaGn!KT_R<2A65 zp~W?bpfJm>&_RxM zMV1|yYy);qA_G4aVOHWh8tZqU>Bq%G$^fkU_OVv~!S)aY>H&}>IEzUhYbdv9od0Q2 z%`9W>ofZ!ODYEiz#d6=o^Jld6^=J=_D1chm;x8K^^;n3*^+8+R=c1*4ttH_>6uoeO z3&wqU|7-?e;NS+1HyRPYz!f~g7|VbM=5liK`G`1)L+>#D2k-v8hZqm2J_$}GYsPbX z5}geN0qlJsI^Gurz^13Fhf+p4;8+QYh+L39+?)X(5U4YL{2u z!I}>UXaHhYcsL1(&T@g@+a*Vb9~STr1L&fqu7@fl83?!lpVyN-tm7acfHSl^=Q#g$ zper6q^weR?^>PCExxDm)H`p7{QL1<%$tg7VA=C}1xUv zlERrlLYUQxf!iMJDG+&2h^PH%QEl@bk$kWwnbNQ$zsVz!W1tF5+ux=B&q32dhDdlN z&Uboom^9_PuFg8Ns`_E_2@+(2JPC6SD+z(KP zF#6Cq-X^2 z2RlU&NJslYIxIx;d`j30t=Gv~5L#El)F(bGxmR=cB5VWA$!JMn*Vzx5SFe+Zg@4X) zo?tfC*eKbknBz8`Amb`S8$i&H3BBau(l%Tg+W40v=ZqAICR7}u6EvIQ}bTAMSI99-$n7)6YZ0PCT7>JRdVti^w9rqRlzg-h}iND zB(9&#hgT3VU>QxLWPX|Ow_-RzSU5%)sej0N-$e!*<0VoQX|Vp$7lJ@qe3T7n$^R|r z=O-m$e4q(7%%=W%<+oy$$Uq2Cc|Bq9rwSm9c=f0V!4C5=zUK=FF$f17pWOxjv^6aS zka#1LA^&iw_}+8cAP{P4m(Mf&smxFkAo10Q@V>tg6v>oeHcXcG{SG~my*`2E12pZ@Qg zFp%hIDA+0M2`f806a}l)^z!l&?o0ifliONC0I#5cXCS(;P#DyOT4`+VfNIhqYNepO zzAz?`l(fBaxl{R1uOtZU<Ujxs<{8A>;XONvEya2B$i)#aVf^T|jKv!i~PmcLHP*H=2BYjpz9^OBWgY>{RREm9#ewr+*EY0{t|A;VigGM z;Yp-n>$p)aP~!>ek&F;Ty~vQDiQe2NqR_cDmgiM zbfWQF(l9ac4b9}Fa#MDbp9AAyw8+v`lHD;l7XzDrM*08-^Gb|RWDXW|0L4>U5i>6@ z@6bPXwM7UiEnQu4rnWxq-w4-lA%SERGerwR2G1Rq40A7Fs-SO)AHc%QHwHQ>`@AJr}{|73i~I(Zx=bC9TII807F54m}i2WEIzP7yigdfyVS_ zxSCarRb-^c=R-g>ZvJd0kdyD|;d#P;da5IOcj}}^@rm~YQ876Rbiyk)Uml)be|Lcs z<{36YDq|}r^aEOvckoAl;30OAK9tfOJX>okN4v z03+}muS@iL@8`?=?R`Jo^ZU)5Gw1AFd+oK?`meP5fDU}t7&V~T+x{UJsJ_l;`Tom6cVIvhL+(5p3h zk_a-;D?ARLL(WHZ_$04F@SnGbve&<}rXVd^dldMtkc~E&&8Y^I%G!Y%M$I$&Z*-~PJ(qL!9;F%6?d`< za-aE%Y>X?%SHUN1v|o8&CFi#mze+A?rN9L*8r6`9VfhsGBYU1;4zBqM7lOj^oz@;} z%`=h)F_EC3Z$-uNi{t&E4+N(ZCP$Twn&*0FN1gKb9g@n6ia~G+jVRfdG0Jojd(sZP zbC$g08g$zC9*li1FzgWUkkUC z@57P_AlKtb6eE!x`~D|)>BMMn8i@z;FxHYTi1WRqT%^q=?4{A7>qoXfFT#EMQJVU$ zkO@SNkHO4!)|&DOr+J`vcO2na`siiRw-3m?L+)*MQafoH2zzgk?ECl|Z$YqUbXZ1& zfegz6WrpH>`F@(yfrj@J=p`wGuYG+I!8oCx9TsE$?VXYTmJ6~gz_>Hi^n)9z_=$>_ zx=!FDjR^)!??Lao1Y<0b_KN{d68l+u3vb?k@&UcxdZx+vA?)>!n$)LrVv@Xfs-spV z2`85>K1~}i*YV%xFC;5?QgG3??dG7wL(%#ho$^4&RP%!(JYnVRQZhy}4 zrKG(dDCC~xzK~MDg0}~|X@W&+f7i%gijnA8dY<>r?RRr@WSFPlo?U*?BYo)eGgrAN z@>ZCAK_T|HyBn$n%RZeXNZ8|Wf?aaoSCOQ|WGxKe0xuUxY1A=8=qS`pFPo78lUz4( zpDc)n7`aN`@tlafD}9sJpTsw?ss?k%7rL;pw}D&8)88TDzjR`x=qk6yC`_xvgcsvDmjBj)lOD17ciF+C}c^ylty!zj+pg?Ic;6z@@03AXBTFq8c_L z^;*Bl!ntYRbh$sgKjH&V#lGf)lTMN{S2Ebrv)GELiu)Mdmth+R$B*3in%_O1YPOg4 z!frvYwY0Ph=+_KpV*Feq62lK@vgflou~x>O@1=viP)}d9U-83ee&Bd=ypC;bKxAVe z+}}dziYZ2d_u@HYAa<9*y+cug5(s7dwN+SqI!-h!#(*jhN4v#Y3H$inc{+aOyQO&w zdukmVjW6ID_qs2TL0mmwV!U_ZB==fDhorZ@l9fp%lkzS4;?OTh-;4~&qDu(GQKmfz zXOgBYzn3pjMF;;D_Kj}&nZuoR30rzp1og7wvPe+IMjnyXd##KmWxnp`ZG+DoA0>Y1 zmmnB2aum>lsb@k5B@dsuQ#S@VXABH^-88VedyA+2!DGITcKWj0?ATF;1fNxk9u#(L zl-)T=<}%iO!*xu=vUoGuPpF0b!Mn~SEDz=;41Ir%MP641FS6#4xlZ)n0M5NH;RcF( zjC*)8@8w$AU*KmXe~z*bx4+ROJM(^Af<8i~lDV$Ug*zc$K3P^ra$3TMmYTttjD`#? z!x$;rmAWh+#P~zzG>dHb`Y`pd@-UMM|2l`}N1GSU%(xM%-^d(DYm*z2Jl=bJ`1$@P z?2}Acma7Uq>x=FO-8Z_4UJ!3SPo<+*OzICP&*xZ&-;O-992Zib+gNOh^pS zs!V(Xj?nT$h{AcSP~T_stqZ5}A>UQMlW+8Vcgd$wZO*?4lFZZ``f0&h>49+2;@HO7 zrrs{TF5af>n@czPCQmtgDDSPz(zTQq)mf0Wku|?VhcnExYwo4YFGpTQ6?Tp5q-$oo zNgvdiHQy1pey5(Lo;voVct*<{o@y0USzL(?_d~QJrr?SeVkQPRA=l&R4A>2~43do; zH%Xj|oLmJb1x*E^PJ)hSj&LVk$6Jo(o9gS-W7|eXa%{|D2|YSrrUx22Jj6OIZkmT! z^bT_$8FPMg%j8!dP*lv)C>T$7dA-{xDl4WWCgmOD6?LJDJ>Akzi!g}&V&zTJC8$0BT#g=TA;2lySP`SIz zqRd87aAqm_)G|lS2kM-K@0=pPn_F4jGb(sIV}>w1`_hYGpDp@U^ku6=;CnJS)v#%- zp|4?}^{Y{NyKt~zuy*Wi*(%f675AQzSi7R4VzS~UxK+8kRk3+r1!rYRMU%03#SeIb zr79TG9&6E4)mJuS0v#fPL*PB-h2`}IVTk(4;BEd%mr#a~gS#x08X`M&zIE8087&$8 z3r!3o+|y;>%DR5Ey9WPYUDEg{CUQ{`Rj67rLX1m>S)nD9@MR~?L%m5*@_9|*k12M=(Oi2@mceNy8{vucFh3u3uZPZ6$}CU0gHj5 z-u1qF;=A$g?47PJ@;VIx-TU``MK@~uc^2_q;SEhnsKL_=mbTG0qqawF)bFSKdRn!a zGo!>=N)tR-TA#ci8@i28?-pTAzbmOJ)faLmka(AhT$lX(nc0iphWL@PkcsuOdSm_0 z)6U|+dMdkHrGa*`=`2~yqs$F5H>Gt_CSsn7T6ztin)sT$b%5MpWuh}Re{~t%!n&E` zm|K?>PBDj)NQQf{^lJY)+g@>zAF2pJkc9F-DL| zaQ@!fPlVoX$Z9C+=5EM$N=2SVo^g|7lT))8^P`~J#aBOiw!*hKChZ*PA^_laaGnFJ!EctXs3Q-@gLak0|%X72qu{1u9Kn5S5!mQ`2!P6h2V=Sn^+dt}z= z&#NlE-g8!OEyAvVo5`A050oWUIQB=irnl0pE<&zBrdAXBwS{*tJokLuOl7IcWzuzmv&fsy%B*Zr51G;i_#6f9-VYymJIsY~>)AOrIslP!U8u*| z3&Aw`{q-$XNcmV}m2>sN4!M&>%BK=@#f{WpDE2YmxX`GbwE3V0HM{OMRc)sa!{f^GFH%57ohPV zgPK+JiGAgs#U=N8+3Zq6Cry>SUA-s$`q!AKAr>J8zZAg(*+DKA1&9KT$dYHyQ4w-d z965nZ?5|I;vvC27ofK}C;tEX*-s7s#w`f{>Tb2z`K}N_n#f9n<)%@%spN%=tU?_3c z3vPB^a^E;Ax!-zU(Qb9t!>w?BEMO{lhh!&u_Irc2*TA0MkzG|YuVbwfRTGVU^ZD~F z-znm2#A#xYVrLV&KjV*UetzhRLge-&ERY zQ;HmEZK89O-|pWH-#7jFr5NLSv{cefjEm`O7#x%s*M<32a`XBFe6J~|SmKhND86y> zf52{!R3Szk4mH}lK&X5?g)lz9J3Cav_+)|c)eXaO@oR*jN9=uJmq!Gosf1Mum(i(l zL>KbPDKgFbP)CC30!{-*G#7S+?>(ebJ^^4~;26t@E8Qf-z_i6)+6DdxnZA)Rlb6SM z1l;3c;9%avxCY!|0$)+g`+x2wF&|=J|MMIR10%o^1Lt=c1>pMY6ApZT)%o{|9qEsO z3;ZJkzOLz5SEaE=)3N{D>j3XCo~w$>$N*PWV+T`HTZo07V+-BRuNV0C(wYzqj5|!f zzL+v9jC(-)W0q=f9N);x2^!mhISozhj7&LQ!S=t3V+gwn0=HmOM?+dyu#GK5&{c%~ zpAv$={jbMd^tAsJakLhpe735_}5cG zB}-RR8%+sIFhDb)4^h6yPlf*}|9`#t+v9&~zWG~||H=QO=6~M&&zi3xrViqEV4zD! z(Z9>}yW9W1{9RF)>(|WxLy3RM{LfQ>&Z0Mjx&Ew~=#AF$feBz4A6QB#ssUGk$$ot> zIf0*t|6YIH%STvhv-V+NfG}hvo~yZHZq48)zh0v0{4poTGh%B=$8lJ#rd2tdqa^*1 zwEXskN~{TMy^2NDu?dAKho+XFO5mMG8O;x6Z!kqz6=EJmC0~3ej z-=BF}9K2CnSf@7W--kdE%D?|W|9>wcU7(VU@VfhZLcgYlCrK+LBpVqS$tWbG9eJE| zH-RxSqKrSjx?@`uXQQSOu5<$XJV=^!TRtVK~{lD_SJKUe)O0LGEr+Qr1zw>ky)vJSNPz+ls9N|Qo)xQ^GVTZ z^|#5T&6SaE7}L3xmm<{1cGa^F=o`*cHI^xF)~Y_Z>SzB=SAsDdE9B08(4R#lxdU4! zV^yJ_#Ie5hIfa$MI1PUcn%o}m#iZ1ExV^M|wPvsh($BnOOe)|$cq)8M9T{IWCLl0b z1|b#gj|OdH`@Q}t|MR1q!H(st0AgZQ;8%zvWY=d_~`Y)z6Nw-l5TARO&qMj3$t{Vy#d5 zAaSRCG5GhgF`V}xs`E6iev&J$lg7gYUu5)w9D}kGt+1S4cnH@@Uoj*tA5JApc=TxG zs!5bwUg*b?BfQbFACrsheJdRisbsI6i_L^)ed|CiuvXfW_}> zhP3HeZr}o0&5%Z@vrHAGCO3i^%c*jPv>*3s9w6E@pL$f-;fxR?KR^H6u{0M+9)lG*5IWbiXkIc;C18(;o24*vs z8!_uDJa^U@p^B7{fm&g~=cYO&{}g~(kPSxMhRZuCC{%69%u$l(N~VVfO|{7UkpOr& zZ1YdgC#t!0%b)PP^VFU=W>5HWB0rst)I;`@=^PTD0Fs zRjdbSps1bLt)kw*6e5_Q(cy!;f9o3HM9Rijjj}Q^%r4==&J1?wcVAvD4@qDi$V!Wn zXF@h|`-NitaPU`&^w9T$oqsrt1n+~-2$vJ1lp>>2z_Pju-e2M};v^5$obsI{r*bH&1p6VZ`Y#dRzoKow zDV1vD&oJ<9f(>z)S(95sFj}kyk1Y6JpxN0QBA38xZ6+$Z` z?CULiNrm42{>VmXP;)uY4%QLRfG7)aCoY$5^HGUAkR7mGsM3#7*?w2oC3}o9(Ivl6)qiR`~;qxzrvwabb&#>S^ zazZS!dCrWkzZVLog!gPaZ$w^bW7=p(Gz{d4;qv)a%%kCah`A=*DyHb?&jaj-bndfc zJD_c%k)BviIK^!1)APX(N?^(p*l$LoBaxQjU_~4<80*)Wn3=s#|2iK`tAuMu9tZX3 zQGy1n%W^eN+*3#sZL7>?R_k2B4GR8u8`HkPV(JzDfM9W2ZEB2+&}<-PX7)(8o%kH_ z0J>GClJCGYdC}>7x}Hm8dbII9hp^u(jV2(HNj6CIWKC{!+%CK?iC-XCrQR8wuLUpA zoh>sC;E<(wq#^o3L9{mEftv~W>DgwfX6OH1nOMar4}Hk12FMW_3+r=|yH=urD>8?CLCki_>^Qtvc= z?_fMXp92FUb#?!(sU+$kdS6ZkL+m3>^Arx$tV`N1cTJKX#q$B|0Fec zZ0SjLLZ>e=Q6w_ebF5-6GgDHw+BR--Wk6J$PM51r>1@^^_;4Fq82#vVcZI&Bq`%u7 z&l97s_5*^u2@WgWJ%fgAS*p1TX31xx9!b1!!l=*lgvH#-*X_7j=J-&p@}9P_qMm2c zr~3gl8ayDru~?!|I;pzP~w| zo8nw2eA!BH?M1If^$Ffq@=pv<1ZZ) zkN8zqKAlREE>tfjuz{2gChi2+>zSPCvQMb5MMQ*kuf*vu%!`kysO4k)#hMghh zn=<4a&s?WIYlwcFUM%) z$|O87-Rc{fZ}6CWqFHySDsURDY6Bt4<$H6#(pqdp_R=k0VNNL;RNN;O`fO2p%0t4s z&>OwYZ#h$x2s?+dB^lXiR^>tk( ze#`c9v}z*s*xO^+_?lInkhAdkm?+fw3D2C}1TDcG&hSKD>sM1%xiXfju|#s2ybFSU z1yjPs8G2^IGq%SVem@^>T=?B?v--^_(k7TAPI`&kf_Ba8g1eG zgFlclnY4cGl;a8QubWgjN6&DUHjB<^MyHPJviM#XS<`Gnmk6zV*U5I(OouYKWnV}A#_JHr1bDp&Z5 zPcUGMx24%M$J9;8@`zPt=2|!f=Ywrsn5oJX>unEG-BC??*Kc~XRGd|5+ePrtd9-m- z@G9v;oQO~7eTjul*X0gYIhKzo-B#B3NAp!}pk^uKmKQ%!S+<3;a3(iNMGsKs>SkgWpoQ1C}5{n&gU99J(eOrlhV%tk!$p<68v6VKgPKJTD zGYqc(K8%c%K-h4AXcwAA=4nd2qvHNVQH3RS2<++AQT6h-OOgZSJgWIBOqy`_j};H? zdR=Ei_T%{P83Cj{zCE1or8`;6LU{N5(}>F@AjJ?0(mj9oC0^^o9}IyrzNqQr_Fq!mYI?oFL8R2A!6cj`dv$4 z>vP*yQyROb5%o@wwVv`XmVUYMU1SIuV_8AQa?={UZ5yX&geo+*%9-Ql9etVN7@ojM z;aF~0e{pty`2rJLeLHvR)e*0F?W&)sFQUlv{!QpESGb_le(T=2zBd!n zS-8{ohD*BbOdY)84AH!upK^eISm(5PN1#?ywO;#DXeV0RuCGveoIDpU^16}@q#rfK zrUt9armW2NTSsYGkNYMB)p+ zBp1+h?SWmserZWX*Rgv>qyAbJ@&0$g1=p_b?tL4V0gs9uAy#d^QmXy{8qTUl&$VNF z+>Kj`!b8IBH7$@w2gv573Nf1jqRZZ`sp^4|_BpsnY19|rOV7L?8-;Z}pI!zx%o)Ya z5OeuIQW^oLDq5-2|Emar?p|BtA;}INQC|^l)Q*S=&?r}iPxJ<6t<642uKK02b zuYc%d>DpUpOKwls8{o(OB7AWYmFn3DJ+bGr2xFM@s-AV^jvCLD$;0Bsu7B3ra5bK{ z_>Vu!h7Vge{X~ou`i+V5Te(J>n43dqbqm$OBLn-*B32FP9glST{pCFiX$PD^+fu%wX*;g@5y8oW9#@M5X2Cr0M(oTF=D~onRMxM2A+u`Ofgl3t*he zc)SLjIyKUvLKm(BqSdp*8jfFErl)meo`CHttwpFpwUJK4URjFPQyn8uvj9DYNVjBs zQO+)b6Kz|+J?1ep3%8!rV^;L~;%`Qkyh^R=ThjJHXrywzG>LDqIQga9!AHs*yG`8X z-R8@xO)a<;lWI=W^17Y2hJS%SLcqc>Bk}Pz-j6FCGlT4*GX3jUSy?jw=q0CiFOBoM zQ5p(zPDvWowz>&CE_zLLZPhlD^vxG%g%8XV-5$8FWu#hv4RTKukuhxIn~|IDN6tiS z?kPR79Pmtz0$-xE9Xa=6^W?XQ7D86$R)h153B1ryiwYw1VVI42(HlBm>kFw$&EV^A z&s*(#xp}uaGl)jD@?7%9oH%;Lg9Vj&{-=&+`RJZ1BcP;N*x9xVw5z47-}RigO{a8h zr@GzvrLw+m(_K${ZEzfJJswMG*ATOadP75tEdqHgPE7pc>G|I5bL?$yDH;w6r^X4}8&c_G?`AR(0=TV}3}3E{ z=l3sYd5bG84a03Dt7lnja}GW%CL3`ov4tL=a1V~oS6K|bP*EKb)PF2*hi`rNvFx8^{>5>PeGkMzCInKC6mMpNV} z@M5=w5JS_)uekXL-BRCZn6qrpPLWs)_pfWK*Bp-!%Y3)`p0%O; z8Vo)Fl+Ug-?>k`WzDKVMq>t|Lnry}vms^euEC{}CUa`v4GrL}yHLSOOl4gx@!2<17a&M}z4jv$+%<9a%L~d3{4#9xz?IAUc1oV_!09c@8Fozx(d&Ok_%oo02J2Zxa3} zZe}O+ul-9;A8zx`F$jOOwzd|S?jlWZyXO#a%w4}a<^UniO_y${CGOPGBodiW%k>E< zmmd013|Hz89Ud0=07~}LQH9OgTY16mxRW%03|!hzSp6VQ^l`SVx|yWJz)NC<^w1NI z`9Fii#Cb<MfMy%8K>_sk)I-DdgnHSO1rRPiCL! zF5#dip^ubKKhACy{L?fnk4&0dJcK2Qt^?vwE~gr%tL$WpvyoRvmHbD#`uJ3X@NZ?o zw<{G8$jaj#QhC6-Pu#hV{7dF=#TLqzml`{~+c_1PSu>Sr#{W4+N!neyuaT=Bk&Fy& zH|HZsCoy$y_|hp+Ft@q?9ZVoTBNL14t5>WtuU?h=^T_0AOZy!4c*5S`jkrVcM)8m7RvfOH zfLV~+qgF|``-G?c|Hnd-lG>jojQL6(U&~+1NgT?l} z0h;SDL>kf56$%JICCur*a0?#d6i1w|p3sp@+rd}^&QuUp2pRf6Sb-d^fIxeQNBl;9 zOn>j=nZ*R}GC|Tjq!vsWM-CmLrK6ePs=`OmTofwhcHm6DVJDvfY~lrH6LZw+j3LTevyX7beG~t;6{;sW!5n zYoTNNpI$Cn(kpe2%q(kNO#+68Z(*VEYM3O&#`;r=p3tFVrDcHs8i380*8Ep7R_LWutXjUm zYrRLXHJ4`n4iZW@a29UZ5MWXL5QZrJBK&7#`zXTB(FYp_yw5fp#z4CR+y{|tlg!e{ z4mXZU#b|GJ<`@pu#CulWFKqrSISHsG3Bt<5%ZDU1Q?>#3RW& zpZ|>iX9r@yKv*~%pcgiO_v`;Q^)JExmsY>Hnwn000A19=67&_J9bRPo5ySl4^mP zFw-6l^1KvhPD$&0?WcB&?ECH*Kp||<633s`*zrm7+Mv~VqYFpbj#o00cJY_lzRN+Y z6fhn){O8aNB-m3ja2f6aC0CSUKX4jVUiESgUkVjcP#GCs4xsT0Cl=FTpYuc)m(F;W$nxx3 zkLHy<^Y9e$yyX=sWn6_F%9@S{HM%IG#?q2AboNn{#DZE|b3xyZk# z%)*`YsfIoL&e!QXBSS*?a%i!m>wj~WeI&Rh?cj`@0J2N}glk_|`T)QmU!mAa!vQ~u>ul$h*2)+Pn#S31 zKicTR4Hm6*v0eO%{|WZWn&v*Qje)N@`^<23^zGB-k!+QURc{;D`UU^lQ>#z#W0(D9 zWVRN3qugaz4}h4W(yg=_2~)idgOZ^;Zjq^}obpLeV*z7)1R9qf2e<5wWJ>G)!IDgS z&j5Pdz&9sA1K8*LKMz#1j`DL6Pq-SFjix->&=_&L&b1+FkB3UUkST5sQ{d|E(B_Ns z9)yQ+I(}DUY4aCOu^(RjAUo!9-t|OHnHKXluXi*Y zl(YSDq2M!+@Wb5qe2Ke1@*hVyZ8k-)aW>1T7wZ|@Eyif*FcaKi56r*s-cGL3J@IyF zTL4wNE}6i+bcBU0&2ZnDc9Cd9sVa)n5eq;5+#-6RZt?NQJHKF?wCJVIuqZ?EfRiba z`6`}%3zky(BwJMdLI1@Qr|xL1-67{eHjR>4hA?53RId%0TcNniQ&OcXBiTL6@y1r& z{Bu7Q``7KO0MLwUPPA^XH9CK|Y^l~P$t%G^D}5W);If)@+~2p1Oyhr*q^gZ(&om#{ zG?~~V{Uk4~AY4|(Qdv)&qferc_gZ$=V~Ox!vTnAqi|2AonpMaxNgYw>kscNwi(VM8B%U#dMF`Ap_ zv8Qa^&t2xa@^h;m9mWrc@@on@ZP-}HTaOFQj@~pUuPa6Qv#RPxL;BY(rs(*s5sYh) z1I|v(rtPY!_WWY-^cv&o(YSt!{HY#`D4z^xV`b7wT~&pUV>pGr$K=jj{kT(3SI^Yx z>?ZqYKID_f$xx^B+qLij)7JM5Tr_nrth>EuD<{W9oA>u+9njm=0(Lzp_rsyiZ{}&G zm75+^h4NEXtHHfVIr%dFgxh|+$iW<%HmQ)$E5c?MPsptGXZJHcUpZ-U8A&K$XTMyD zmLmsNZ-pG6$sWQgE?lPRTxOiYiUo=Qz;tW6wn8agN>cRExXyw8twfDUt)V2l9X1s4 zU5hDJA-mzk-qVA*2%+6Iu3v{%1>#_XGjrq=IjJ?uGa%ekq3c`b{|yv%{nTl$q`H&Z zw&`vNk3Pwbr2h2@CjXzY`^W@qVa$Syp@|--~_n*!54+xf>V_@-!J|2dT!tJm_x5Yx=z>q z1JYiLz$-g{VO%B&a{E|m!!QSYRy_y)j6N+;c1dho6Aut^Uyd<%S;f6FOy-#YVz>`4 zDSm6G&^Myt9I0v%wCvjlyl_TH^ijRMo>%@}IFhIhg6#bv+?xabe3bDFOtB>=c7B+t zGqLgxi}&-+lS9mBglmH9XeYtlpAg?0qvi>=mwN8&P?`8i_mu>MFg#4W83}>H9Jb-9FB)rd8 zIfDh@?BipCb3`S6=Bb_vf-X%hWBqCNKg%McqLvW%T&n>0@BW7M$X4htjNRE6=`~Ja z-BeDjDtd9k=w@%PC$tSXK)Zi*(r`&Vc$;N&VOrtQZ0HMqvgk$g8}*PV#F$z2+wK4A zkr?_b(K!HoF@`|U<&oMDej9?QO5($9O~W%*!1WSf^AR3u zS9(sX55T*%B^q|x9q8Rg>pGntee3n;j4ujF>B0wxs;hJjUHgDYqj1;Jzkm3wO-k5^ zUjV+{bUfnZ-Y0f(STR%7Ib5=gC66GVtKVILv(oUmM+@&u>p}cLy9DjWBML&gInAPN zcUB73Pm-MGUisZ1m#_GMlIJ*B@gf$m*zy`wlZ1}-MU$)QB zdPGpuY-ff7E6WW5qsibM4VIgmf>hJXAtO_FEXFeEsh*uc-W+G%nu2tjKja`(*<#)vLi`=11>X-^LVEOM5R>ZLXpzj|ZFP zF5{U}*4TS$-At_((aMyP84q6ZAEH%bpRK11i&JdJD$8;eO?S^>C_Sls9 zeP7)eoW1WoYlEWXwaO9}bjX?;8Enc*^IFxcADA?US3e)$z27UjUFb2kRjUGoAfT^p zZB-2HPkJ@92_V0X=3iFTj?YTkHY}WZ@NT%XQ++c#POhyIv)_hl*Y}|h2C#Q~ga%J5 zsQxi8VAw#2AWfc*{ci9&$a6`azia*rF%ZmB48A{w3rCq=qMFCM1Euy`6I^%*&Gi9a zB84Sym_QxK-r;y;9NFvh2$?q5B^sSK(VNE6$9!Kd(|J6-W2rt=yhi-DT2jQen-#v zFk^VNPm#4!`?-t!sV8cdVL&8~V&5~S=QmDD&jgAJ+w1OhOdsQ`J=$3Letk0#)|4}x zEelm|e&!T3{Z`5waQrU4H{2GQq>4=0-q8R<3&Hj-yR#gfRN5mIU93w6*iI;F5tGBU zhLU~Gx;?DZ<*0_#SHidseQE`2U83Rng$+sRHO>_k3L|%^))lRD&z%_7&S$Z8uPhVv zv1Wx?_faGCber6`FHV0dquuGv4^CY{v`EoXv5+Mf%|(fnc;y)5cu@lXzR7qnXYcsf zEE)u`zo6Cnh_!0 zn%|#Z4ssDTmTayUt&;!Ck9lOjOu3oRc*0SwgM8j}LBe}a2P-2ky86*)Zw=!JvU~g} zCgtzBK0T>n0i@atZZ4;FwNqGPEU6tuc4Vs6e5!~?A(~taS-{kpc=WV)C1>5CtGrjuG z5x#XqDXPKEO`m;#d(GHzrQrLq;a89@r+SHwW|JQJBgev|dFn3%bU@0o=y!M`%=J!S z7pRz+&MF-UZw)ec6!+$r@r^ltT}+&L>h3x~mDdhtI-(`=M8PcHAF;LYl0Tu$vO8%rw-^Qr31z(Bz#M$dA`inV*JM{=ev z5&_Fpwsx(R(<{liW}scd_Pe2TKHX}^=H$5rZ_l8H(VnS`+QDnje2rMvRpk^ z0^tiN^4B7;K~yI6Ld}~vpdBIOFYEUYQj-xny1RCa->PR5S&QYq&6F*1O@*{-JVSA! zL2d=RxpTg~xNF)so^m5TDrdsFraun)?p+Le-K*3#KE z&1xX1EPcL8I|nKe)m-#$y+*3%F(rx<6UnBQN86*}!5Q$tA&{!|XJl|a0^-nI=5>Cy zpY!9p8PnMcGVA)$*5~yd&-mW}5bT5kSV)l{Wik%y*)v<%wpzaRPMc#sF5M!p=6oqL_*uA94|W=^Y|Pk?YEi2yQ}>rOw8>@!s*A6n%dHB??# zw>EMb#$BqMsW^V6-01GaV>IV<>`|HHHB)CN#}ngx?L~HzqIEFu-uhFI2_;H?&Rs*P z`qoZjFtu{AmJ?52A73+r?3rsJmAuN7rAE=4F22&i2oi7f5x;)D`HfT+aB+ojr77OcKB9!iG`FH&r4GO8PnR+DzBr%vg_{a=;N zj!&I3$%@9xgo&sd(4c}24`*^LyB$!Egm!mcnyqvX^o8$nbs3FN>xegc-Lb9tAqj0f zV=9tD?UcK|oz>^Mar^6y-%wScH7uTB6k#2}H}_Dxs>q|<1?#EpG(OvM>{MVn_nC`Uuc_#-oN4i||6md3 zZEdPTYvqCy%?DCn8;l502_^0zQ>&3G21{aW{)ANYfQiiZxz?;NvC6Er<+>Q#Ar~Qd zDp|`QY_#$z;4yyhxU1~SMKd|6x!TkBJ%?3!7WDYq4Z|x`=W8RV_u57Yg+6Xpp~)(obn_ey18CbhFxSPERt7(maX$nV)sF4T1v!M9?9^w~Ji#8hh-= zXnG6zc-AegGSNT)pjw{NkZN@3r)O^aCJB$dJTlH1sx7C~&3eBTHXe<0PO~1q*Mdvn zei&K@A+W?QzD5nhH`^87K5x8pC-0~a;jui6-l@hOsv#JOxFHo^H80qmQMxX6c^by9 zeUVTq?^nXct|VkPh_EQ8EPY*AwM{*sS*pL*5qh72##^m&)+`xya1{pegN_6O6MV4c z=eJPsx)2d?Q3Xcp^4Ux%r4xdm^|xyzAei3_yi9^x9K^~e@tsn6bZAh*#w-UOaK#6l zI1Wcp6BHg1sVU@DTida8CN4C>t4=y;ydHzUxV%3jc^;SRou8+h8@e3te3Hax$9ed? zJ)OWr>R?l@a^a75#DO@fl0>&lN~f~p2DM+hUW!Sr|5hC9 ze<_Xn<})u%RhB^veA$q2m)k)&?c` zAK>mI93a-nqZ$UH64ie7JDwOgMWr!-fHACT6Hd}A1Q|r@0l>w3lPXWHj9(uR7T`O( zHO~zBaD^aaU@hDNaN>d3$Sa%}6Ne1s14_Z*Pmujj7R z0<$@>VWs>#8BCyYeEXv`uHci@%k(=E3K;HtMS%MMUyuq^ zhR4XlBKv_Xe^nn}hVK%{Rk=KkQ)BN$(*85xAFQ9fFTEz4#l|ms`nn<>pt)oL2k_Ft zJL1KH-}yR$(I6F-$gG^$=;+a>x3W~}o$fuL_(c+0%xeG`%_PL8T3-G{Zeljg2kRPF z0!Vl8JujX_vCo)fZgxzh+QC4=p_mzv-~mJyfUO7}Z2F$-eZ;zqfQ0-T+5NE2M}2lp zU*yO|(ZKS z_DGdiRJ?O#ssGmzuOy7c%9^iIZbFGRXrO*OH|sdnROcE>O(jLAB-QCT&rb4?Hhu0) zHujP6xVfp>H_?OQw10Oy5IpeE^a9ADkJZplDYFYZx3;QGwayy|22oilcZ<{FVC_6k zvA9&efl2f7pS~odX_dXbN%I&EVN0L!ZZpr@u4cf8wS@Zq&A9><;aPA189AHL5BtP` zJeK|SW#L;dKr|qB)ng@S@u(cjR}LgS!2htC+xFvXyD~u7BaIr6O0&`#9~|@=_QaI9 ztkG@IOi+E77Wwhl{+h>o1}=c3!bH}EQog@E1p-2Prn+CYfASd_s61Ycux;w&`E9dr zVwv{o>%yUpuDiL3AgawYwSF;99LL)Q`oOK<+!S&t$PQ)V4J-a8mnqKu4fYAmO(&k-e7u5|b8-Lij5KfQM~QBEJy@z~4t;XRlEPI9Wq@y-m#q-MgG z20zc=Mh+{GqwDWQL{?|?KH$fl5+Wy%tU*kay7qbjX zofEhbBOig1C&aSBu^ic^J?g`sUdn1#+2ph9p5?P^*F9~ry{+N8 zSwAPd+fksR%Yc<*uk)3vDKz=uJ$~nq5GtO!7I*c3x?rpV-p{ga?U3dXL>Y=;wbPRH zTpF^n%L&>1O}>z#jrHCY7@yya`P=A4K)aBfOZ}nIRIm8B^$0Qe`lqOhrub*UM~@Nh zQ7-&}?e16)ZUO=}@HyLx>?zt@=t`=Cz|3)yk{5qx09s9)+k8&`90g>2*5k?E|^E@EYC%O>B&2s~MB zmu3!qXlB-z%0_sfHxh2$919s(CsB*wKdRN-X+rAN+ZD$4t;F>u`MT2x{gOlNtR&XW zsCRHWC0w?4FTD59X=5+i7H4|J1#%SJo?j=b5lah!Hrrp{XuiDAJ_cfogsM=jrOg+Q zs0IY4piW9XfbItP^AbeNQ%8$66wUfPw9jUzS@hj9J42&*6}4#(DWomus5f~!9t8|Uyiq@z)?6UE&*095aJnUxsO(7 zWaVAD;<~$E1%D-eg~x2`DjC8Mle>ADp^*1Am-qr^q zLO9&w=|or`bb-^B`I^xRN>BMMASB3*y>VVVBq6vo{6RE==v~^S^6oiM%?a{feUCRd6Rh(ydHI6=)CTJ$? zcSSNei^kI|lw^!;7V9^|)QZ*Ry?`7t2;tl}Am*96H(#dxRZN!560=I9Bq4fm833o* ze);Mxpbq+~09KpqZgsz2Ch5zXK7Zw;9FTFdvaG5QvCD504tR%t9I21d`G6kWcm{LN zJ#CD;Ej2Ec9-5idmHjmqREcZlZHR~%Q##)-EOkrtUOTh*v*Zv`gK1&!rhg&sW{PvZ zMd-1!-kz^&vX_p#d`%U5cQZp`#%1ACyTi{PEZ;Uqa=WO`53W~D?zJgi>vrg#F?8Rw za8>a%=bpE&0WvfqpR9jiS&26eWwj@Qm<>cicba6KxA$j4m^nD)soh-z9UD)`hTKq< zK%xibQ+pqFAcg@7PZ`H(vO2mawCnLDes6;Dz;vIa-nKt^av0fh|3*U?fEfc(vN#(j z4W&BdSfsZCS3y*A@#@cZ`9^~O4_j{?5LFw!i%LmJD+ovc0+;jiiF#Fy6%@xmD&)OndEMt0}8sl#yiD2E| zo-mX&3Mf$USuFDR8+xBf+nubwK0@+r)~^p6RP<_pA%?9saT}_>&X!9dQ9AtAu$=_m zyzJ8#T>e^2<~oX*hz*jUXSLhIljgBoM6XV>3g=2^k0}kBJY3yh*B7@;n3JwmNnK2* zevtT>+eh{%<^1}p{Mg%a`8PwvHxQY3XP1@jKyavUcT&M<+&-4BnafASB}FrM$4?@a z-f%pvnQ;rbVC!K3>w`-OTRL5_v%r#)pw&S;@(EbyuTW4}QdlUmxF?w<<$#>$ob8)ufB#m_9OxC{Op3*Lg!ZxDH9Vg6H`e)fn+KgOeaXewAa) z!)SY0VgI0?a_foZ0WN&DY^i-}nW!bOlO#gPj=iI4)^=5Y=pB#Y8T&1iS{UICp!WGl02H&aYa%Tnu6`Is4N8 zxK0XNyZKs>|7JQI17a*6%lbq|aue~wa%%M%J77T=8K!(h+UzfE5{CJlk1vHsONSiX z&0V)$ey~3%OCylG;RAkWzLy#yd#I5Dsg!LD<4 z5RiXLXze2~*-|-+<(!Vc((WLzqc=rUS+S#`sn140KxIb8u{osuwn%RsV2_ivw#KgR zqmLTcsZkwCh8vfQ5-PTiLb)5U zr=%iB$TLGFjz{%z)o7zDcnJv!s=1?kUyT!KPZNP(4h2+8;_nI$Lg)}{zNQ{T4-Ev8 zev6_F%$wSucN3%@@gM))VPflVnEo`Av~dO{}J2l1&ZroxqK5 z!)PB^dXd9ZR9M+0q5Bk^Q1*<_%MuN{VSaS z7un)`{hff+o27%w*+UN<0`F5y5$rz2CfSNZ*B?FZnTy}vf6Hpf9zw=eZruiNXd31n znsg7Yt{AlliSV)7ynB!((e@=3lN!pICENEROAh%5A3^b(CmtBvVv9JyZ$6o|U&iv6 zrlml2Wc_Rb$Ht_DRWajld7rhkh5X5> z&)^f?vEEFw2`I|+AV)&C`IeRJ{)Xcr9&(J$Vc`A=Mku-9>>-h7nf^$rxbOsE!@Aoy zxmuqOmYY5kEbUr1Pd2(hI63Xw#4a3`z*`-lxxyak+!jjIlej^O!1!nd#z!j=aO?Zv zE3b)INPXn)BDL-fyQ$&xDo0QYDzfvUMQ`@^J~;8SpXHm@=cWu6+Y-Wpt&ZdzoRzyB zL3Y$wOE1^UmsJ5yIs9P8CQmol$!F_x(SRA1d8MWKWSl7b@|&>Ac@h1m zJdj6h@}gJj>>EzO=ANb8pcgsg1U{{|;6l6S%2Og1ULQFOq!!+=n=$Bxp^J(pIiHs= zykpY5`Z#6F66vz-y8lN_X-Xjq3!z`H$FjsMViqB~{CRa5g&QZMId7tqIjJe*V3M`@ zX12jjzuyvfvp7qV+O{<+^*R^s?BeoUxekf6+(gQlsdqH@tc45Ual$hXpTo1(T0+Ap zc}oc#Ch-hJ4}Z$yt!MVo1Mw#Cy!m1L>Mm=g*Xm(-GnaUyS=F&`S z>ASl_L>N+#X0dwu8BT4LKDtn>=~1ovI%)T0EX})1+q^o?ddjQc`6boa4jcow=OZ#^ z^E~q|BzL{7+Is5GpYLJ4S}bn4A-onG3~7n8Je6}+DNG8^p@7;?n6$)d?#?}@;5t7<|!ivywJ%%vsSCJVbsQf?{9!THF zjp+xO>Cmj40k3sa)rLrl2(MomE2m$N$MqRl?pllJ@0mvR73BDhZzjsC$-6yIW&joR zQq`wr?Fi75=ZeQpM7ca_Eo92O?izb4gG{B>EZyJz=0G zx6oW)`^wSuQu=L)j_E~WvECWhLHg3x5Zs;M;u`RPqEJp&^_!b~#I|BhsBiHpkGnve zXQP5$w!K2#&dHO%Jf+c6#Cx%3;tS(8H8q{kE+QYb7$2Zh1!S>x zy9H@!YPZJBbrGANzoe@v`E}#ZGOi59N_3g$e~0&7_Z9F84;~|QDz>3R+uJq`RV6*C zJOu+Y0r)ggWNtSTl7VmgUyNsnSHg>*R$F9n`BY*^RGH(xuuye-hKx43IJz7Pl~ z%`Cy7W&xe|%~g##sMIF;6QtZdL_y`DK?5aK^28WXkAPbvC0U_3E6xq{P^vvh8;s4e z_H;KGlXpv+MBXNWX->RyZ+9_i{ixer;K@rP2iRLLLN|g!dlOTVHA~MXjxyQz&&<I#$~{`jRXN`&RM@@LfC;y*(b4Iz@8zw zX1H?)HRC~$RJzloQiEkKkKCM>ySLK7Xn_NiU`s<*(?UnL$gk$ZhbvGfU>Ril+*`*+ z`nhQoXhf%cZ$CbnJE$39NBSJNB=t8&m9sQul zcl>REHz2^TAi9Yv`X7XXRRI)8&yV{NVwu?Bs4^#Vj{WuK3H5<}yH-z=jEBkzpzQE7 z-QO60z3lrc;Pq0^oxtmFP|+bbB36IPK?I3{w_VPt6=V4X0j1S@f5?OuFp0*4gn;X| z7WMuPOM(te!e@F@AYf%b{XZObT5+(qu7HmXFJUuZ?uscl?!Zq2XWX;Al=v^=9S)XP z#R-&&zDRvxGu>+cR-b*1Q9h3Sh!rz&{a*rjzfCl=C^N9rYRvg5%mG`Hk)!!C|HJ7~ zDi=4m$^0jj{a?8K?ogcCLh)TSW6cZ+>4qIZ4*m0`n*;Z>32i3hRrGhRqqewgP)Gyg z%XqbQ5^(ScC}nW19Exs3$U*{;%4Fx+v*Ie(iU#MM*kF?OZeWGuS{)eJ9*vCsj?9o$ zfU&Q`Uo|~9RIPVzmN-nlQ~;a+F1H@7_0cV^K@Oa44s-!g2ttlr7^3b27>^ioT7N5~ z5Uzra!_x3`*2fK$3m7_C5fM*YW=>;`SyFoWv%(wr&qXm_p6y9?)3zNG_Sc{&iB;IS z%6FM65SUICd+BldoE)HJtM$GK3D5U9DetCuwvfZ7Dy^TXeQ#s|_(0W9`>XRElbL!) z)m%j?{_9f`Mi3Y{-ke~#fv-Tc8(pI8U!NI(6bKF|Tv(0b#mmm(Igq>UvDZeHxsjBp za{;Dg;)sM?pHcj{&Bxel;s{tL_a?FeEv4U91T)NLKBl=jt(A%^AaA!`bT+dsYR{4V1 z^cF7pBqoI0YMFj53Tm$1+}w97{{BCO);9Gq(x+QVQ|ifBK1<|De13Z0S>)K(`Kq>| zLC`}_bbEVS@ktJwG7Cap_sg*A$+i0mytCUZ+UjdcVX99PBg4rofAoxS!gQTliS&V) zygK=JzBxBtyoaR$I+Y)yAOq<>iGt4I$g!G;L=jK7zS%f*p%s?}Qj3f3KR|Bws zM?hv{ZXf?eVX}3IN`?X44LPhNsyS2dtcgq7qZvr$1asTy<)c!I)qy-Y`hiA}hsQT5 zx4KoeN{s~RPYzuzioCNXBu5~H%Us=c2kt!Lb+H{CWxkYY+@Xj(@Ili63HOL^Y2I%e zcA5$(_-v9H+(tCq^O#dzRI3av9`kr#3yoNgq(WAD5)|wB>y}5S5VP)M1nf0e` zecNd~U`JL@LC31`sV{RTjV`v5xlXVhwwf-eb#5?L({gWJ6;Gv1{PyJlN~kKxgoo>Z zkiEcGr5b`HLUj8@2WE-m2Jg9B`(bm6M=Bu-y%>7X9U_=Iy3WY;wnE$--0cCP(`{K1 zCeB(jZYMudY9#46nqUsYr179#-JNzA?uE=<9cvnT@yj`GPfPpw$8crLT2=IpL@Nmw zT{yOr=jH{{xUm#O00H$@GT`2@=h@tha~>}HOVNUGzN}Z#OkjFNRrDK*3k|%lct*Y} zP*%;lZ4^{u9l5R=rYj2VFyz)_RVamR_TonWP08kTzw(D}tNNyS) zfDfAKHBN*1BqWMcdK9dr6^cCDAf3n8aI^5?Yxd z6xrLGZ$EAnSn#Z*UPsggR3cJSND|=5>ihDn7}iAGW4n{$b)JWe3Z&^{>1Z43X3e^b ze<@+5qXQCt)vcRnsuih-A(%M-CQU4cR!lfy{TCy*_@d zZ6?aGp08e4M`K6;z7Dlo7FCU^ZWK49GDXvtsSJT!$IL$d6Cr)aAIPgJn`Wn)S*M0c z%M$p=no5>QfD>BtQ*zhUROcpv_3Y@8P;zd6b({3<`U~$oVq`9;USVCo-i!d{en#py z3R8?iF5a(|mW;f9<~Tqg>mp~DE6-l1PUYEC<|gA(#DfVl^g3(P-%JLC3nOHHLCN6Q zlyyUxkd?>sH=}m%#q3VEWB#CTS$4UKDI@bG0c%dO)V8&5>p5gptjL(Wt1z2+fJ6*=HWQeQOCoHfNl{>K_mE*?O zC?u|a9rWjcJPfTFmWn<(U7NB53K0*N^s0-_o)^epGsa;k1$j!XF_}P9`uI~+RCiE$ z`=W|l&NjLROuHVewKIF}vZ{YGT#;LQhac@# z$k$PAbN!J9XDE;NPgD%lhu_$nFL4}Oqj>y_4CiLWM;t`VeCOP6Jn7EDDhqJU)(qXw zV$`vZ6a}{m&3`g0B(hY@?_f*@PAr3RP5ESoE(TPNh$!--Sw`2@3Hvf78vidv1?s`Qea!K~z@z8tYDyk4zXvUP<%W z+bZjZno?iv7D%CzWUZ;Cz z(74QdIUZ}9-_^P3DkynEi111CE_RwQW#(DPeU%TuwkCgyvz?}krepm8vD2gL(b#fvtym)H)O5WAn?UQ0_M1p%x#krg`TpiJWOsId5{-!;3@# zEEua}9L6((Nz+(zJmFpgxHcmeGr1Q7X-3Mss}(kXm7lHX!kAM%UhA%=EN@R%6`a2xc&G35 zyILwEThjG66nbu`-Rf?Ku_K$*X4=<{m_JHmXy3=*~U~$&U{+WrfFTqaXCiwWm_8);_Bi=Gu||ivJ1HX1%1M+ zRvo;_g*CHo5_X0ouCkB3;(tvAmpTBRRRNXr^s5WjRXd-(F8<412E+qRgPe-J;|0J> z>x7kGP`kqfw`=8>8i&IL*8<6ViW%q@vFQ8b{V`b@IX}4v3q~n749@d|V*})jIvOe? z;xTWRfdn2Zx5?TVj=$}^nC9Qn@`V*dDG%Elf#P`JGhfVQY`YJA3{_(1wH5y=vP7J(8@ii$Eq@ zqRKYB_VlYfg0tgMuD+vylUAqhBbP9Y;t1hue*>v=Fs*r>CwY55&U`NoE|DCyyYy7W zME>N(k0V6(ONFqebIn^dwR4iD$#h}deM0g))|2Xi_S5b=!XslX0sQafU{owF68{Mj z4d1_lq%uyqrLm$&SC~VNgm1(>)+jCuO^qt?Pc^)S;yxh1< zv@B5rc1Ms?nRcD!YN1_oD3G%-P=iR}r|5NV8WYU>#=W5Mqw5Ff^_;l8tnlIkpAIVl zvk^FU@ym!HOCqxwqF>OYq8hvWNM%9gUH|`%L_Yf8Bk@60BCNIr%l_`o%9KrmhQ8DK zfGrZy{D8Xg^1W$RIW~Wg zeKQ~ZZ@zWpBc*;<%KQ|>vk}OG zqv7kqUE6>L%xMw!rc((}K+5?g9Ge000h0`Svv5rT>_H7f&!d>g5+|Id=Fd!sU5r4b zm&6{y5d!;I3UZDC{WPrUtul{6{5yA2`S&xdEWQVc$}I|*f5H zSqk(3c1!+7F?{s?8Fd*T+foZ7oagZ3+TxPVMmZCLOvAPJ+4bv@R3y%G)|zwLc4{~N zB#P=<{z3jOD<_vbHfrA-r&W2W-Lx(*%5>?e9~!tWo9YMp3*bIA`zy+V#6C0pwYVNh z!OD8M<(?CJ7%rf>w-U+bcbC+v{yst-X*M*3cYaw{B+Rg3E&C#ReyWB+9O%E52UIc7 zQ7_-z2hBTH2w#WQ6z75&r#az}$~t`!tA6Dqt;vB1LRU;`mrpwL!G2*}(PF1_UxUcv z>oBQz;1|VDx?UzqyZjc~nYMQzCmqV@uO^u`vuRo$4P|S+albmsfb5F-N4f=JZXf#* zKujd)MJ_hEGw5)LklSr>2C-Umo`<8`7dthEeMM8wGuC{Lajg$z_hnr6CjAniLRg=a zjomK-X^QJaU%}vVd;h28UZ9o08{*mAfF4<1FjITKN%7c!rRZVWbC23fOCerxIE7YX zE1YFv=3#4P8<9xouEKGj?{{ff#zxZx&43EyUA%J%4u2`c*HRODRsRv}r??yL%1JC| zW*w3UFfzfB&twLKISFpab@ThAcq=%cSMkftB^b}2{O}d`r<8IL^5<*4A~MD74yO|A ztX|>?p@*LC93c}ei;qc44hY%qHYoclGra$u1O?#yD;OG`G0HzV%ko#wmWumRm{Ht} z0%K49%5qqtvH{b~oT&aDcm4K#DOd#mv6d}CoqBY-hPzY$mzOByk-@!^rH-z2dLo1u zr=7W}qt{a{f!+m9Q;uX*!oOx{n(b@VKmAyFt=}-()1F10`HA+Y@JdD?HDa~;`dx#4 zC=s!fm(USH?M0u;9~#HQ!E)MNJAKEv6zBUl%RWuLrCTo-t&Lj}9Y+o1jhtnmzgos5 z_r)rg-`l)7@TtAY2_|HjAl zcYO6m{uCwSn4V`bzPc!+#aj#}ca!W3;iHM#UhU%klT<3G*_VMdGA4ANu!w^geZKK# z1WdK#ax@iYDZMiSAGO$uT&VwQs^=RzX5cLe%>Bt=XVTEj{ZD8F>Ni(AVfB#vbI=lS zqRF`&;=O`U@Nce9Vznuk3jswMIk9RmAac+zd{QWI9G?Y$e+ajo+H!^oAH%R6vB=`w zci_wg23mI|9*7)l+SlV`7SMKm;oEB1@j^E9Ar4P~JFK$3H3atDhyU-MdoF>eChj2S z@}_>f-V$*%HWFzV9cWCL(O$g-jJc7drhC96GOCE@G;Uw}u3zs!)VwZ?NFX+T$epgG z7sr%gU>c)w=v!y{c$9ORxRhj(L(R+9nPKA*_qbL(IrB0-LaP>2HR7;O5Q<^rM3QX} zym9rDY4^0$i5m^4aG`^q;kFZZab?Gp2)0Qw*58GG3FbXw3=86zf<*6e9sTG02(Bi| zr|2^i0oz`Uw{Ai&j8c<(s+baSU3a&8nIJi#%KcI+AtuIBe z=EHX*9|+(y1RbSWK!#d_pR%IjVml571b+#>b)uxw<-od zcYpI2MsWgKxZnyOwtsDmc7Ic-7F56+$#SnLRCd(BSIXRuR%Z4M3;XNANu`Gp(PpqO z{mfcFV;F$T5*+=)<;csB1nCsj2XS)PjEF|ee>YBo-V|38nR^F|b%74&-h19g@)x%e z>!bq;(N0prFG$SwRyWQr$!`2NG+GfLp7uD6ae;Xt0ovoE1WNzg%u(POntw{m)uhs0 zZpTmH4*56E#oI0NXgV(jOPv)oe;`=7`S3q!hYo(Bx^u4rJX|gSECtu}_?Q2zsV*{L zP3=<&>L{YWCiZzxhz#SK)w8Z$_zoHRlZF7Mu8v>#*T;~R!UV`9qIu*`C&@@p0{3fT z4hrf2mUcM;Jnt-m&Rq=sHO}n=fHD_ngCBDFykym>l=HmUdVbWt!IAewjR|w~f3yps z4tYK3MpsnZTYnG%Yih4J>9Y$Rs z1~LrqPWGezXEf1*8U?f&;UNzZE z7ixR;mhZo#JXE+aPfR>NF|2O{g=)E4W?zfm>51nQE(q%s(>=84OXf#LhK=bkwaH ze5U{`SU_fd%Hy;NySD@a&PCO8Mw(UoT{Fp-Z6R?mB8Gs1)l_%AtGx@TqKINCA>iua zzxBK8S#038XHnH%?4CCZzrb~cKFL-E6P1!l?Y)V@<8>Jy>a_BH!gMMhlp>Kk+8*vV zc|thULF2Bh@Ib<=uP*K>;duy$!0;13X4#>L`7VuDlQqjI)LFa+_pwmd44+K z{AnE*IPLa9I_^CdJuEd=55!Md&JfQ7z^hIeweXO>@Wy2*H#>s+QX3;RR85KhX-Mty zhN#$9lj7c_8bYgM70Z&UCdfeP3f&z7 zCDG7RMd5=U;V{ht;FX&st}}Eg3SOaB{la;M+~|yE%AershIc)kd0?h) z=&?s$RY2$fTpjJ%;m`BzRR~qLSm(nv8V#VSG;$U7dBg*XH1%DL+6ybnjsV`QVjZWi zxM^Kt%5Z*LP6>I)*CIF8wh&0e!M#JJ$M2Y1Wcs}UfVen}XN47t$|3&aNtMWaPvEMi zW}4S2|BdkVXk*`h-yX%c|h9>tIK$>i{qt5w@-&PBB%=>2@hp^W}P=iB}L^H zGSCPJ0dI?kXTpdRz$SgOvn|Jy`G19>3ml5_4th~;0%ttWy35~6`5P2blTWVFh-MUbCz5!8> zg54Aare-Bjv@HlgY|Wn0+O9ufV%DRdmcxI!`?0E>!qPG>t)RGx#qX4IMu_-U0el5g zIR%}knhsTpme5C)89_TtJ3<=+*3)O0py%9arzt;`(~-+kNo+t25kPRGGvhhdt5}wF zn&Lg1M_w@ot32Wr%m?U-!o`k((|Vm^13r~tLB(hP_wAs*F#BLJonl7vyW{gsdv=YF z*n7kfHi#!n23=K0IvR>wb3eJiWa8!dIK$%Vfz%V+j~^-=Ob z3a^GZ?XLy{cOcF<&R2c}BCm_8j@=-qHNHuNE>Pqc)X@`58Xh0TX|m|yfH0)AC!Cw;cJNkU@YZP3U9_lDKg81! z*hk2Mk{o=|#g~SfIJqRLBvy|qPDC)xsyJH-LQpvBPFai9fZ+1#f#E4!m$hL< zwzm4ZR%a38hiyNUMzSJiSErIdNeV;j^AffZL1=UFRgLE}&gff>N=O2ZK>R0Au96mY z;qMXfy3ik~p4!R=rEs!5GvRO_OWD=FXO^OdJ%lxuHJPbTWQ0$d3|)S%E0RcF%6N$C z&DeI}DK_zgnz$MD>nrYu%i89t^HR7I&U1bv0hHGHvo1@f+G3}^d8xK9{tO68hLOMR zk!ZP|>n)BXXN%E+UWGaI0kgk<~8;c&5^i~gXiRE7n)$doM z;Bxwz^5-txobpcp5DrzgE?-}P?9Ou_bl>Z0 z>C}Gop`I%hU}d<~Pg2rAKRs0~lm+^U5&d<7@(wL7dQH@4-*GmgitkwDfhN;pGU1ov zGfo?hiRkr$Q-+HyG_HtX?LWLv_H>PV9TC9gCRuYroe6^Coo$mt{xG0o=?SEIuGrH3 z*`a-=+GT2apDvzG)Ieg0PN0+J-D4HiPo~QCr(Z3PLPbdpemPHZq_~c@?TZa-&)6MQ zK@cz+hnT-TcO08hrg9lrKvM20G&BF;o9Vy1^7p*&-}c!MeIga?m3!!~W0awldu9+C z+jTLwi=r{c7qb=NGZ!?pxL2cdF9&kTw+2Lh2MYWSqYpa$$#?NpzGV2ECOXXUuGe*$ zF|H!v#rdD;{z2{)0}1PMPLS0@moa8y{l)iK*QP2Zk)9ndNXgoXp+{PEkX>`qHg{%Z zRCg!+I*N*_qzOrHek=&1T6AM*IstGh47k~B1r*a(;W4{Duh~@0lf4SVjl*8oS>gS9 zaJ@~Y=aO@p-#@hdX!!ex)JxlEG6-fMD)5LfS{47ZO&P&s-epRO!5_yW0>33+G0jD(Dzu)## zWCZAus1o>-(XEzJGE{Q}?i`vWUFkN+(KaT1US2F zRZDmS#O}*)hH5>L(y4_g*;JVM4%lXpEsCs|fXj831r#Nbm`@51RZRxmye&%5wb6Vj6QQ)*5`*Mi?>Z%b~Dyx&8Xuy>4@+V$o+q}^nkUVlG`j>;Gi(1+$`jP&MPv4d;~bu_pi@iVArFRLDo92;D_-g5X!pspsaYMEmoTJ;q;BH4G{J)7huu*PS)0n&7E=J^) z8Ox5Cj9&MeTFRLAV^kkBoyEEf*Imce%pDs}eBN&e{WbDdS8c2EU|Rq^<4HUpG?jwI z806k4nKrVCxw7lFVDU^Eo43z6&nZEl4#QL!OxDpgV>BPX$rx*E|Nmc5ahNCGtN7rV z!_vXukLYTeudXdgC*)apohq6eJvvtIZQefTT`{QrRWAbXeLa<#n*wGCfdT zo>oKxHXSL)$_0f(3iKadjNyqF=e|iO6mhw+=CK-|U;Cx?*9H(n|8nbO52ok7r&mt6 zbulS5{kJ8hzT~M@@G-n%iYof6l%&#?Z~b>13nU%t-#AXpy%!Cy{`0Ew!JF~#3jxdU z$6avAtDJrBU!R!42)ZpQKRhF1LA$+t7x{tycHe%&(gG58@@evY8Sn^fo}F7G3%m!$ zdy8pUw@w(+N8VzdPte}%ftv|IyxtDHK5ja4fR?JKW~e7;J7OUA&@GJn-Ykk}W{C>M)en#2ck z`wN9^isxUPL!A%2c0~AkQ<$g}z)Dq)a!(>}hXc0CBd|Y+X=oE=(Yx@#r5z>br2Kz9 zI0NgK!r^PB1T^ODgpz;U1!KEylz#`@`ShWQQxw*HVsI&usi6OFesFFlQdl$d7WNo5=XO!n zBZ%1ky7q)94WLg`ZP7zPVk5WU(NWDE6x!QaRi^?2)ZqKG`J71Z7Ts~!%W(h$z1`5l zU@hgLZ$1zau~^=29=FpELSP7>5hHl50Llj|5LXm?j+!F}bAJK-F2HA6-(K|_BA<=6ghQ>Jkm%nn+wjy}u^wXCTCd_tG`rf~Ljqa@Vh zAA$*>r`up9F=9LbEIe4P&&p|odC>xpoMRuS%=zuKT-XMa;=Q7lU;C2j+E?fK(C*lL zTeusfu`w#13up^-;QsJI(?GUoOn(K4H z!q*cqjvt1kiWrRj{P{5LNjbu20+}STOSI|=CT;7g^L>l88d-koVoj zen^S=$OoG09o>{gC1yR!9pR61NZFpyK=k{nHM{g>{@;C&KJH&{>US5p;W#?_;nN$c zvn9Es^-6E9fr*cKIS666Th~1^&y?o#IKPn|dg5YkL1$IjYJxNcXXkh2J+;@CYtNnE zPNYGZxA|)h*NQL7f)Cj`3-^~i-2}5A`I2_(Etzv22VU(?@5p|afXWe6lNkyTST<)0>IuDZ$M$4f6#?SpU~SNeUfCDxev)$9XkM0p zSx(FguBmRzsVHLlevUft?Cr}l^P{=o=*#>Ej2wm%cxk%<#Dl0JOI{^@gV-)1(AvYH zb1br;jPl*C>?++F)!q$0<>=NJlj-EdhcnN3Eeg+TKIFZPTtw>z zq1t!8_-l*Qj=ms^i^PkC6uphn{&AA4sK(H$(3(VnU2Pd+wPs20b@IbFzUR{o{GCrt zzWvtdQQqm|dmlr6B6!FD-hlsz-c4S}< zC4?q+THqZ2T$PFrS3F{UU{*WXZQ*neX}g{!xYaBn7;HK#UiIMXnxcz~>t2MYqxVXK zTv^`ICrnO>e9`)WJq=QqyAkrFlhI#mx!Bi#^-b4t}Lc zOu{8`%c4Auguri;Z(PxLQb#M><-f=Ln ze-b$$>2>-uPRo#F2VR>$ssfHOn;i zT!P_mbF?`Pt&di!#TOUEXRN~X+YmXUYD!m^`NmR9G7R5s1(!9!xvV)vnNLxaMyD(> z@-?;oaTwNp6d)}j4rh*gd@qvfJ~0|x47c86oJJ?*&YkLfWyv(MQ^PLFBsl$dAC#bCJ0-n`_>Y6wuCW6(n3Vg&d+~`Rj<|qqPa+#vlEsYjmx=b4&$}K>qVwB-n9^0UJT@OM5D8=*a2stg$yX(5*cbcsOetoXeb*xCEp zDp?hJZ>H<}DESnBofQ>Mg(q_B_DdvTai84mhq!5(QO$h~r07v*sP8P{vaC()K}{O) zjyIiC3vwP0tu5wp?5sPFRB3LLK72U2OXZc*&Tt6eWcWBU1K z=|@UD9e%EQat!Y1JHJQMT`N44`rot-jz0xEA;Ho1^5G*XUp<1BTTatgS9!$X#AeLsT+w2n4~|@OMcdnl;aI;DF!Gr zIM8Oke>aL-61#^+0I8^NY{E?Ny_a3!k(v7mAxaCqI~pfIn-Y}l8|g?};L;m5!P)bW zoq-XvB(EkWFn%JlpOFtG>@`e5_fZ%D&+pd}K7P~EeRaB+atm=1SS8UDmZ3S$qf`VB zoJ%Iv8iqU=p&Z*^$vfy4@801fB7~f-8{Z|u&OUujm%ROF^z*d{6ZhOn7aH#5(obW8 zU>W;jsRWd=Gl@UNj~~*|5eWEM>f=pfbqeUw55|A|;&R7@*NidI<6@pk^#`sEHHR6Ny%t$X~o@F#_|om*tH8L z*xBZI-k{WO{Au35e?GB%e*9`+t@=TlbE69Xafb^28Tu{S$tN=5ig;s>WKrjDD9>+vciq?ODak8eP5_^lggx;C9LASAtd61*OMmX>`r* zom;7-l`Nb+$H)6(S60m;lOm0KwTes+cV+O?6vms{xVl+{?iuOL<2a>zFxTrxS-rD7 z*ZDsVxGF7T?YMcWl4$I6jHyf!Q*j~L7jmu($I*D(t}QeQM^YreZQM)CP`Gp`z$+&X zUAZbKV+~@opY#AO2VKlVKVb-izEy=4rK4^Hbv?2(OZHji5tAe=il8RcZy9sM62Br zP`C^1)T)=~#m_tB0URRdn+i8uvkF_RCRo@e4=YZX>MltBxH)L{r$V}UyVsb+{m<}R zD+b`&_DA1@7*kf=h??(jj388setv+rh^f(u2 zHu_@Fgx2jD-FDlyKmE4k<0$OOk#!Iyb+ zb|hQbdsG|Ln*7g$o8TjpMLL!@2g1!4ig;-W!sMSOKGrVL(ji!=6BWw;FDa}_hgcIZ zJG*9}JFF%i8qzmwij?UlxEAzQ8sS!-)m$SBAy`JK;?}CS`;m6gMt1lr&QeUQ>i2|= z1Xa#pv4qv7*ZsXEe1K}pbvsv>S=>zX`G5Mcj8+lA9GZXN?6_e|f(#3|*BdWvKV~0& zkR=TQuikR!SO z`260I-HyeL^WQLOCtQE$`f}Iio(a+yy@~m%-Saqc9(Rl&din}`-?F3FG<<~v=_D$C5f@vtzS3)GWN!h&0Vo1b}aIGrc zUAM3Pd_fn(ZRGoMaWk%oR%TZ{ilK~Zm%@8;Ww{ut|ti`GaF~6U!VxRv|=#n zxwvEG@KECF!uJf~R}zr8S;(Mg+62?jzI*byz|wPAd|b5fY7jrsg6EfFY{KS}DHCOs z%w4Pu&Q1j}Tya>fArBY-z6MQx#zvrD^1q^ zE2&5Fj!c8QSWt)jG97^p4$0%LwiU_d<1bi^)q|LPQNA>%YKsxuaXp()r_s;df%x{BCh0=#8MCiv zeP<>rR<;O6Y4nz~3yd5BsN%lziNgut(jffnUEZCZN1}e8y{Vsg)@?9&|9G`5v z$cdTel-EJn8xe%mB`_rhRsMHL13dH7^agCluhPK=VcS8}EP{$=8-$r`EaVb+&IDF< zk7Dn3o4wri^V;hdO~J6hiaP$*6Ponrn1IK1Wi9+;>CZ^I#W>xidesjSR5Ft^v6`n? zWPEOYkE)7JlLH@fO9l(TKYLJ;ePy|N6|FqA=9eV*xpz%;G`HqBD)45vZ!TbG9$WOr zi#VJ!NV$a#&FeIYwW891tn`Zz?XR3x)?{vDA{khV)r^kZ*;W<1@(nRaRLmy|RCXrv zy~(VN_)&HDdLGl`MzYobXxpEn|`!Yl;Ou=3Vq zVP&+ZZ|NGg1KAmH2bD@)NJLhPO!cKWv3c|qQw#LPg7;=$QPaOkwl*%gz|v&hT&!#8 zA^l&}y=7ZmOYkom2rhxaHMqMckRZe0lEEEC^ft5j$qlhB7qr32EQ zmtU9&0?e@)aKD*^c%;;}`_y^$$}Qgk0ipOY=ifj39ri1}OgH`gr#|jB!2$?}0a74I zg}#MYx*BT>Iu)T*^;s`j>2SL_r9$_|pO%p~o3S1;PDMcd?{n1r-GNiln{Q^Mq^8mV z`wxC9>*U^xzjev+H12uyLIanSaxq|OHHrRGhDuSatH-^t03{FzrJlk3m?nBTRPV6k ztAC)tgeyVx5}lAQ;(7uFE%{iW0G!_nG4f{?e9WB&TsMUt0p%$CMYEU6Kj=^+=pxWDpx zkm61C0`Aa9JnTLBFx9_aSi8ZDUoD)>$I3j^ZQ04rvDhg2xFX2^mM zTOw86)8qFl{k&49q5K(u~@06^+DwgLyGD08k;J`t_ zPqm+@!UxCOVMh5qGPuXYpNrA|)HY10_wKK6+5<)Id-`S3--PLkCfq_lF(*`CE*b9q zJ&m_?yaa#gF5VM_AFwQ6pyx#OecGLT-!kqR)e`x^wGrzowCT|g;1257F+;=qw5h?X zM>4?;#nTNy+{b}xH?v*brT=^Ech`myEvbGLa-K2PVz0p=hH?HOK<3T;$G-qpYo=;0<&kmVxafi+){PC3S2@>D)m9#GJn3(GAH}H0 z_jwSTff}2UwK1;4bL-;dZw8|Gql<*Fysr%At$hWb=*mC6_VtY0c}VJc#*m}v?Q0$P ztQ$*#JdH4jU}T5Z1jVzIaDhI1Ld z=lFUaMseF>Bmy!;d$U9BhW{-4;QHu_2-e&o#GQjJ`pj2nw~wB5`0z0=N8-(KsdNe` zmxh|e{A`HG*!&~WTaUP;V{g<|!EJ^cYH%RqhR3e~ilDw#s?8O&Trv}SP|P2qZOw)! zsOI>MA!NArYB7FvNznI)!`nj0%!$`1D1Qc8oK1d=3d#md;v{c>EA55w#RQ2lU}i_e zfmx}6FAXo3tASIG4rkzFX_}vmz9DKScEMRja?lx!FNO)X6{@Gez#MrLWDtcSIx*mQ zHYOWkowO%AdX7S!PXFuNDl&jDS#LzXrzzOf)W2J7H3}@vz{F1J273z?OO_037(4M} z!kcNcU{m{Aqm-@rT+C&{v4- z`^%IK#|@RHKWXu1Y;AXApNT9-^xiXjBl!F2tYi_5IzMCLbH9vQCo1X-Iuh&}V-05P z8DUGAH<#aXG`*g5^mX(Id2PxE$E42x__RZ9TaxhWoq~Z&xWjE|!H3q^-AA&#o~n)` zV?c2RiiqcZLPXvs{c_R=BO9_t9U-qoYKA}`h$0@Dg+-+ld>GgI3%ZFIg_V|PF6F*|{XX+JjE-#954+Vr zgPOqTG7)I`MSq%=jiI~#(>nT3WE5RE^K!$W<8D~rP`SPSfDujFTf1&cSIf`j$u1uw zJU?eO60HT~Jg7cu#y{lQu-=o{sg((|9W2R&?wjxBZr*NP_Vz(-vd5>7x(n8=1EvA2 z&De=wbe$fH`sZsgc}F`D8DsH=(U&%31nKRs=eWhHxs;qxZl*#_>yPh{l>IHA{QKJvPi6 zy8b|IWa8ic6mzU2a#od-|Ci3wOTxv+1R{z9R0r>xxjT5u2wHQ`9Aw@8P_h%0toLm( zzX%yDABIm1w|&tf=U(^{pJu7d4xuvMe5i| zc3+^-7)Ez6RW20M((N*ePRL7IE(m!}=D?|LaFK{?1|hp}v8aHHd0(y->v4iBEl10i zf+XDUdG?{7iLp2#HB~p$1#kUR)4x`)QG!2+SyMX@lFYc7`&(+wt05W%rUkg_%52eg zq}sAvM^L$wE*jvJ=cyxXwA9K2_@m6*oya~?Q7<9S#8F{RdhXK$KqbEQ{By0%w%rD!rz|R zzedg;cD{$~DIgdgCmL5yI)~oH&iCK#%1UrQ8Zk=s#;7%Q0moBbWBOmYc??y@Cbr=>va zw?NxRaJyGGpq9#d+K!4iRoD3Xmc~A(-3&nw#l<$=+4l@~*2`c|iPgds3ZTv9Wbu&ypI!jMI`nY^`;}Qf zB;zNlb>$0~KOUVah{w0m=b0Sto^lE;USdCGBd{><*2(cYbQwWi4FVT@>Jn7;Vua($ z=b<_3CiyoS+++A@Q8gz+X(;veLJb~7_E#X2eI{=CDWg-L) z1=3J3F>L?Naro=TOuXW3f?MAQCliA^;O*)B($uND&$`L3~iASP4>; z2V%!4=C$on9`qtQ(WD3m5~*S}8b{kU@|Y1~`gu_s_S|qw04hM11+&Dn-ht7Sk+38Y z1w4z@n75mwo;{X8D*rn1i2_;k^BQ6lOccn?djAei6QZFi?3GA~)@ymcGH&Jjy~5)Y z^k~*W2pT5{cwd=D=QnGnSR01%Ddri1~=;3EJs z>>&Ui05GuV|33-q0AndMWdDFFdEP6?0iHk|%Yg)caZ07BqomQ1x&1P4C+h*-1YQI3v=k9cgCoko z(EnTd7E<8pNLaKyj`>aoP5GbNMoO%l|IlA_WaYwZs1^u>c!DHqnYW?x-z1Q~Ji#ph z2m|gA5e%1J6VNy0#M4Lt7Oh2$g)^|ykW~cbQt~bT0pw?ZfSkx*DKnw!AhG0?52$GW zW&(I31+`Bn4hqrZu>Z{TBic1Ach| zj`#&|zzl3fagd|qf5r0Fall+D37)F4p_T)g4W1kcI8Ohi4+sNOQYSW&X0MyL()aU*2W_}^*&pV`Yv|98>x z3jfp@6Nnv|#Db2NTm9c&VMa0(ZC(U|6uA+y>wtTmPgki90PJD{)gOSL#osJQy-$BW zUp?|){>J~63;~USQ!)P@d9nf7d>7fST2SkpExhqA>c7cKqXhsAUWbQgBc&o&v<#dE z6?CWXC3L;7{|2GiR$DTRO#wd*xq)75OvP#3TOs^je%##~fjAnXs3}8aP=%L(T{Ivw&EUf~o~$_swqdtZemU2MdX9o81M40%oS);L#Ya+TGY9HkG!^ zDTjU*R?`^x+QWX&YE&%Ymvj!bjZnDjA7ts+4_Jp!sVzzTjFWlH#G$-H53E2 z^U%K|Pb~a+8c7=tWlMf((d((b?d zGNbP4)N(TxSx@8);eO2R|5~kE$w#ad>Rl~Y+C+13 zailfhr0?+TR|sQWb()CR0Qc-_uCsRTb%sZ+m{FYNxy zJW3+C4gdWeTdFFRxk-&Ho6?L~^bbq(O5tB2ll+U)_jQFw$OVZ6R5<9iQ3TN2r~hO% zbD?>7`rGaG`DZwB9$mEq~`@dg&ma?wGRb~L(xXK#t!Sr*h4M;%&MexNzkGQC> zUrHUEH{{NIKbVyWLpm(>t5CBcPKX8BTH zJpjTjKh{AN_)i>z4mh~)vNVA%vM?mS)M0{3-amB`q+1C(#8#%_i{5#hf@FW^QnfF=XyPq;Z^V z7P5-#3}JcpH5D_H7n{Bk?euN&p7r`Brk+#gT==KiGWWTGj7_zfXa z9Rm=3eY(m^oI(KZ{#`U^koze_*o@;s?9wFo$Gd(ZW2b-k`L@tFefjBxd;>2EY1N02 zzRt7Ss%R{2bksv>L?Jmi(1xd2UiDySDhVoSex&0wxq)DW^Sv2nrrnJL`7G?Tt3h&}>PK^X zsXdS@av}+_v4n&+>{bcXiCv;Om#CR13TnSBPFR=cA08sbjdm6J0lx^U57N*#9kzDYj{vuW{E<~PJ?aY5-~Y3y z4o2)i@S4xFJQcuMEtRln+WKJ9;LRemcI;G93PDJ~8}cvH^k64hQ4%-^)=?Cv|Hi}? zWR`XBL?z-;5}YUK*W!0L-`{et@vzRiyxEaaDUI5PRN!AFZ4gzy1GwNC)lB==h9S}&Wt^oLphrTq4mxau)4jmu$F zt`}*)5u{uihjCfdN;Zh1SbqESn^gnq6$=(_;~p`E7tZa{Ky)a}l`VTGAtk}Is#m;M zOYP5B06}?=E8ctV<=9;-4q*)ZnTejd3-?*}ad_<_--)k^Y?$yP2aDt>;0q2}s>lE9 zq$Ei7C}CZ#)YO(->jym-@&CrydhwAV!69_(>LZ51!p-2aoof!;gbl~oONJ=uRuaC) z5SGZ4S`Q5VqkFSepU7GUJDI0}o)6~N;my}ei8HiW$MEeP`6TL`@v-=~_7APAAA)%7 zElYfETZgIPo}(=2LJLL{vQHHGh=#~7zMp&hm69884Wz-T6a_({dw|nyV*IBnA4{7C zFu~s%fg?W7XoSt{^UeuYYFvfj&lCO=o%8fjiC_hVaf;bUS z+p#2DM1<}6GFn|5*+j3il6q-4JtbOt1(*+Q@Rxl;nBzuZA-r3b+aA)st{^H)c@oWz z(zIhD>hTNW1$t5Dp)|s%+0M5CvtI1If11w1T z=aU!EkGR2YKN*}itDKBzW2w)d{<_lZJ+;WfYkw#7r#SKHbHN(%%MlFTV6>UEe%@}Q z>m)U&2K--MM0Zu2JtZ%GIXM<5 zCYtBU=1Bgx?Zp?543~(DhIm9OUj_5BbD6I?TFhJNau7^aS2#@xmFQ$|`_-~Cn(%Yj zPFK9V1<;2NH(|mu(C>7^i2d4mIc(?ZNfQ3EIXbc34bnx&iEoIr6raX}ZY=>(uje{# zQDe0NM2x2NDd;7xj`UVewAtyR$M~*S&zxrTKWrGabel%q%n9P+8hYoBR0S#6=me8A zMV=N2s`v!~jzzDbFMVy2ttLhBoSvvhhoz_)Bjo9p>)UGex*Y>T&ui!Qo-T{Sbl2*S zjNdxq2)xK42Y6@7K0WY1!_LVKM;aT;KxytatMd?sAi?!XdBZUdkG zqlf5dD=O+cS0#Dx*2@~jO9K5{-_Ag~l_WQZJohcLJLMJo_(ofqxd-d^*VA>Ecxmcj zJF;&v6z0E$Wo+dnC`tvnTDEasg#m*s)bQ->-uFK2=J ztTlI5cQ<-@&-2Cvw-!3>iCZo9S^ZeaWA{{}dIEUn^+avFK*ttiCE;^pf~L72i54}m zJpCE^6q#&i=UfODv&kMp2)&8&KI%Qui~nE+TszWqTI@O)U5YB$6+M6FalD4ua2K@M zb)TFhe{cNVKpibABoK`=6#7Kj*6el%inMAWLoymVsUVN! z`>gB&p?49gOuT@mrSC4Bd3^)1lY&hGnCafwFJZ;&y;AfDjQ$*oR(Slp ziO@e9&b|^2@4~=*U_l)Pa`d|>5L#@&*$w#1B*dYbtjqL^ietTxCFm^{i>vd~SwWS5h7H7WYKVt(VkBNmGb|IhZnURg1`^OV0;$ofcbD-H)1VMz(X%Fl=dybah{Z^Uf(9%iGU_rG4PzUxd_ucbJb_sd zBEws049h~csw>QG6iF0}=d6n&R$ZaDicBlFO4M7Q`Lg(|{0g|`g`c-7%+@;CwgR^e z)wQo{m#LO+BjW3o0UIF<}I*;Jdjl_{4uR!6TcMWgTL2QAN;P}-J1_O1>ARhQ z@wqRBX_Hzk+%9>Ul{$iEOng*DyP0)X8`-Az#fG(gUoF-DE*YRfv$lx+rZJ=Sj2>{0 z`kMmRPWI3z5>m54ZC{2KsFblh-Miu9#)XC1RiD$dp8vXPMdWi-fB)m+-m2bC{=CeV zlurZb6sL+2D9cK%Mwm^(|fB|K{tw0#56 zKm5=0Mk1c_oD;&#r~`y7sk2*TTwB>%H!Dpv#5JYMIck&@0-~0aO3oJR3=X#;0f<~t zd*${-$B-dk9 zPc#N4G^E4-*N7jxQKsX7YVrcFJLz77X6Arh#)x^`u{f50cM8-QYxxcRr|*XJ#s!)} zA1@f**j>%r$Y6Pv(0$1xv{g)x{e$HhA^h%Q0sO}LbZvB{z}7p)A|N!4Ogi3#^m*AK z`;&fR7?y}Gx|qYJwutjGgeNIK>+@VT>26&4|7^x7tk8;C!wJSetgt@rfkKIQW33v0 z1IXaeEp*O(+Yg;d@%BUtzg*yGlsU~{s4UknfQ$k;jXnE)h^%Jd|aX7OH|+tgr{#Tttt2flp%^OZ{7+4NN{w0@dXz zWE#`r)v)&_^S!%a1blP-Ydlo*oq^D_E25Qkt9DqR-|ZC+jsZ#pPuG^KAl7vdH^@!AKAo{f(ob%U~=8*!v0saBU z(=w{)ZZ;Uj{~Sd|U{l$Nsw%xNmLKPX(2T;N?Fd(!XbaDx z3ifzhy-L-Y7Li^m(0Y>$i*4}tQziH+sQN%yP#ryU3o}Oq=vn@?Aj*A4)3PlG_^d`6 z)+p&t`Va%apJJL#=l8`WE74SIl8b`XU|;K4v67QQTw@cav+UU7d@q zc_}Z3V`GOxLMk0DBidPw#lxg6;HCf45AgrR?6>vrwD$9aR-{A!qcDYnax?QYAlg`j z&M3}bvk|*>JTGTD3vH8&iK;k3PS`@{dNcLL)3sGWxc{|0nY0Fq_M;Umm_XRIr{cT|dTClzWrRTx*)b|&k8tov@uTovT} z7qpPIeSrRqC_MUf`Ef5)%_xa*_Oa0W*v!``C8v?9nX8|*>9gf;!mvF>pR~Fhh%-B9 zskoGV2Urqlq>mt#R#K{tGS`l)W+=@zOh#M8P1}L=HRMcy2~x?X60~rcD~`Z=#;>%- znEs_h7}`Q;$Icrae_%Eo5l>4RB@`bl@e08#l6MX)2bC6?CX)gkF7^%lqe@(cB zW1&B#tVu@jbFU_9kx_mnDOvU1#QIS|`Rb;sO#I33?{a7#V1I<{qgxDG>_QEhYY^nq zX9E`4YAp~O%%u7MuoVbB2kO;Y}e8|uYlf=nVqNUr@q&Co~D7bHLkl9WP1J~zt9CZtyx&uBqL-5sxjhTcWne+ z+iRZdy;e0~{9th9;q&rVpIVPlYwCaTDhvv7zzirOpMoR^`R+4rnC{H+87k7g$@O?g z8(Uj7$-L}4J#Qp!O3PL6MfYAyjNKDmsUkiw1Zys4?X6EMv8pd7HLRPtfr~w<@=wlR zce}bOc=lsQtj8Ak!KZ$=Y(hApoNkKN0{_VtSh*ynWHKdEaY5TZIPrLyNW$#PoUF;jv<{c%gJ-2FkLc4}A_$PoVkU1(RL#!hy9 z72iu=UXXgUh~PPqsYRs-eDm0~Z`FV}Z6aw8c9WG?El`E??pRTob1ya?SWcaJJJQcf zYmy&k4E^0wPtI0(PtL^Ewg(;2vE_i5RQp-3GDetBW#wy@8kvW=bIElUOO^eOPuuYJMKLog}G z{xL1kD0OH|;O`y(NQ`#)+7~q$IgjK;2biU$E&vJQ3Sx_t#r3llc>WZLZbTAXQ;^WLV~ zsitS|T3gq`7x&ot8pIX&UIFFAyT&^F6VcsYwrO>g%kW*8mocIP66a{s-_d*DX~Vln zm-8x%SaT-qT4~-|>UwXI?lDid)y6?|;VM;@E3Lv?W(pmIe?^NsSJ8hvG}n$bJxiSB zPGsPR_vz}A*jQ7c z-i^Pi>_leP9xVGq2@?p-Z3(6r%3@}TYSqeu{GjK1Vd~s9jokICuxU-6SlqCTU-nD` zp{ms8Q+$%Bb0+FU`63j|%LYj!GcFhPS?8!VM&uNTNl}e>8{ZbgghhcGiE|v+6mz37 zIg4{&rOB0bwGJkuEEPK|nNOrVHH~a@1w`R z$ds*0u#E0qz!cvD=?wV;HUhjCkQcK-=JEJJvamgcQzPjjgW@ic;XHIZ4siCf= zLLNE?8N}-CC`~t(OVZh%lD!7#V?H@))%P0PXejv&8Aps)jdC5%bRfB*Zl;m(1r??I zcx}O==sE7VC@@@nbQv>@WOQa3ZH=S7mQl*pw`H!>o|fW8bL1n;k*B~7;3O!!;Y!Ih zabC>JN@I(gm18+YUwRu5g6)WH5kb5}gO{aFo8+6K5*Vy0DF_V`s3x&-<#tKTK?zZt z>UwScj~B6t(xEbZ4PCcB4RRdx16ySz@(fPY->I!};o?${zlT?T+^pkhVP&U+XRI8O zWp>L)d=jX!ZBhFH-m)I=fM^nlOra}sf`!rzHIr3Un{>M#`wGvZh3JW3m0{T_muL}9 zQzJ|AmqF|om6>qyxkpCSTn=F+25&S>gf$jn_WPFF@v8zRn!LzTOCwxTdI^B zf5uiWm~|@FXS7*Ad2$HEOM7IwoD~DbA>i}J%DNe?^I;+~2CO#L`r4#w+AW5ga}+ww zzteRi+Bl?!zr*tNEcw`V!+pc2AF4MDcJr`wdej2yeXdCpYGl9th6VAZT#VlkpFUrV-J{vsDM+FBCi!Z6T1gO2fv z3z?765ZdxRyPc7FW@<(-_QWlA%4Ar8S6-)5Er)Hh;6R?`&GCu$F_tUkq4-tfJlpgN z{joi26?P7DYyIWz}lbx{$tXI)h?DuWRES3nrQ_mSaUUlZ$ugZXu?ErV=>b&kKPL^ z>U4{IT17kTJL0a6VdVtZVd=Pe&sg9)y%H=K3skHKZ!;ksEIPiHSj7V%o zm^mVgLPyBc%6K%1s1z0vV`2QPO`NT zL>h&TEJ=k?nUkuGU6byyS0()tvF8ho(lC!mWxyAoz(fgZV*&(&YHiZ36RI1I{$ZAE z1wx!&s++L)F`*1v;uLKQ?b&1yWg|*MXtaR@Mn`H3>i81OUOh|&{4|}@hnuUx%DhNBLVKa-fvJgYOx1J-4Qd}6ZOSK$X1=Vz-k$=|SBK^>D%Y*Zt){G}Q zUTu3}E$(EB8C%`EhwEmycDtb5Um-2kgAOo*=C9PcJqK6OZ9a&R=^T)ym&zyXIqqxC zEVe;}*bVC_+Qx_|YMS~%*brtr%^C^$X$qZti43sw(E5D&I%&MR#W0bWsyTFtP)Rcv zquR?__xH=JXkE`M(dMr`)v^P!fXy-j^t9{sDe9^GNS=XqvXOQI)$^G6$tpTpiM&*3 z`XVem2Ei4_!mWEKPJ7Xe#z-VwwL;NoJp#LMF7q>0LK7!zYvimHM_UX}MN=5(Y_|C( z-|dql1OoCXf$Reqwx_Gqvnp6S?{?R~nZ#&G%ytPF1}Cwm&UZ_&jCChM@O#G(RBWI( z!bhv~>ZT7yNQj%e78%EpUyQEUsyD(6p%EG^@8k6&Mym4|ubGD%2k8O=%;dn6?a<* z8AYc>$6S_$h3>l*+D4rb*eBX1XigF&6aK3)K5AzGb-)=UchBKMO?2K9<+7pUq-Q96 zsgFz%+rlDAhgLp_SeS%*hI;O2hkbELW+(^DI0pGg?tf?;B54c@G2+G=FQ)WS2~4)g z+mRM4`zUayXHZA0#~JNK_&zcTD|>EDp-J|YGIj{|gZ+pF*n2>3n;l&I>K*MVPAzUN z^)(43G8T^mpQBpn*7}B zkNzA{CKgkT^2a7TLNifcU}lii$Vtx-(creB1-@5fLZxTD<*bQdryt&~qOZpL;FJu@ zVWeSLtx*~lHRDMDH1M$aW~-kTfRyyhlt1~Y+T&>mRIe&0E-->gEn(H;nrw^FuFBi| z7~68pv!HJBM#DHswmL)c4CZr`El}tQq$ohLTaID5YA7}4QX4*&lOOk6X3DAR6@FnY z;&<)pbOX~qqpX^Xl6E1PashdG)wu4*2W z2MahI>t5Ngykj%faMlY_)J2Hso3|gt_N*qXWPCwdEKBI@I2`JRijPlAH7s%5u$q9%gFG1qnJ{mpRqZvE{4wIiu z>aOOkqm6N%z{R_SACF3W*4fsKq2T-zo)jc2t8p3Di=CstmM zro_P(c^R=|wI_4>tTx?RrZi|~Z(lg1Ge)b!RZ8R;K9)lYn6fB~m7{jd<&Kq}yq^+e zgmz(F3Tbp%T7D2DI)SQc{pK&D7d0+>RrN}joum!7`KPV*Gpe^Ld7O){kRx-n&tX|G zYJXcKUg9j9P=NXwFhIK4S;us#x@8_^WJJ58H8W~sH>~h&)eB4E&oH5Of<6XjQ8TTw zi=v`4EmsB6Xo3o27Ru%ti*J-V$|cJs4T^Cb$!(73r(A?FGN0*`6dsUdG>3_pObUm$ z;Bdq^dlZPY;y*;(9(}IjtMz3JkEU8N)Ofa+mze&Lw2}GcU1Vo5mi8#Wwj7&mWOjIZ ztn@G96K%=DDl8t!Z1pOmM#-lJqE^;+t`D7!!Yeun$b}Hkma%<4a=`v1i3;F?T{`RD zv2f!{NfxP*{TW8Uy zxg1ai##$PPlKpC(%g`Ih6BO+YVIPIcN&k~?M=ye=$pSDmGp*KO?LR!~3s9-rQIZKm z()p6{&?y6UKTsedtJ3TMkR&9XC;y9%iKX=5Y*Zb;`ePL|@sg>il^2$owdo3q=DgDr z%R`TdbZ|psW3*dW_h~hsG8Ix4tDj>is_|oRqkqbf`%HFJ{YjkX_B^Xxf@@fSqhq9s z=$yH!XoB?q>a$%z={l2;!a2?8Q}b)y)?KPYbTNjEAw)V)(h;KCio`atT^*LML>N$=UZ|Gj0!Z*$0Sw2pwby)km$GWe$&lG3w2%=9bz6Cz!8!3s)aM-7kUWmO1fJPg{XQ=BlR#2Fo#r(nY1$EZ9W? zn`u#$0^I7woHB|i%hkk_-Ru`hdSn*5z|LPt%XOK)2KcG^)EVcW%T`}lF2*$sqBY*r zo(v>1P&|lv5O)`z*Emb2)RZWjU>cb#FP(pmUHZ$*gNnnGQ!Qa$K$FE@?-?rXO3B$A z^P6lQE(>*#wtNeBIZS8mQ<_T)Q%0#d=J-;|kOn>#fdgDzCrXR-i5)i=d&jdx@VxcH zN2A&KrR?xPW^Y>1i$i~N4i*^)$MZCLjrjm><#RK z?d-4c=dER&*SYfzC@F0KyQY2BAH_ls9L0g4965w07hjo;qDHIuM9C2v5D+W(M1Eem5TGVZfSRP-F=r&{r=NNp*=3Y=sH?BvlbS@Sm=0+v zWV#dC7=T#{Y&@%9pzlsM#P~kML_~XKgBo*c(A860zhh1IsLEJXpl<2O@poWx-b1-S zLVr|MUd~H)*4YSa(eZKusm#8h6KyGsDjYE^BV7w6V-JD)oO-SI-OGsv;?06I#9@FT z{KVFXHd1X|DW=d&GwSI!G@DiF8v$@aNsl}BSB^TVUe(iovMn9^~3V|FE z88|qMzzPb0uLn@d6vQhdVT3Z{H$|BgBtTIksiovVkL(+MSc9=V(?rl`;?O1F)_Y(D z_A{$p9(P%@WQ@p>?^hjn3iqm>XBG49Gpbno*y~=+#I46$icKi_l&Ih#D3kIxG;sA2 zo*qGPXM388Fc~ed@oS8gnZ;-3qB;x+K^j_dFb>LeS;vGuife@$L)sBBtXOau#Z%D~T&4#<{;2tB}b)f3K=pat&=2tsqbO8~Ei}jg)Fu zVtca&W~U%_7^}kWVUqt|rm>)eMDE~wX@Sn9j|2I_RXQ@9=3qxD`9z9@aAqx|Y-2N4- z$0KR|!oxU|qLi^2>UZ&Sk34X{cgHq~q>m|deQ`jQk_i29YdVN~)!>RpoUyx|U=y$D z=`qBpQ1!mP@e3(Ibx$G`F**W9eS}G0?1RDrPvbouj#h9>=B)mqWQ~tcfW^C*`($@6r8rf~0Vba00GVi(bM7-$Vtu|flUI)o5s@zw%!ih4 z#dAcIR~E5jY0A~R$4c~GAihh2i^+&_u^y3x(1hbEGL(53kgNYpRq21ppDuFJ67q^? zQCCfwiL?ytiH2 zK*-c5DC-|6O3=6^n0_nTyu}I?&q4e0I0@PjLIeg!UKK;6Iu%!zf0Z>wM4$swQ(=52 zVXtdM>BpuA&9dJoWe<^A*SY#zU;f-9UbJ4_ie(jT4!btnN~ryB*Ma^((ydf${{vrD>H=TLma&8*3H}{Mr3RWS3`{1j6ZFgK)99m&jgf zpOz^}JrhjfWKZ5Y{76Dy77ndF;L7+cRWrPPBK~I^ZPowN%kLr#Z-Dc4CEV}JV;asm z%&o;*nd$=Ntb>W}GrvU1Z?C5;*%>ud*E{I3Lr{`R^&_nGmNW`2mlGoqxe69IQqqiM zU*=f+9Hy(~?79hg*@Yw0P~i=PF}v@K3)Y%w3O{_Mt!@7$R?*0MNj<-Ds%eaf?4yw# z_3fser7AnAf-5ETOxNFhAttDJ5y40_3XjK4c|`GO@}8f_zUeaS3mbWh`xbw?b^{s0 zDY-4}hXu|8`oeal0)$-}%Sm1Nwos3I-ofDFTa#*Sac};f)I#tGE3$+mvY&Ss>?!-~9{Td>*Ze*Msw*Ty{R?+`oqwnY>a?*AuqAOX5BIYV z!)O?K2GT=YUUw(~WJMCaM2Z`lA5tI9x^oT27-E)4xgBR5qtgqChE(%}mQ zIX1qge_!keXp05G-pIB0M<1Aa7*Y0@IG~zIh1&L$+<^mCu-mip-l$+U%SZc(o2Tq= z7~ISwnfh0Dk4zr9k@(ZQqB;ZCxo`vJJe>gZ2=j51}1QK7>B5G)ng zxYqs|jj8iqfgc7!LAwbL461HoVdld5BEPzqqJuYj=h7&=Jtyf`b|dtNLqwf(ZtnW_ip8581VeC#`TOOuhu9j zP|rHhzqbNe0Mt^$i=RY;UPFoB(z4?@&dI6SY}(WJR&lzaGO83wkl9aWwtOnXZrcCG zuhZjun`~Be!|208tH7VTya?-0xZ{`=t=5?*Cf5J@o>(im`RF$M_o{c(;+clH0Nc1Z zpWa@wF+_z<#*S&Ah!sNuN|7au@$?^Vq-TjSl35n`c$da$%3R^T-!JP_JS+-~=h*Hq zUX`=3;9YNyVW^Rvr2|8canWsMlHSYVdhU~-{5$ej!~c(wY2;>wvvwb;;e<}m{JdnG zoi4t|dyXkPoLcE@P|{hY^D!yggJjlku@$!{Qt`R0w^{a$>g$&`H!o^*szgE?aj%>{ zq{^4d{}%n|hdsosa9S*KNR)WMIQ1I~OpSsej548NCB650iNa;ZqglGwlb3~LMCLH| zC+n0BS}BtieSBKIUheZ5yjMAXHle$Ir8>*ke#GxewBy2xolo30JaPy8>UZQj;ifM` zTcO^!%|)hR{FAOsghc|PD{LAu1Sp!dXgF1t?fq`F z)Mx8XjJ6yPwtK0@g7x{0c4fG2XIPwYPY6M0lRZ6SL*)t36Xq+lb_I{r;f-<7{Q9fm3F@$!29Z(%mO;|5~ z4fO^TJKt)RhWgF*tA#a8ep|kH$o65*oSB*ve1?Y+8fb2g{TSWA=(A3m>f7U4ZIx8Q zWfDnJVxuwY9{Lv zb#As-xDtua4@R}RR>`iBx5=O6w4~Z0?>~ndNZ1bzB*TU0U30!ya#$7EHgg&fcUYsk z2i~;7OnabWc+H`HeQZ}mfYq*a{>K_H>-w>VnviEbLhT+3qd<6z9OOZI0i~nq6~a$t zzf>W&-(UMu=3ja5-g3!CI>OZ{&sUI7J!q<=KSxp}NjYM|Quo@5^WqfxVjN zh_=}51MU^ieDz>isGlTUH2Uoht6PReu+3A0*GJPDvQ(@g1N9B`7$=mprvFa>+zcc0 zDOD>?q_>N%o?rq6I-4Yg5@RJy5Zh5e2_Z&mzZy$4t*YxU(QaQZpuKOZ!0gzSBH0;7 zGU9#L=}Mfgp!=T11Ws%E-a}Xu@+LHM{BRIB~(tJ??QdcJ6mZ|N#k>^mpPdA z0=yLap7|_%Lt9hOCbQ!6P2gnuf00IFR010&l6b8(D(jubX6}(QG0E@!}3Urof3MF<*m>{;J zfD%HCRDV?#>C>{?FVUx%YI(0G!tB%Y|@b8&V8c1)x+>U@rAP|57 zkWgAcVL+@=QR@m`Crl2n?NoLa>CN_pk`jav$(~06C4^3N)2FjYzjlc}=2btodM5Nd z2FpV=?4W51$x2pU%?$VMz`XN+_gq)?zdZr$d(~&5#=IJyM}t8F^(?qvA~5U8r#<$W z`J{pLhDqYJgX#bbfP~@$3Ik$|W?E?=y*ZLFm;#+;nnH=a61EXOiSWVWu9*wdNb@31 z#j>)@WQK+VLAGAGo@v%MNY?bslJ^v`yWC@yb$EZ^XBxd5x^HO!013rOL_t)4w9D~F zCvg9F>IDmR!%!Ni=Nnir5t#Ml(;oX2eXihj!sK=oOag%p%t@2N0}=-lo^s5z(m;As zG|n&trl1=a>8(a;eb(Iasr=-3+PrRwJ_fb$q-}^4KFVsV4)*#+MJzw&AiNLHUpZ3_N=yL_H6DGH#U=j#)U{0D89*{Vg z@PwnNa|N%Hz~vzrOo7HSO<}}K3Hu12NHl<>ut-yjaf>u{0qR5`hh({OIm0Zcvz+M_ zlLJkeG`q$M=0Ci*^7&`GDjYL`_0Eo&tFO8iYGX!quVrA>5sgZel<0E>uam-;!Y~O0 zIxr_qDhx;*OnAak)VYG!N#ODj1g0Qkk>0c^lz1s&8{v}(-#PADx!@TYw?uDsoi0v| zX!?*VS1o6VC3TV!wZ-57Lq$>vi&f1Ld|$Vql6l5&Z6h=Ihu#NCZ zgby6qFVa+g+#*d~fEqo>Ay=kahE_}HB%|u@iOwd5oRP9iygmD#N^c*{EQZ4$(Qt!V zCb{aEU;>ja)Qw+)$LPVh(HnjzM4wMkOnJgE2?RPYCw)|VpfIr4q3~=j;p^nfV9%H< zmMznqomZtGlyJ@mBBK^*vqm!s6ipyRiW~_*I^sx)y~jF8sM^@er_*^xsZ|Mc_QUsg zRC()=zG@_2)y%E0!7_Vh0`&{^uy?&t_%V7gZuEv<0nz6Z6jPosOag%p%t;^B9w-d# zbtpWWOZYnZGT1Ywie-!RRsm(_6)9OIob!Q*vPh5nHF~S5Qv*bX9G#|dWG2)=&9t&W zCvina31?M27nq>J2}*cw{=J>oFndpQ>D@a4?+soHHG3Z3z!?0v238$Wmb0R&&tF{$ zsva)_1UfJ$ea&+_VPIZpe$RBSz;()LSp))8kg`lu2r*H@Ho_+nzH_{Nk?vGHks(K~ zStqZgaRWub3s)KlG$G`fKalTOWk;z82Ju46HE{aS)->oee(n5u`gIHSus5es_$fMD zM25XeYJY2)R%f2g>=Vessz69UUCJ<}h^G=h5K2}F6ItxI>s(^zsp%K#Q$(!5 zOp1&w$RyY`gI@t0xpNBPmk)X>G!WoI2sOVES5RdE^u`8WW;O2+=dJ8zk9d{aID!6e z;zSE|hvph2>ZyTQPcC~QM4!rKdUF=_q>=!j198%a>4Cz)QlVEY)3^fH>4K{#2uwlB zGEE`GObOcvpG5f1k^RTuCOz#U&H83nqfs2Wixk2QVs{~=QbahW1 z^9*I4O75Bba(UAP=6*ZN7wRGXbP#QjsHX;IJ-O^HR(&cLYE_Sy1ppn0lRgX&6b9x+ zruR(a3S6g@BHkb{1@&B}sqRso5EAP~f^S)*SS0^tkG?mg#J0*{rU>b;?+I zzy))_OP1-|7HK}8e8gsi(b-j@VyH+igh!rx0E7<+fOOr)0hH>L@VGi=QXbR%7}c5P zwras9u-*i0PIIEl7V06-x`SwgL_IYy>&f`rpn81KGOhNAk7-9{I#=L20h%tD0$#F6 zZ!aq?dmDpNIxwMJE0VZLOkf8>Cr5{iBeNMe%2*ok4-f!_*>y(=l?kElw$lmrZMv`8 z1jG8ehLD;5WYfrC7DkxBTBwDg_@VG6yW@LicvP*^hF;#Y18z$nc0t6^$?H-gna(GW zg%$c9hQJi0Y@`%I{FLy45VAsu$l=GHze=k$sR_SMMrBupkD+#Q0aP(#VPBAqobjK! z3Uv#ir>Yz8+um%GLKAuXbiT`B4g0&E=R3N=7wRGJX9v*+i8^Xv)e+0y2*YD7(`=O4 ztUiG(%xK9K0#gvaOjB0kxnG2=5XyN#gl%=3Z1w-)P_0Q#_ysa5yDEGPwUY~=iWv#( zVtYnU6OFr$tQdB^tBQMl%9@Pmc(}LP<>pObJs597D?i@^9$ct9ePJqPDrw;f24+1O zdjkypPs#vD4n=SW+?GD73nGq=)P@+#bUuMBERwHstaM(iuuLn4N?13x33iz=8$`H{ zP;!J@2d)#bY9b=k1EG?m3jZWPBnWfNj&v@zNAfTx*BwO)A;<1EQwW9zS&!^GMU|@v zZvyM7c@tXs_f7y?sE2$9I*2w%)KLSgj)=b@hW;mI05q2(iUBrKo3)cZsyk2^SnKE$ z&on-PENrQ`Uui8UH$K*)q2!~Un8NCqrt;a(l`xKCzX1} zTRb@QVdAJkiH>l>RJG_xVH zYyLyVxWKIYX6S!X20(Htf*W8Xb=vxn1K|TtN&Pd^ z_yn@Bt^hnssy1kurWRJMW-f?u9i?1TW=nFNogfBXDN4l^03-mxirY}B*nUexxKuMT z7(eFSTznK@|6Ld2U(i(SZscLjY2r1%gJ<%`Ki6!|zXE>zMR;-h>m5cO-pvbj z$A?lORY3_e46HgLGXD?8(Ep?ifaFpLHyd=cob+dYTa7+Y>LiV28lONGPFd;A1ycaF zOjAZ`w%vrR5XyO0BnS|V>P1>Cj|f{ONV$WGD>z6eEtR0hQn5#N*+)$EiAb3^rFh^d zHNk;DavwC0L9##cP$q2CexP=C!gf|k;so@CddL&*Bw8s`M+>YvqB!s@=xBe^4v32u z!40sH+AN*)h46vA1Nolld@e~yW?fMiOaTLyX)1qJRd!-aS@kk4)*=S|`VJb#22cRR zFX}|3Vr+aTXw2uWX5}sHlS#+JM9d_^e&f~X-$;GqBz~`_`UhOk{JklETgU$czRKc# TMXJS^00000NkvXXu0mjf((zb_ literal 0 HcmV?d00001 diff --git a/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx b/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx index 69577c2f0c..26f73000b3 100644 --- a/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx +++ b/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx @@ -140,6 +140,34 @@ Invitations expire after 7 days. If an invitation has expired, contact the organ +## Expelling a User From an Organization + +Organization owners can expel a member from the organization. Expelling removes the membership immediately, revoking access to all providers, scans, and findings scoped to that organization. Owners expelling themselves are blocked if they are the last remaining owner of the organization. + +To expel a user: + +1. Navigate to the **Users** page. + +2. Locate the user to remove and open the row actions menu. + +3. Select **Expel user**. + + Users table row action menu showing the 'Expel user' destructive option + + +4. Confirm the action in the dialog. The membership is removed immediately and the expelled user loses access to the organization. + + Confirmation dialog asking to expel the selected user from the current organization + + + +Expelling a user revokes any refresh tokens the account holds, but access tokens already issued remain valid until they expire. The default access token lifetime is 30 minutes, so an expelled user may retain access to the organization for up to that window before being fully locked out. + + + +If the expelled organization was the user's **only** organization, the account is permanently deleted along with the membership. All personal profile data associated with that account is removed and cannot be recovered. To preserve the account, confirm that the user belongs to another organization before expelling. + + ## Permissions Reference | Action | Required Conditions | @@ -149,3 +177,4 @@ Invitations expire after 7 days. If an invitation has expired, contact the organ | Switch organizations | Any authenticated user | | Edit organization name | Organization owner with **Manage Account** permission | | Delete an organization | Organization owner with **Manage Account** permission; must belong to more than one organization | +| Expel a user from an organization | Organization owner (no additional permission required); last remaining owner cannot expel themselves | diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 5cd038666b..bedc6d819e 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🔄 Changed +- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787) + +### ❌ Removed + - Redesign compliance page with a horizontal ThreatScore card (always-visible pillar breakdown + ActionDropdown), client-side search for compliance frameworks, compact scan selector trigger, responsive mobile filters, download-started toasts for CSV/PDF exports, enhanced compliance cards with truncated titles, and Alert-based empty/error states; migrate Progress component from HeroUI to shadcn [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767) - Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797) diff --git a/ui/actions/users/users.ts b/ui/actions/users/users.ts index 717606594d..446fa81812 100644 --- a/ui/actions/users/users.ts +++ b/ui/actions/users/users.ts @@ -2,17 +2,64 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; +import { z } from "zod"; +import { auth } from "@/auth.config"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import { + TENANT_MEMBERSHIP_ROLE, + type TenantMembershipRole, +} from "@/types/users"; + +const getUsersSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + query: z.string().default(""), + sort: z.string().optional().default(""), + filters: z + .record( + z.string(), + z.union([z.string(), z.array(z.string()), z.number()]).optional(), + ) + .default({}), + pageSize: z.coerce.number().int().min(1).default(10), +}); + +const updateUserSchema = z.object({ + userId: z.uuid(), + name: z.string().min(1).optional(), + email: z.email().optional(), + company_name: z.string().optional(), + password: z.string().min(1).optional(), +}); + +const deleteUserSchema = z.object({ + userId: z.uuid(), +}); + +const removeUserFromTenantSchema = z.object({ + userId: z.uuid(), + tenantId: z.uuid(), +}); + +const updateUserRoleSchema = z.object({ + userId: z.uuid(), + roleId: z.uuid(), +}); + +type GetUsersInput = z.input; +type UpdateUserData = z.infer; +type UserAttributes = Omit; +type MembershipResource = { id: string }; + +export const getUsers = async (rawParams: Partial = {}) => { + const parsed = getUsersSchema.safeParse(rawParams); + if (!parsed.success) { + console.error("Invalid getUsers params:", parsed.error.flatten()); + return undefined; + } + const { page, query, sort, filters, pageSize } = parsed.data; -export const getUsers = async ({ - page = 1, - query = "", - sort = "", - filters = {}, - pageSize = 10, -}) => { const headers = await getAuthHeaders({ contentType: false }); if (isNaN(Number(page)) || page < 1) redirect("/users?include=roles"); @@ -46,22 +93,29 @@ export const getUsers = async ({ export const updateUser = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); - const userId = formData.get("userId") as string; // Ensure userId is a string - const userName = formData.get("name") as string | null; - const userPassword = formData.get("password") as string | null; - const userEmail = formData.get("email") as string | null; - const userCompanyName = formData.get("company_name") as string | null; + const rawData = { + userId: formData.get("userId"), + name: formData.get("name") ?? undefined, + email: formData.get("email") ?? undefined, + company_name: formData.get("company_name") ?? undefined, + password: formData.get("password") ?? undefined, + }; + const parsed = updateUserSchema.safeParse(rawData); + if (!parsed.success) { + return { error: "Invalid user data" }; + } + const { userId, name, email, company_name, password } = parsed.data; const url = new URL(`${apiBaseUrl}/users/${userId}`); // Prepare attributes to send based on changes - const attributes: Record = {}; + const attributes: UserAttributes = {}; // Add only changed fields - if (userName !== null) attributes.name = userName; - if (userEmail !== null) attributes.email = userEmail; - if (userCompanyName !== null) attributes.company_name = userCompanyName; - if (userPassword !== null) attributes.password = userPassword; + if (name !== undefined) attributes.name = name; + if (email !== undefined) attributes.email = email; + if (company_name !== undefined) attributes.company_name = company_name; + if (password !== undefined) attributes.password = password; // If no fields have changed, don't send the request if (Object.keys(attributes).length === 0) { @@ -90,13 +144,14 @@ export const updateUser = async (formData: FormData) => { export const updateUserRole = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); - const userId = formData.get("userId") as string; - const roleId = formData.get("roleId") as string; - - // Validate required fields - if (!userId || !roleId) { + const parsed = updateUserRoleSchema.safeParse({ + userId: formData.get("userId"), + roleId: formData.get("roleId"), + }); + if (!parsed.success) { return { error: "userId and roleId are required" }; } + const { userId, roleId } = parsed.data; const url = new URL(`${apiBaseUrl}/users/${userId}/relationships/roles`); @@ -124,11 +179,11 @@ export const updateUserRole = async (formData: FormData) => { export const deleteUser = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: false }); - const userId = formData.get("userId"); - - if (!userId) { + const parsed = deleteUserSchema.safeParse({ userId: formData.get("userId") }); + if (!parsed.success) { return { error: "User ID is required" }; } + const { userId } = parsed.data; const url = new URL(`${apiBaseUrl}/users/${userId}`); @@ -158,6 +213,156 @@ export const deleteUser = async (formData: FormData) => { } }; +interface ServerActionErrorDetail { + detail: string; + code?: string; +} + +interface ServerActionErrorResponse { + errors: ServerActionErrorDetail[]; +} + +interface RemoveUserFromTenantSuccess { + success: true; +} + +type RemoveUserFromTenantResult = + | RemoveUserFromTenantSuccess + | ServerActionErrorResponse; + +const toErrorResponse = (detail: string): ServerActionErrorResponse => ({ + errors: [{ detail }], +}); + +export const removeUserFromTenant = async ( + formData: FormData, +): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const parsed = removeUserFromTenantSchema.safeParse({ + userId: formData.get("userId"), + tenantId: formData.get("tenantId"), + }); + if (!parsed.success) { + return toErrorResponse("userId and tenantId are required"); + } + const { userId, tenantId } = parsed.data; + + // Resolve the target user's membership id for the current tenant on the + // server so the client form can open instantly without a prefetch. + // + // We cannot use `/users/{userId}/memberships` here: that endpoint ignores + // the path user id and always returns the authenticated user's memberships, + // which would make us try to delete the caller's own membership. + const listUrl = new URL(`${apiBaseUrl}/tenants/${tenantId}/memberships`); + listUrl.searchParams.append("filter[user]", userId); + listUrl.searchParams.append("page[size]", "1"); + + let targetMembershipId: string | null = null; + try { + const listResponse = await fetch(listUrl.toString(), { headers }); + if (!listResponse.ok) { + const errorData = await listResponse.json().catch(() => ({})); + return { + errors: errorData.errors ?? [ + { detail: "Failed to resolve the user's membership" }, + ], + }; + } + const listData = (await listResponse.json()) as { + data?: MembershipResource[]; + }; + targetMembershipId = listData?.data?.[0]?.id ?? null; + } catch (error) { + const handled = handleApiError(error); + return toErrorResponse( + handled?.error ?? "Failed to resolve the user's membership", + ); + } + + if (!targetMembershipId) { + return toErrorResponse( + "This user is not a member of the current organization.", + ); + } + + const url = new URL( + `${apiBaseUrl}/tenants/${tenantId}/memberships/${targetMembershipId}`, + ); + + try { + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + errors: errorData.errors ?? [ + { detail: "Failed to expel the user from the organization" }, + ], + }; + } + + revalidatePath("/users"); + return { success: true }; + } catch (error) { + const handled = handleApiError(error); + return toErrorResponse( + handled?.error ?? "Failed to expel the user from the organization", + ); + } +}; + +interface MembershipAttributesResource { + id: string; + attributes?: { + role?: string; + }; +} + +/** + * Resolve the active user's role inside the current tenant by querying the + * tenant memberships list with `filter[user]`. Returns `null` if the role + * cannot be determined (missing session, API error, or no match), so the + * caller can default-deny the destructive UI action. + */ +export const getCurrentUserTenantRole = + async (): Promise => { + const session = await auth(); + const userId = session?.userId; + const tenantId = session?.tenantId; + if (!userId || !tenantId) { + return null; + } + + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/tenants/${tenantId}/memberships`); + url.searchParams.append("filter[user]", userId); + url.searchParams.append("page[size]", "1"); + + try { + const response = await fetch(url.toString(), { headers }); + if (!response.ok) { + return null; + } + const body = (await response.json()) as { + data?: MembershipAttributesResource[]; + }; + const role = body?.data?.[0]?.attributes?.role; + if ( + role === TENANT_MEMBERSHIP_ROLE.Owner || + role === TENANT_MEMBERSHIP_ROLE.Member + ) { + return role; + } + return null; + } catch (error) { + console.error("Error resolving current user's tenant role:", error); + return null; + } + }; + export const getUserInfo = async () => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL( diff --git a/ui/app/(prowler)/users/page.tsx b/ui/app/(prowler)/users/page.tsx index 8691137ae9..4b26c0baf2 100644 --- a/ui/app/(prowler)/users/page.tsx +++ b/ui/app/(prowler)/users/page.tsx @@ -2,7 +2,8 @@ import Link from "next/link"; import { Suspense } from "react"; import { getRoles } from "@/actions/roles/roles"; -import { getUsers } from "@/actions/users/users"; +import { getCurrentUserTenantRole, getUsers } from "@/actions/users/users"; +import { auth } from "@/auth.config"; import { FilterControls } from "@/components/filters"; import { AddIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; @@ -10,6 +11,7 @@ import { ContentLayout } from "@/components/ui"; import { DataTable } from "@/components/ui/table"; import { ColumnsUser, SkeletonTableUser } from "@/components/users/table"; import { Role, SearchParamsProps, UserProps } from "@/types"; +import { TENANT_MEMBERSHIP_ROLE } from "@/types/users"; export default async function Users({ searchParams, @@ -58,19 +60,24 @@ const SSRDataTable = async ({ // Extract query from filters const query = (filters["filter[search]"] as string) || ""; - const usersData = await getUsers({ query, page, sort, filters, pageSize }); - const rolesData = await getRoles({}); + const [usersData, rolesData, currentTenantRole, session] = await Promise.all([ + getUsers({ query, page, sort, filters, pageSize }), + getRoles({}), + getCurrentUserTenantRole(), + auth(), + ]); + + const currentUserId = session?.userId; + const currentTenantId = session?.tenantId; + const isCurrentUserOwner = currentTenantRole === TENANT_MEMBERSHIP_ROLE.Owner; // Create a dictionary for roles by user ID - const roleDict = (usersData?.included || []).reduce( - (acc: Record, item: Role) => { - if (item.type === "roles") { - acc[item.id] = item.attributes; - } - return acc; - }, - {} as Record, - ); + const roleDict: Record = {}; + for (const item of (usersData?.included || []) as Role[]) { + if (item.type === "roles") { + roleDict[item.id] = item.attributes; + } + } // Generate the array of roles with all the roles available const roles = Array.from( @@ -88,6 +95,11 @@ const SSRDataTable = async ({ const roleId = user?.relationships?.roles?.data?.[0]?.id; const role = roleDict?.[roleId] || null; + // Gate the "Expel" action server-side: only tenant owners may expel, + // and never against themselves (self-leave lives elsewhere). + const canBeExpelled = + isCurrentUserOwner && !!currentTenantId && user.id !== currentUserId; + return { ...user, attributes: { @@ -95,6 +107,8 @@ const SSRDataTable = async ({ role, }, roles, + canBeExpelled, + currentTenantId: canBeExpelled ? currentTenantId : undefined, }; }); diff --git a/ui/auth.config.ts b/ui/auth.config.ts index c43ff55cc6..24b889b631 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -15,7 +15,8 @@ import { apiBaseUrl } from "./lib"; import type { RolePermissionAttributes } from "./types/users"; interface CustomJwtPayload extends JwtPayload { - user_id: string; + user_id?: string; // Optional - doesn't actually exist in JWT tokens + sub: string; // Standard JWT subject field - contains the actual user ID tenant_id: string; } @@ -90,7 +91,8 @@ const applyDecodedClaims = ( target.accessTokenExpires = decodedToken.exp ? decodedToken.exp * 1000 : target.accessTokenExpires; - target.user_id = decodedToken.user_id ?? target.user_id; + // Map standard JWT "sub" field to user_id + target.user_id = decodedToken.sub ?? target.user_id; target.tenant_id = decodedToken.tenant_id ?? target.tenant_id; } catch (decodeError) { // eslint-disable-next-line no-console diff --git a/ui/components/users/forms/expel-user-form.tsx b/ui/components/users/forms/expel-user-form.tsx new file mode 100644 index 0000000000..0e9b375147 --- /dev/null +++ b/ui/components/users/forms/expel-user-form.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { Dispatch, SetStateAction, useTransition } from "react"; + +import { removeUserFromTenant } from "@/actions/users/users"; +import { DeleteIcon } from "@/components/icons"; +import { Button } from "@/components/shadcn"; +import { useToast } from "@/components/ui"; + +interface ExpelUserFormProps { + userId: string; + userName?: string; + tenantId: string; + setIsOpen: Dispatch>; +} + +export const ExpelUserForm = ({ + userId, + userName, + tenantId, + setIsOpen, +}: ExpelUserFormProps) => { + const { toast } = useToast(); + const [isPending, startTransition] = useTransition(); + + const handleExpel = () => { + startTransition(async () => { + const formData = new FormData(); + formData.append("userId", userId); + formData.append("tenantId", tenantId); + + const data = await removeUserFromTenant(formData); + + if (!data || !("success" in data) || data.success !== true) { + const detail = + data && "errors" in data && data.errors?.[0]?.detail + ? data.errors[0].detail + : "Failed to expel the user"; + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: detail, + }); + return; + } + + toast({ + title: "User expelled", + description: `${userName ?? "The user"} was removed from this organization.`, + }); + setIsOpen(false); + }); + }; + + const displayName = userName ?? "this user"; + + return ( +

+

+ {displayName} will lose access to + this organization. If they don't belong to any other organization, + their account will be permanently deleted. +

+ +
+ + +
+
+ ); +}; diff --git a/ui/components/users/forms/index.ts b/ui/components/users/forms/index.ts index 7d863e16c5..8ba3f44f2c 100644 --- a/ui/components/users/forms/index.ts +++ b/ui/components/users/forms/index.ts @@ -1,3 +1,4 @@ export * from "./delete-form"; export * from "./edit-form"; export * from "./edit-tenant-form"; +export * from "./expel-user-form"; diff --git a/ui/components/users/table/data-table-row-actions.tsx b/ui/components/users/table/data-table-row-actions.tsx index 09fa6978e1..3bc8e6a0c8 100644 --- a/ui/components/users/table/data-table-row-actions.tsx +++ b/ui/components/users/table/data-table-row-actions.tsx @@ -1,7 +1,7 @@ "use client"; import { Row } from "@tanstack/react-table"; -import { Pencil, Trash2 } from "lucide-react"; +import { Pencil, Trash2, UserMinus } from "lucide-react"; import { useState } from "react"; import { @@ -11,24 +11,51 @@ import { } from "@/components/shadcn/dropdown"; import { Modal } from "@/components/shadcn/modal"; -import { DeleteForm, EditForm } from "../forms"; +import { DeleteForm, EditForm, ExpelUserForm } from "../forms"; -interface DataTableRowActionsProps { +interface UserRowRole { + name?: string; +} + +interface UserRowAttributes { + name?: string; + email?: string; + company_name?: string; + role?: UserRowRole; +} + +interface UserRowData { + id: string; + attributes?: UserRowAttributes; + canBeExpelled?: boolean; + currentTenantId?: string; +} + +interface DataTableRowActionsProps { row: Row; roles?: { id: string; name: string }[]; } -export function DataTableRowActions({ +export function DataTableRowActions({ row, roles, }: DataTableRowActionsProps) { const [isEditOpen, setIsEditOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false); - const userId = (row.original as { id: string }).id; - const userName = (row.original as any).attributes?.name; - const userEmail = (row.original as any).attributes?.email; - const userCompanyName = (row.original as any).attributes?.company_name; - const userRole = (row.original as any).attributes?.role?.name; + const [isExpelOpen, setIsExpelOpen] = useState(false); + const userId = row.original.id; + const userName = row.original.attributes?.name; + const userEmail = row.original.attributes?.email; + const userCompanyName = row.original.attributes?.company_name; + const userRole = row.original.attributes?.role?.name; + + // Expel gate is resolved server-side against the active tenant's membership + // role (owner vs member), mirroring the backend rule in + // TenantMembersViewSet.destroy. The row is only expel-eligible when the + // current user is an owner of the active tenant and the row is not theirs. + const canExpelUser = + row.original.canBeExpelled === true && !!row.original.currentTenantId; + const currentTenantId = row.original.currentTenantId; return ( <> @@ -55,17 +82,39 @@ export function DataTableRowActions({ > + {canExpelUser && currentTenantId && ( + + + + )}
} + icon={