diff --git a/gatehouse_app/jobs/mfa_compliance_job.py b/gatehouse_app/jobs/mfa_compliance_job.py index 9c6a882..4aa2b7a 100644 --- a/gatehouse_app/jobs/mfa_compliance_job.py +++ b/gatehouse_app/jobs/mfa_compliance_job.py @@ -23,9 +23,10 @@ from gatehouse_app.extensions import db from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyCompliance from gatehouse_app.models.security.organization_security_policy import OrganizationSecurityPolicy from gatehouse_app.models.user.user import User +from gatehouse_app.models.organization.organization_member import OrganizationMember from gatehouse_app.services.mfa_policy_service import MfaPolicyService from gatehouse_app.services.notification_service import NotificationService -from gatehouse_app.utils.constants import MfaComplianceStatus +from gatehouse_app.utils.constants import MfaComplianceStatus, OrganizationRole logger = logging.getLogger(__name__) @@ -35,9 +36,11 @@ def process_mfa_compliance(now: Optional[datetime] = None) -> Dict[str, Any]: This scheduled job performs the following operations: 1. Transitions users from PAST_DUE to SUSPENDED status - 2. Identifies users approaching deadline (within notify_days_before) - 3. Sends deadline reminder notifications - 4. Updates notification tracking metadata + 2. Sends suspension notification to suspended users + 3. Sends suspension notification to org admins + 4. Identifies users approaching deadline (within notify_days_before) + 5. Sends deadline reminder notifications + 6. Updates notification tracking metadata Args: now: Current time, defaults to now (UTC) @@ -45,7 +48,9 @@ def process_mfa_compliance(now: Optional[datetime] = None) -> Dict[str, Any]: Returns: Dictionary with job execution statistics: - suspended_count: Number of users transitioned to suspended - - notified_count: Number of notifications sent + - user_notified_count: Number of suspension emails sent to users + - admin_notified_count: Number of suspension emails sent to admins + - notified_count: Number of deadline reminder notifications sent - processed_count: Total compliance records processed """ if now is None: @@ -55,6 +60,8 @@ def process_mfa_compliance(now: Optional[datetime] = None) -> Dict[str, Any]: stats = { "suspended_count": 0, + "user_notified_count": 0, + "admin_notified_count": 0, "notified_count": 0, "processed_count": 0, "errors": [], @@ -62,16 +69,67 @@ def process_mfa_compliance(now: Optional[datetime] = None) -> Dict[str, Any]: try: # Step 1: Transition past-due users to suspended - suspended_count = MfaPolicyService.transition_to_suspended_if_past_due(now) - stats["suspended_count"] = suspended_count - logger.info(f"Transitioned {suspended_count} users to suspended status") + suspended_records = MfaPolicyService.transition_to_suspended_if_past_due(now) + stats["suspended_count"] = len(suspended_records) + logger.info(f"Transitioned {len(suspended_records)} users to suspended status") - # Step 2: Send notifications to users approaching deadline + # Step 2: Send notifications for each suspended user + for entry in suspended_records: + try: + user = entry["user"] + compliance = entry["compliance"] + org_policy = entry["org_policy"] + + # 2a: Send suspension notification to the user + user_notified = NotificationService.send_mfa_suspended_notification( + user=user, + compliance=compliance, + org_policy=org_policy, + ) + if user_notified: + stats["user_notified_count"] += 1 + logger.info(f"Sent suspension notice to user {user.email}") + + # 2b: Send suspension notification to org admins + if org_policy: + admin_members = OrganizationMember.query.filter( + OrganizationMember.organization_id == compliance.organization_id, + OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]), + OrganizationMember.deleted_at == None, + ).all() + + for member in admin_members: + admin_user = User.query.get(member.user_id) + if not admin_user or not admin_user.email: + continue + + # Skip notifying the suspended user themselves + if admin_user.id == user.id: + continue + + admin_notified = NotificationService.send_mfa_suspended_admin_notification( + admin_user=admin_user, + suspended_user=user, + compliance=compliance, + org_policy=org_policy, + ) + if admin_notified: + stats["admin_notified_count"] += 1 + + except Exception as e: + logger.warning( + f"Error sending suspension notifications for compliance record " + f"{entry.get('compliance', {}).id if entry.get('compliance') else 'unknown'}: {e}" + ) + stats["errors"].append(str(e)) + continue + + # Step 3: Send notifications to users approaching deadline notified_count = _send_deadline_reminders(now) stats["notified_count"] = notified_count logger.info(f"Sent {notified_count} deadline reminder notifications") - # Step 3: Process any pending compliance evaluations + # Step 4: Process any pending compliance evaluations processed_count = _evaluate_pending_compliance(now) stats["processed_count"] = processed_count logger.info(f"Processed {processed_count} compliance records") @@ -82,7 +140,10 @@ def process_mfa_compliance(now: Optional[datetime] = None) -> Dict[str, Any]: logger.info( f"MFA compliance job completed: suspended={stats['suspended_count']}, " - f"notified={stats['notified_count']}, processed={stats['processed_count']}" + f"user_notified={stats['user_notified_count']}, " + f"admin_notified={stats['admin_notified_count']}, " + f"deadline_reminders={stats['notified_count']}, " + f"processed={stats['processed_count']}" ) return stats diff --git a/gatehouse_app/services/email_templates.py b/gatehouse_app/services/email_templates.py index 4e6fc9c..5d68285 100644 --- a/gatehouse_app/services/email_templates.py +++ b/gatehouse_app/services/email_templates.py @@ -424,6 +424,70 @@ def build_mfa_suspension_html( return get_base_html(content, "Account Access Restricted - MFA Enrollment Required", "Your account has been suspended due to missing MFA") +def build_mfa_suspension_admin_html( + admin_name: str, + org_name: str, + suspended_user_name: str, + suspended_user_email: str, + mfa_methods: str, + members_link: str, + deadline_date: str = "", + days_overdue: int = 0, +) -> str: + """Build MFA suspension notification email for org admins. + + Args: + admin_name: Admin's name or email + org_name: Organization name + suspended_user_name: Suspended user's name + suspended_user_email: Suspended user's email + mfa_methods: Required MFA methods + members_link: Link to manage org members + deadline_date: The deadline that was missed + days_overdue: Days past the deadline + + Returns: + HTML email string + """ + content = f''' +
+ Dear {admin_name}, +
++ A user in your organization {org_name} has been suspended due to MFA non-compliance. +
+ {get_alert_box(f"A user account has been automatically suspended for failing to meet MFA requirements.", "warning", "⚙️")} +
+ Suspended User Details:+What Happened:++ This user did not configure the required multi-factor authentication method(s) within the + allowed grace period. Their account has been automatically suspended and they will only + be able to access a compliance enrollment screen until MFA is configured. + + |
+
+ You are receiving this notification because you are an administrator of {org_name}. + No action is required from you unless the user needs assistance setting up MFA. +
+ ''' + return get_base_html(content, f"User Suspended - MFA Non-Compliance in {org_name}", f"A user in {org_name} has been suspended for missing MFA deadline") + + def build_org_invite_html( inviter_name: str, org_name: str, diff --git a/gatehouse_app/services/mfa_policy_service.py b/gatehouse_app/services/mfa_policy_service.py index 78709a5..831e62c 100644 --- a/gatehouse_app/services/mfa_policy_service.py +++ b/gatehouse_app/services/mfa_policy_service.py @@ -403,19 +403,19 @@ class MfaPolicyService: ) @staticmethod - def transition_to_suspended_if_past_due(now: Optional[datetime] = None) -> int: + def transition_to_suspended_if_past_due(now: Optional[datetime] = None) -> List[Dict[str, Any]]: """Scheduled job to transition past-due users to suspended status. Args: now: Current time, defaults to now Returns: - Number of users transitioned to suspended + List of dicts with suspended record details (user, compliance, org_policy) """ if now is None: now = datetime.now(timezone.utc) - suspended_count = 0 + suspended_records = [] # Find all compliance records that are past due past_due_records = MfaPolicyCompliance.query.filter( @@ -437,21 +437,65 @@ class MfaPolicyService: # Update user status user = User.query.get(record.user_id) - if user and user.status != UserStatus.COMPLIANCE_SUSPENDED: + if not user: + continue + + if user.status != UserStatus.COMPLIANCE_SUSPENDED: user.status = UserStatus.COMPLIANCE_SUSPENDED db.session.commit() - # Audit log - AuditService.log_action( - action=AuditAction.MFA_POLICY_USER_SUSPENDED, - user_id=record.user_id, - organization_id=record.organization_id, - description=f"User suspended due to MFA compliance deadline passed", - ) + # Get org policy for extended details + org_policy = OrganizationSecurityPolicy.query.filter_by( + organization_id=record.organization_id, deleted_at=None + ).first() - suspended_count += 1 + days_overdue = (now - deadline).days if deadline else 0 - return suspended_count + # Audit log for user (with extended details) + AuditService.log_action( + action=AuditAction.MFA_POLICY_USER_SUSPENDED, + user_id=record.user_id, + organization_id=record.organization_id, + resource_type="user", + resource_id=record.user_id, + description=f"User suspended due to MFA compliance deadline passed", + metadata={ + "deadline_at": record.deadline_at.isoformat() if record.deadline_at else None, + "suspended_at": now.isoformat(), + "days_overdue": days_overdue, + "user_email": user.email, + "policy_mode": org_policy.mfa_policy_mode.value if org_policy else None, + "grace_period_days": org_policy.mfa_grace_period_days if org_policy else None, + "policy_version": org_policy.policy_version if org_policy else None, + "reason": "MFA compliance deadline passed without required enrollment", + }, + ) + + # Audit log for org (org-scoped entry for admin visibility) + AuditService.log_action( + action=AuditAction.MFA_POLICY_USER_SUSPENDED, + user_id=None, + organization_id=record.organization_id, + resource_type="user", + resource_id=record.user_id, + description=f"Organization member {user.email} suspended due to MFA non-compliance", + metadata={ + "suspended_user_id": record.user_id, + "suspended_user_email": user.email, + "deadline_at": record.deadline_at.isoformat() if record.deadline_at else None, + "suspended_at": now.isoformat(), + "days_overdue": days_overdue, + "policy_mode": org_policy.mfa_policy_mode.value if org_policy else None, + }, + ) + + suspended_records.append({ + "user": user, + "compliance": record, + "org_policy": org_policy, + }) + + return suspended_records @staticmethod def create_org_policy( diff --git a/gatehouse_app/services/notification_service.py b/gatehouse_app/services/notification_service.py index f29bf4a..cb813d2 100644 --- a/gatehouse_app/services/notification_service.py +++ b/gatehouse_app/services/notification_service.py @@ -28,6 +28,7 @@ from gatehouse_app.services.email_provider import EmailMessage, EmailProviderFac from gatehouse_app.services.email_templates import ( build_mfa_deadline_reminder_html, build_mfa_suspension_html, + build_mfa_suspension_admin_html, ) from gatehouse_app.utils.constants import AuditAction @@ -209,6 +210,100 @@ class NotificationService: ) return False + @staticmethod + def send_mfa_suspended_admin_notification( + admin_user: User, + suspended_user: User, + compliance: MfaPolicyCompliance, + org_policy: OrganizationSecurityPolicy, + ) -> bool: + """Notify org admin that a user has been suspended for MFA non-compliance. + + Sends an email to organization admins/owners when a member of their + organization has been automatically suspended for failing to meet MFA + compliance requirements. + + Args: + admin_user: Admin/owner to notify + suspended_user: The user who was suspended + compliance: Suspended user's compliance record + org_policy: Organization's MFA policy + + Returns: + True if notification was sent successfully, False otherwise + """ + try: + org_name = compliance.organization_id + from gatehouse_app.models.organization.organization import Organization + org = Organization.query.get(compliance.organization_id) + if org: + org_name = org.name + + from gatehouse_app.utils.constants import MfaPolicyMode + mfa_methods = "Multi-factor authentication" + mode = org_policy.mfa_policy_mode + if mode == MfaPolicyMode.REQUIRE_TOTP: + mfa_methods = "Authenticator app (TOTP)" + elif mode == MfaPolicyMode.REQUIRE_WEBAUTHN: + mfa_methods = "Passkey (WebAuthn)" + elif mode == MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN: + mfa_methods = "Authenticator app (TOTP) OR Passkey (WebAuthn)" + + app_url = current_app.config.get("APP_URL", "http://localhost:8080") + members_link = f"{app_url}/organizations/{compliance.organization_id}/members" + + deadline_str = compliance.deadline_at.strftime('%Y-%m-%d %H:%M UTC') if compliance.deadline_at else '' + days_overdue = 0 + if compliance.deadline_at: + deadline = compliance.deadline_at + if deadline.tzinfo is None: + deadline = deadline.replace(tzinfo=timezone.utc) + from datetime import timezone as dt_tz + now = datetime.now(timezone.utc) + days_overdue = max(0, (now - deadline).days) + + subject = f"User Suspended - MFA Non-Compliance in {org_name}" + html_body = build_mfa_suspension_admin_html( + admin_name=admin_user.full_name or admin_user.email, + org_name=org_name, + suspended_user_name=suspended_user.full_name or suspended_user.email, + suspended_user_email=suspended_user.email, + mfa_methods=mfa_methods, + members_link=members_link, + deadline_date=deadline_str, + days_overdue=days_overdue, + ) + + NotificationService._send_email_async( + to_address=admin_user.email, + subject=subject, + body=f"A user ({suspended_user.email}) in {org_name} has been suspended for MFA non-compliance. Manage members: {members_link}", + html_body=html_body, + ) + logger.info( + f"Sent MFA suspension admin notification to {admin_user.email} " + f"regarding suspended user {suspended_user.email}" + ) + AuditService.log_action( + action=AuditAction.MFA_SUSPENSION_ADMIN_NOTIFICATION_SENT, + user_id=suspended_user.id, + organization_id=compliance.organization_id, + description=f"Admin {admin_user.email} notified about MFA suspension of user {suspended_user.email}", + metadata={ + "admin_user_id": admin_user.id, + "admin_email": admin_user.email, + "suspended_user_email": suspended_user.email, + "org_name": org_name, + }, + ) + return True + + except Exception as e: + logger.exception( + f"Error sending MFA suspension admin notification to {admin_user.email}: {e}" + ) + return False + @staticmethod def _build_deadline_reminder_body( user: User, diff --git a/gatehouse_app/utils/constants.py b/gatehouse_app/utils/constants.py index 187efa6..791732e 100644 --- a/gatehouse_app/utils/constants.py +++ b/gatehouse_app/utils/constants.py @@ -80,6 +80,7 @@ class AuditAction(str, Enum): MFA_COMPLIANCE_BYPASS_ATTEMPT = "mfa.compliance.bypass_attempt" MFA_NOTIFICATION_SENT = "mfa.notification.sent" MFA_SUSPENSION_NOTIFICATION_SENT = "mfa.suspension_notification.sent" + MFA_SUSPENSION_ADMIN_NOTIFICATION_SENT = "mfa.suspension_admin_notification.sent" # Organization actions ORG_CREATE = "org.create"