enable policies
This commit is contained in:
@@ -140,13 +140,14 @@ class AuthService:
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def create_session(user, duration_seconds=86400):
|
||||
def create_session(user, duration_seconds=86400, is_compliance_only=False):
|
||||
"""
|
||||
Create a new session for the user.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
duration_seconds: Session duration in seconds
|
||||
is_compliance_only: Whether this is a compliance-only session (limited access)
|
||||
|
||||
Returns:
|
||||
Session instance
|
||||
@@ -163,6 +164,7 @@ class AuthService:
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(seconds=duration_seconds),
|
||||
last_activity_at=datetime.now(timezone.utc),
|
||||
is_compliance_only=is_compliance_only,
|
||||
)
|
||||
session.save()
|
||||
|
||||
|
||||
@@ -0,0 +1,978 @@
|
||||
"""MFA Policy Service."""
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.organization_security_policy import OrganizationSecurityPolicy
|
||||
from gatehouse_app.models.user_security_policy import UserSecurityPolicy
|
||||
from gatehouse_app.models.mfa_policy_compliance import MfaPolicyCompliance
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
from gatehouse_app.utils.constants import (
|
||||
MfaPolicyMode,
|
||||
MfaComplianceStatus,
|
||||
MfaRequirementOverride,
|
||||
AuditAction,
|
||||
UserStatus,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgPolicyDto:
|
||||
"""DTO for organization policy."""
|
||||
organization_id: str
|
||||
mfa_policy_mode: str
|
||||
mfa_grace_period_days: int
|
||||
notify_days_before: int
|
||||
policy_version: int
|
||||
updated_by_user_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectiveUserPolicyDto:
|
||||
"""DTO for effective user policy combining org and user overrides."""
|
||||
organization_id: str
|
||||
effective_mode: str
|
||||
requires_totp: bool
|
||||
requires_webauthn: bool
|
||||
grace_period_days: int
|
||||
is_exempt: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserMfaStateDto:
|
||||
"""DTO for per-organization MFA state."""
|
||||
organization_id: str
|
||||
organization_name: str
|
||||
status: str
|
||||
effective_mode: str
|
||||
deadline_at: Optional[str] = None
|
||||
applied_at: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AggregateMfaStateDto:
|
||||
"""DTO for aggregate MFA state across all organizations."""
|
||||
overall_status: str
|
||||
missing_methods: List[str] = field(default_factory=list)
|
||||
deadline_at: Optional[str] = None
|
||||
orgs: List[UserMfaStateDto] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginPolicyResult:
|
||||
"""Result of policy evaluation after primary auth success."""
|
||||
can_create_full_session: bool
|
||||
create_compliance_only_session: bool
|
||||
compliance_summary: AggregateMfaStateDto
|
||||
|
||||
|
||||
class MfaPolicyService:
|
||||
"""Service for MFA policy evaluation and compliance tracking."""
|
||||
|
||||
@staticmethod
|
||||
def get_org_policy(org_id: str) -> Optional[OrgPolicyDto]:
|
||||
"""Get organization security policy.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
|
||||
Returns:
|
||||
OrgPolicyDto or None if not found
|
||||
"""
|
||||
policy = OrganizationSecurityPolicy.query.filter_by(
|
||||
organization_id=org_id, deleted_at=None
|
||||
).first()
|
||||
|
||||
if not policy:
|
||||
return None
|
||||
|
||||
return OrgPolicyDto(
|
||||
organization_id=policy.organization_id,
|
||||
mfa_policy_mode=policy.mfa_policy_mode.value,
|
||||
mfa_grace_period_days=policy.mfa_grace_period_days,
|
||||
notify_days_before=policy.notify_days_before,
|
||||
policy_version=policy.policy_version,
|
||||
updated_by_user_id=policy.updated_by_user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_effective_user_policy(
|
||||
user_id: str, org_id: str
|
||||
) -> EffectiveUserPolicyDto:
|
||||
"""Get effective user policy combining org policy with user overrides.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
org_id: Organization ID
|
||||
|
||||
Returns:
|
||||
EffectiveUserPolicyDto
|
||||
"""
|
||||
# Get org policy
|
||||
org_policy = OrganizationSecurityPolicy.query.filter_by(
|
||||
organization_id=org_id, deleted_at=None
|
||||
).first()
|
||||
|
||||
if not org_policy:
|
||||
# No org policy means no requirements
|
||||
return EffectiveUserPolicyDto(
|
||||
organization_id=org_id,
|
||||
effective_mode=MfaPolicyMode.DISABLED.value,
|
||||
requires_totp=False,
|
||||
requires_webauthn=False,
|
||||
grace_period_days=0,
|
||||
is_exempt=True,
|
||||
)
|
||||
|
||||
# Get user override
|
||||
user_override = UserSecurityPolicy.query.filter_by(
|
||||
user_id=user_id, organization_id=org_id, deleted_at=None
|
||||
).first()
|
||||
|
||||
# Determine effective mode
|
||||
if user_override:
|
||||
override_mode = user_override.mfa_override_mode
|
||||
if override_mode == MfaRequirementOverride.EXEMPT:
|
||||
return EffectiveUserPolicyDto(
|
||||
organization_id=org_id,
|
||||
effective_mode=MfaPolicyMode.DISABLED.value,
|
||||
requires_totp=False,
|
||||
requires_webauthn=False,
|
||||
grace_period_days=org_policy.mfa_grace_period_days,
|
||||
is_exempt=True,
|
||||
)
|
||||
elif override_mode == MfaRequirementOverride.REQUIRED:
|
||||
# User is required to have MFA even if org is optional
|
||||
effective_mode = MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN
|
||||
else:
|
||||
effective_mode = org_policy.mfa_policy_mode
|
||||
else:
|
||||
effective_mode = org_policy.mfa_policy_mode
|
||||
|
||||
# Determine required methods based on mode
|
||||
requires_totp = effective_mode in (
|
||||
MfaPolicyMode.REQUIRE_TOTP,
|
||||
MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN,
|
||||
)
|
||||
requires_webauthn = effective_mode in (
|
||||
MfaPolicyMode.REQUIRE_WEBAUTHN,
|
||||
MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN,
|
||||
)
|
||||
|
||||
return EffectiveUserPolicyDto(
|
||||
organization_id=org_id,
|
||||
effective_mode=effective_mode.value,
|
||||
requires_totp=requires_totp,
|
||||
requires_webauthn=requires_webauthn,
|
||||
grace_period_days=org_policy.mfa_grace_period_days,
|
||||
is_exempt=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def evaluate_user_mfa_state(user: User) -> AggregateMfaStateDto:
|
||||
"""Evaluate user's MFA state across all organizations.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
|
||||
Returns:
|
||||
AggregateMfaStateDto with overall status and per-org breakdown
|
||||
"""
|
||||
org_states: List[UserMfaStateDto] = []
|
||||
overall_status = MfaComplianceStatus.COMPLIANT.value
|
||||
earliest_deadline: Optional[datetime] = None
|
||||
missing_methods: set = set()
|
||||
|
||||
for membership in user.organization_memberships:
|
||||
if membership.deleted_at is not None:
|
||||
continue
|
||||
|
||||
org = membership.organization
|
||||
if org.deleted_at is not None:
|
||||
continue
|
||||
|
||||
effective_policy = MfaPolicyService.get_effective_user_policy(
|
||||
user.id, org.id
|
||||
)
|
||||
|
||||
# Get or create compliance record
|
||||
compliance = MfaPolicyCompliance.query.filter_by(
|
||||
user_id=user.id, organization_id=org.id, deleted_at=None
|
||||
).first()
|
||||
|
||||
if not compliance:
|
||||
# Create initial compliance record
|
||||
compliance = MfaPolicyCompliance(
|
||||
user_id=user.id,
|
||||
organization_id=org.id,
|
||||
status=MfaComplianceStatus.NOT_APPLICABLE,
|
||||
policy_version=0,
|
||||
)
|
||||
compliance.save()
|
||||
|
||||
# Determine status based on policy and user MFA state
|
||||
status = MfaPolicyService._evaluate_compliance_status(
|
||||
user, effective_policy, compliance
|
||||
)
|
||||
|
||||
# Update compliance record if needed
|
||||
if compliance.status != status:
|
||||
compliance.status = status
|
||||
db.session.commit()
|
||||
|
||||
# Track missing methods
|
||||
if status not in (
|
||||
MfaComplianceStatus.COMPLIANT.value,
|
||||
MfaComplianceStatus.NOT_APPLICABLE.value,
|
||||
):
|
||||
if effective_policy.requires_totp and not user.has_totp_enabled():
|
||||
missing_methods.add("totp")
|
||||
if effective_policy.requires_webauthn and not user.has_webauthn_enabled():
|
||||
missing_methods.add("webauthn")
|
||||
|
||||
# Track earliest deadline
|
||||
if compliance.deadline_at:
|
||||
if earliest_deadline is None or compliance.deadline_at < earliest_deadline:
|
||||
earliest_deadline = compliance.deadline_at
|
||||
|
||||
# Determine overall status (most restrictive)
|
||||
if status == MfaComplianceStatus.SUSPENDED.value:
|
||||
overall_status = MfaComplianceStatus.SUSPENDED.value
|
||||
elif (
|
||||
status == MfaComplianceStatus.PAST_DUE.value
|
||||
and overall_status != MfaComplianceStatus.SUSPENDED.value
|
||||
):
|
||||
overall_status = MfaComplianceStatus.PAST_DUE.value
|
||||
elif (
|
||||
status == MfaComplianceStatus.IN_GRACE.value
|
||||
and overall_status
|
||||
not in (
|
||||
MfaComplianceStatus.SUSPENDED.value,
|
||||
MfaComplianceStatus.PAST_DUE.value,
|
||||
)
|
||||
):
|
||||
overall_status = MfaComplianceStatus.IN_GRACE.value
|
||||
elif (
|
||||
status == MfaComplianceStatus.PENDING.value
|
||||
and overall_status == MfaComplianceStatus.COMPLIANT.value
|
||||
):
|
||||
overall_status = MfaComplianceStatus.PENDING.value
|
||||
|
||||
org_states.append(
|
||||
UserMfaStateDto(
|
||||
organization_id=org.id,
|
||||
organization_name=org.name,
|
||||
status=status,
|
||||
effective_mode=effective_policy.effective_mode,
|
||||
deadline_at=compliance.deadline_at.isoformat() if compliance.deadline_at else None,
|
||||
applied_at=compliance.applied_at.isoformat() if compliance.applied_at else None,
|
||||
)
|
||||
)
|
||||
|
||||
return AggregateMfaStateDto(
|
||||
overall_status=overall_status,
|
||||
missing_methods=list(missing_methods),
|
||||
deadline_at=earliest_deadline.isoformat() if earliest_deadline else None,
|
||||
orgs=org_states,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_compliance_status(
|
||||
user: User,
|
||||
effective_policy: EffectiveUserPolicyDto,
|
||||
compliance: MfaPolicyCompliance,
|
||||
) -> str:
|
||||
"""Evaluate compliance status for a user in an organization.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
effective_policy: EffectiveUserPolicyDto
|
||||
compliance: MfaPolicyCompliance instance
|
||||
|
||||
Returns:
|
||||
Status string
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# If exempt or disabled, mark as not applicable
|
||||
if effective_policy.is_exempt:
|
||||
return MfaComplianceStatus.NOT_APPLICABLE.value
|
||||
|
||||
if effective_policy.effective_mode == MfaPolicyMode.DISABLED.value:
|
||||
return MfaComplianceStatus.NOT_APPLICABLE.value
|
||||
|
||||
# Check if user has required MFA methods
|
||||
has_totp = user.has_totp_enabled()
|
||||
has_webauthn = user.has_webauthn_enabled()
|
||||
|
||||
has_required = (
|
||||
(not effective_policy.requires_totp or has_totp)
|
||||
and (not effective_policy.requires_webauthn or has_webauthn)
|
||||
)
|
||||
|
||||
if has_required:
|
||||
return MfaComplianceStatus.COMPLIANT.value
|
||||
|
||||
# User is missing required MFA
|
||||
# If no deadline set, set it now
|
||||
if not compliance.deadline_at and effective_policy.grace_period_days > 0:
|
||||
compliance.applied_at = now
|
||||
compliance.deadline_at = now.replace(
|
||||
tzinfo=None
|
||||
) + __import__("datetime").timedelta(
|
||||
days=effective_policy.grace_period_days
|
||||
)
|
||||
db.session.commit()
|
||||
return MfaComplianceStatus.IN_GRACE.value
|
||||
|
||||
# Check deadline
|
||||
if compliance.deadline_at:
|
||||
deadline = compliance.deadline_at
|
||||
if deadline.tzinfo is None:
|
||||
deadline = deadline.replace(tzinfo=timezone.utc)
|
||||
|
||||
if now < deadline:
|
||||
return MfaComplianceStatus.IN_GRACE.value
|
||||
else:
|
||||
return MfaComplianceStatus.PAST_DUE.value
|
||||
|
||||
return MfaComplianceStatus.PENDING.value
|
||||
|
||||
@staticmethod
|
||||
def after_primary_auth_success(
|
||||
user: User, remember_me: bool = False
|
||||
) -> LoginPolicyResult:
|
||||
"""Determine session type based on compliance after primary auth success.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
remember_me: Whether this is a remember-me session
|
||||
|
||||
Returns:
|
||||
LoginPolicyResult with session type and compliance summary
|
||||
"""
|
||||
compliance_summary = MfaPolicyService.evaluate_user_mfa_state(user)
|
||||
|
||||
# Check if there are any REQUIRED policies affecting this user
|
||||
has_required_policy = False
|
||||
for org_state in compliance_summary.orgs:
|
||||
if org_state.effective_mode in (
|
||||
MfaPolicyMode.REQUIRE_TOTP.value,
|
||||
MfaPolicyMode.REQUIRE_WEBAUTHN.value,
|
||||
MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN.value,
|
||||
):
|
||||
has_required_policy = True
|
||||
break
|
||||
|
||||
if not has_required_policy:
|
||||
# No required policies, full session allowed
|
||||
return LoginPolicyResult(
|
||||
can_create_full_session=True,
|
||||
create_compliance_only_session=False,
|
||||
compliance_summary=compliance_summary,
|
||||
)
|
||||
|
||||
# Check if user is compliant
|
||||
if compliance_summary.overall_status == MfaComplianceStatus.COMPLIANT.value:
|
||||
return LoginPolicyResult(
|
||||
can_create_full_session=True,
|
||||
create_compliance_only_session=False,
|
||||
compliance_summary=compliance_summary,
|
||||
)
|
||||
|
||||
# User is not compliant
|
||||
if compliance_summary.overall_status in (
|
||||
MfaComplianceStatus.IN_GRACE.value,
|
||||
MfaComplianceStatus.PENDING.value,
|
||||
):
|
||||
# Can proceed with full session but warnings
|
||||
return LoginPolicyResult(
|
||||
can_create_full_session=True,
|
||||
create_compliance_only_session=False,
|
||||
compliance_summary=compliance_summary,
|
||||
)
|
||||
|
||||
# Past due or suspended - compliance only session
|
||||
return LoginPolicyResult(
|
||||
can_create_full_session=False,
|
||||
create_compliance_only_session=True,
|
||||
compliance_summary=compliance_summary,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def transition_to_suspended_if_past_due(now: Optional[datetime] = None) -> int:
|
||||
"""Scheduled job to transition past-due users to suspended status.
|
||||
|
||||
Args:
|
||||
now: Current time, defaults to now
|
||||
|
||||
Returns:
|
||||
Number of users transitioned to suspended
|
||||
"""
|
||||
if now is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
suspended_count = 0
|
||||
|
||||
# Find all compliance records that are past due
|
||||
past_due_records = MfaPolicyCompliance.query.filter(
|
||||
MfaPolicyCompliance.status == MfaComplianceStatus.PAST_DUE,
|
||||
MfaPolicyCompliance.deadline_at != None,
|
||||
MfaPolicyCompliance.deleted_at == None,
|
||||
).all()
|
||||
|
||||
for record in past_due_records:
|
||||
deadline = record.deadline_at
|
||||
if deadline.tzinfo is None:
|
||||
deadline = deadline.replace(tzinfo=timezone.utc)
|
||||
|
||||
if now >= deadline:
|
||||
# Transition to suspended
|
||||
record.status = MfaComplianceStatus.SUSPENDED
|
||||
record.suspended_at = now
|
||||
db.session.commit()
|
||||
|
||||
# Update user status
|
||||
user = User.query.get(record.user_id)
|
||||
if user and 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",
|
||||
)
|
||||
|
||||
suspended_count += 1
|
||||
|
||||
return suspended_count
|
||||
|
||||
@staticmethod
|
||||
def create_org_policy(
|
||||
organization_id: str,
|
||||
mfa_policy_mode: MfaPolicyMode,
|
||||
mfa_grace_period_days: int = 14,
|
||||
notify_days_before: int = 7,
|
||||
updated_by_user_id: Optional[str] = None,
|
||||
) -> OrganizationSecurityPolicy:
|
||||
"""Create or update organization security policy.
|
||||
|
||||
Args:
|
||||
organization_id: Organization ID
|
||||
mfa_policy_mode: MFA policy mode
|
||||
mfa_grace_period_days: Grace period in days
|
||||
notify_days_before: Days before deadline to notify
|
||||
updated_by_user_id: User making the change
|
||||
|
||||
Returns:
|
||||
OrganizationSecurityPolicy instance
|
||||
"""
|
||||
policy = OrganizationSecurityPolicy.query.filter_by(
|
||||
organization_id=organization_id, deleted_at=None
|
||||
).first()
|
||||
|
||||
if policy:
|
||||
# Update existing
|
||||
old_mode = policy.mfa_policy_mode
|
||||
policy.mfa_policy_mode = mfa_policy_mode
|
||||
policy.mfa_grace_period_days = mfa_grace_period_days
|
||||
policy.notify_days_before = notify_days_before
|
||||
policy.policy_version += 1
|
||||
policy.updated_by_user_id = updated_by_user_id
|
||||
policy.save()
|
||||
|
||||
# Audit log
|
||||
AuditService.log_action(
|
||||
action=AuditAction.ORG_SECURITY_POLICY_UPDATE,
|
||||
user_id=updated_by_user_id,
|
||||
organization_id=organization_id,
|
||||
description=f"Security policy updated from {old_mode.value} to {mfa_policy_mode.value}",
|
||||
)
|
||||
else:
|
||||
# Create new
|
||||
policy = OrganizationSecurityPolicy(
|
||||
organization_id=organization_id,
|
||||
mfa_policy_mode=mfa_policy_mode,
|
||||
mfa_grace_period_days=mfa_grace_period_days,
|
||||
notify_days_before=notify_days_before,
|
||||
policy_version=1,
|
||||
updated_by_user_id=updated_by_user_id,
|
||||
)
|
||||
policy.save()
|
||||
|
||||
# Audit log
|
||||
AuditService.log_action(
|
||||
action=AuditAction.ORG_SECURITY_POLICY_UPDATE,
|
||||
user_id=updated_by_user_id,
|
||||
organization_id=organization_id,
|
||||
description=f"Security policy created with mode {mfa_policy_mode.value}",
|
||||
)
|
||||
|
||||
return policy
|
||||
|
||||
@staticmethod
|
||||
def set_user_override(
|
||||
user_id: str,
|
||||
organization_id: str,
|
||||
mfa_override_mode: MfaRequirementOverride,
|
||||
force_totp: bool = False,
|
||||
force_webauthn: bool = False,
|
||||
updated_by_user_id: Optional[str] = None,
|
||||
) -> UserSecurityPolicy:
|
||||
"""Set user security policy override.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
organization_id: Organization ID
|
||||
mfa_override_mode: Override mode
|
||||
force_totp: Force TOTP requirement
|
||||
force_webauthn: Force WebAuthn requirement
|
||||
updated_by_user_id: User making the change
|
||||
|
||||
Returns:
|
||||
UserSecurityPolicy instance
|
||||
"""
|
||||
override = UserSecurityPolicy.query.filter_by(
|
||||
user_id=user_id, organization_id=organization_id, deleted_at=None
|
||||
).first()
|
||||
|
||||
if override:
|
||||
old_mode = override.mfa_override_mode
|
||||
override.mfa_override_mode = mfa_override_mode
|
||||
override.force_totp = force_totp
|
||||
override.force_webauthn = force_webauthn
|
||||
override.save()
|
||||
|
||||
# Audit log
|
||||
AuditService.log_action(
|
||||
action=AuditAction.USER_SECURITY_POLICY_OVERRIDE_UPDATE,
|
||||
user_id=updated_by_user_id,
|
||||
organization_id=organization_id,
|
||||
resource_type="user",
|
||||
resource_id=user_id,
|
||||
description=f"User policy override updated from {old_mode.value} to {mfa_override_mode.value}",
|
||||
)
|
||||
else:
|
||||
override = UserSecurityPolicy(
|
||||
user_id=user_id,
|
||||
organization_id=organization_id,
|
||||
mfa_override_mode=mfa_override_mode,
|
||||
force_totp=force_totp,
|
||||
force_webauthn=force_webauthn,
|
||||
)
|
||||
override.save()
|
||||
|
||||
# Audit log
|
||||
AuditService.log_action(
|
||||
action=AuditAction.USER_SECURITY_POLICY_OVERRIDE_UPDATE,
|
||||
user_id=updated_by_user_id,
|
||||
organization_id=organization_id,
|
||||
resource_type="user",
|
||||
resource_id=user_id,
|
||||
description=f"User policy override created with mode {mfa_override_mode.value}",
|
||||
)
|
||||
|
||||
return override
|
||||
|
||||
@staticmethod
|
||||
def get_user_compliance(user_id: str, organization_id: str) -> Optional[MfaPolicyCompliance]:
|
||||
"""Get user compliance record for an organization.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
organization_id: Organization ID
|
||||
|
||||
Returns:
|
||||
MfaPolicyCompliance or None
|
||||
"""
|
||||
return MfaPolicyCompliance.query.filter_by(
|
||||
user_id=user_id, organization_id=organization_id, deleted_at=None
|
||||
).first()
|
||||
|
||||
@staticmethod
|
||||
def get_org_compliance_list(
|
||||
organization_id: str, status: Optional[MfaComplianceStatus] = None, limit: int = 100, offset: int = 0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get list of user compliance records for an organization.
|
||||
|
||||
Args:
|
||||
organization_id: Organization ID
|
||||
status: Optional status filter
|
||||
limit: Maximum records to return
|
||||
offset: Offset for pagination
|
||||
|
||||
Returns:
|
||||
List of compliance records with user info
|
||||
"""
|
||||
query = db.session.query(
|
||||
MfaPolicyCompliance,
|
||||
User.email,
|
||||
User.full_name,
|
||||
).join(
|
||||
User, User.id == MfaPolicyCompliance.user_id
|
||||
).filter(
|
||||
MfaPolicyCompliance.organization_id == organization_id,
|
||||
MfaPolicyCompliance.deleted_at == None,
|
||||
User.deleted_at == None,
|
||||
)
|
||||
|
||||
if status:
|
||||
query = query.filter(MfaPolicyCompliance.status == status)
|
||||
|
||||
records = query.order_by(
|
||||
MfaPolicyCompliance.created_at.desc()
|
||||
).limit(limit).offset(offset).all()
|
||||
|
||||
result = []
|
||||
for compliance, email, full_name in records:
|
||||
result.append({
|
||||
"user_id": compliance.user_id,
|
||||
"email": email,
|
||||
"full_name": full_name,
|
||||
"status": compliance.status.value,
|
||||
"deadline_at": compliance.deadline_at.isoformat() if compliance.deadline_at else None,
|
||||
"applied_at": compliance.applied_at.isoformat() if compliance.applied_at else None,
|
||||
"compliant_at": compliance.compliant_at.isoformat() if compliance.compliant_at else None,
|
||||
"suspended_at": compliance.suspended_at.isoformat() if compliance.suspended_at else None,
|
||||
"notification_count": compliance.notification_count,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
# =========================================================================
|
||||
# Multi-Organization Edge Case Handling
|
||||
# =========================================================================
|
||||
|
||||
@staticmethod
|
||||
def get_strictest_mode(modes: List[str]) -> str:
|
||||
"""Get the strictest MFA policy mode from a list.
|
||||
|
||||
Used for multi-org scenarios where a user belongs to multiple organizations
|
||||
with different policies. "Most secure wins" logic determines the effective
|
||||
requirement.
|
||||
|
||||
Args:
|
||||
modes: List of policy mode strings
|
||||
|
||||
Returns:
|
||||
The strictest mode string
|
||||
"""
|
||||
# Define strictness hierarchy (more strict = higher index)
|
||||
strictness_order = [
|
||||
MfaPolicyMode.DISABLED.value,
|
||||
MfaPolicyMode.OPTIONAL.value,
|
||||
MfaPolicyMode.REQUIRE_TOTP.value,
|
||||
MfaPolicyMode.REQUIRE_WEBAUTHN.value,
|
||||
MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN.value,
|
||||
]
|
||||
|
||||
max_strictness = -1
|
||||
result_mode = MfaPolicyMode.OPTIONAL.value
|
||||
|
||||
for mode in modes:
|
||||
if mode in strictness_order:
|
||||
idx = strictness_order.index(mode)
|
||||
if idx > max_strictness:
|
||||
max_strictness = idx
|
||||
result_mode = mode
|
||||
|
||||
return result_mode
|
||||
|
||||
@staticmethod
|
||||
def reevaluate_all_org_compliance(organization_id: str, now: Optional[datetime] = None) -> int:
|
||||
"""Reevaluate compliance for all users in an organization.
|
||||
|
||||
Called when org policy changes to ensure all users are properly evaluated
|
||||
under the new policy. This handles the edge case where policy becomes
|
||||
more restrictive (e.g., OPTIONAL -> REQUIRE_TOTP).
|
||||
|
||||
Args:
|
||||
organization_id: Organization ID
|
||||
now: Current time, defaults to now
|
||||
|
||||
Returns:
|
||||
Number of compliance records updated
|
||||
"""
|
||||
if now is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
|
||||
updated_count = 0
|
||||
|
||||
# Get all active members of the organization
|
||||
memberships = OrganizationMember.query.filter_by(
|
||||
organization_id=organization_id, deleted_at=None
|
||||
).all()
|
||||
|
||||
for membership in memberships:
|
||||
user = membership.user
|
||||
if not user or user.deleted_at is not None:
|
||||
continue
|
||||
|
||||
# Get or create compliance record
|
||||
compliance = MfaPolicyCompliance.query.filter_by(
|
||||
user_id=user.id, organization_id=organization_id, deleted_at=None
|
||||
).first()
|
||||
|
||||
if not compliance:
|
||||
compliance = MfaPolicyCompliance(
|
||||
user_id=user.id,
|
||||
organization_id=organization_id,
|
||||
status=MfaComplianceStatus.NOT_APPLICABLE,
|
||||
policy_version=0,
|
||||
)
|
||||
compliance.save()
|
||||
|
||||
# Reevaluate under new policy
|
||||
effective_policy = MfaPolicyService.get_effective_user_policy(
|
||||
user.id, organization_id
|
||||
)
|
||||
|
||||
old_status = compliance.status.value if hasattr(compliance.status, 'value') else str(compliance.status)
|
||||
new_status = MfaPolicyService._evaluate_compliance_status(
|
||||
user, effective_policy, compliance
|
||||
)
|
||||
|
||||
if old_status != new_status:
|
||||
compliance.status = MfaComplianceStatus(new_status)
|
||||
# Reset deadline if transitioning to in_grace from a non-grace state
|
||||
if new_status == MfaComplianceStatus.IN_GRACE.value and not compliance.deadline_at:
|
||||
compliance.applied_at = now
|
||||
compliance.deadline_at = now.replace(tzinfo=None) + __import__("datetime").timedelta(
|
||||
days=effective_policy.grace_period_days
|
||||
)
|
||||
db.session.commit()
|
||||
updated_count += 1
|
||||
|
||||
logger.info(
|
||||
f"Reevaluated compliance for user {user.email} in org {organization_id}: "
|
||||
f"{old_status} -> {new_status}"
|
||||
)
|
||||
|
||||
return updated_count
|
||||
|
||||
@staticmethod
|
||||
def check_and_restore_user_status(user_id: str) -> bool:
|
||||
"""Check if user should be restored to ACTIVE status.
|
||||
|
||||
Called after compliance changes to determine if a COMPLIANCE_SUSPENDED
|
||||
user should be restored to ACTIVE status. This happens when:
|
||||
- All org policies are now compliant
|
||||
- User overrides were changed to EXEMPT
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
True if user status was restored, False otherwise
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
if user.status != UserStatus.COMPLIANCE_SUSPENDED:
|
||||
return False
|
||||
|
||||
# Evaluate user's overall compliance state
|
||||
compliance_summary = MfaPolicyService.evaluate_user_mfa_state(user)
|
||||
|
||||
# If now compliant across all orgs, restore status
|
||||
if compliance_summary.overall_status == MfaComplianceStatus.COMPLIANT.value:
|
||||
user.status = UserStatus.ACTIVE
|
||||
db.session.commit()
|
||||
|
||||
# Audit log
|
||||
AuditService.log_action(
|
||||
action=AuditAction.MFA_POLICY_USER_COMPLIANT,
|
||||
user_id=user_id,
|
||||
description="User restored to ACTIVE status after becoming MFA compliant",
|
||||
)
|
||||
|
||||
logger.info(f"User {user.email} restored to ACTIVE status")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# User Override Edge Case Handling
|
||||
# =========================================================================
|
||||
|
||||
@staticmethod
|
||||
def get_override_summary(user_id: str, organization_id: str) -> Dict[str, Any]:
|
||||
"""Get a summary of user override for an organization.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
organization_id: Organization ID
|
||||
|
||||
Returns:
|
||||
Dictionary with override information
|
||||
"""
|
||||
user_override = UserSecurityPolicy.query.filter_by(
|
||||
user_id=user_id, organization_id=organization_id, deleted_at=None
|
||||
).first()
|
||||
|
||||
org_policy = MfaPolicyService.get_org_policy(organization_id)
|
||||
|
||||
if not user_override:
|
||||
return {
|
||||
"has_override": False,
|
||||
"mode": "inherit",
|
||||
"org_policy_mode": org_policy.mfa_policy_mode if org_policy else "none",
|
||||
"effective_mode": org_policy.mfa_policy_mode if org_policy else "disabled",
|
||||
}
|
||||
|
||||
effective_policy = MfaPolicyService.get_effective_user_policy(
|
||||
user_id, organization_id
|
||||
)
|
||||
|
||||
return {
|
||||
"has_override": True,
|
||||
"mode": user_override.mfa_override_mode.value,
|
||||
"force_totp": user_override.force_totp,
|
||||
"force_webauthn": user_override.force_webauthn,
|
||||
"org_policy_mode": org_policy.mfa_policy_mode if org_policy else "none",
|
||||
"effective_mode": effective_policy.effective_mode,
|
||||
"is_exempt": effective_policy.is_exempt,
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Security Audit Logging
|
||||
# =========================================================================
|
||||
|
||||
@staticmethod
|
||||
def log_suspended_login_attempt(user: User, ip_address: str = None, user_agent: str = None):
|
||||
"""Log a login attempt by a compliance-suspended user.
|
||||
|
||||
This provides audit trail for potential security incidents where
|
||||
suspended users attempt to access the system.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
ip_address: Client IP address
|
||||
user_agent: Client user agent
|
||||
"""
|
||||
# Get current compliance summary
|
||||
compliance_summary = MfaPolicyService.evaluate_user_mfa_state(user)
|
||||
|
||||
# Find which org(s) caused suspension
|
||||
suspended_orgs = [
|
||||
org for org in compliance_summary.orgs
|
||||
if org.status == MfaComplianceStatus.SUSPENDED.value
|
||||
]
|
||||
|
||||
org_ids = [org.organization_id for org in suspended_orgs]
|
||||
|
||||
AuditService.log_action(
|
||||
action=AuditAction.USER_LOGIN,
|
||||
user_id=user.id,
|
||||
organization_id=org_ids[0] if org_ids else None,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
description=f"Login attempt while compliance suspended. Suspended orgs: {org_ids}",
|
||||
success=False,
|
||||
error_message="MFA compliance required",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_policy_bypass_attempt(
|
||||
user: User,
|
||||
endpoint: str,
|
||||
ip_address: str = None,
|
||||
user_agent: str = None,
|
||||
):
|
||||
"""Log a potential policy bypass attempt.
|
||||
|
||||
Called when a compliance-only session attempts to access a
|
||||
full-access endpoint. This could indicate security issues.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
endpoint: Requested endpoint
|
||||
ip_address: Client IP address
|
||||
user_agent: Client user agent
|
||||
"""
|
||||
AuditService.log_action(
|
||||
action=AuditAction.USER_LOGIN, # Reusing USER_LOGIN for audit
|
||||
user_id=user.id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
resource_type="endpoint",
|
||||
resource_id=endpoint,
|
||||
description=f"Policy bypass attempt - compliance-only session accessed {endpoint}",
|
||||
success=False,
|
||||
error_message="MFA compliance required",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_multi_org_aggregate_state(user: User) -> Dict[str, Any]:
|
||||
"""Get aggregate MFA state for a user across all organizations.
|
||||
|
||||
This provides detailed breakdown of how multi-org membership affects
|
||||
compliance status, useful for debugging and admin reporting.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
|
||||
Returns:
|
||||
Dictionary with aggregate state details
|
||||
"""
|
||||
compliance_summary = MfaPolicyService.evaluate_user_mfa_state(user)
|
||||
|
||||
# Calculate strictest requirement
|
||||
modes = [org.effective_mode for org in compliance_summary.orgs]
|
||||
strictest_mode = MfaPolicyService.get_strictest_mode(modes)
|
||||
|
||||
# Find organizations requiring MFA
|
||||
requiring_orgs = [
|
||||
{
|
||||
"organization_id": org.organization_id,
|
||||
"organization_name": org.organization_name,
|
||||
"mode": org.effective_mode,
|
||||
"status": org.status,
|
||||
}
|
||||
for org in compliance_summary.orgs
|
||||
if org.effective_mode not in (
|
||||
MfaPolicyMode.DISABLED.value,
|
||||
MfaPolicyMode.OPTIONAL.value,
|
||||
)
|
||||
]
|
||||
|
||||
# Find exempt organizations
|
||||
for org in compliance_summary.orgs:
|
||||
override_summary = MfaPolicyService.get_override_summary(
|
||||
user.id, org.organization_id
|
||||
)
|
||||
if override_summary.get("is_exempt"):
|
||||
requiring_orgs = [
|
||||
o for o in requiring_orgs
|
||||
if o["organization_id"] != org.organization_id
|
||||
]
|
||||
|
||||
return {
|
||||
"overall_status": compliance_summary.overall_status,
|
||||
"strictest_mode": strictest_mode,
|
||||
"missing_methods": compliance_summary.missing_methods,
|
||||
"deadline_at": compliance_summary.deadline_at,
|
||||
"requiring_org_count": len(requiring_orgs),
|
||||
"requiring_orgs": requiring_orgs,
|
||||
"total_org_count": len(compliance_summary.orgs),
|
||||
"per_org_details": [
|
||||
{
|
||||
"organization_id": org.organization_id,
|
||||
"organization_name": org.organization_name,
|
||||
"effective_mode": org.effective_mode,
|
||||
"status": org.status,
|
||||
"deadline_at": org.deadline_at,
|
||||
"applied_at": org.applied_at,
|
||||
}
|
||||
for org in compliance_summary.orgs
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
"""Notification Service for MFA compliance notifications.
|
||||
|
||||
This service handles sending MFA-related notifications to users, including:
|
||||
- Deadline reminder emails
|
||||
- Suspension notifications
|
||||
- Compliance status updates
|
||||
|
||||
The service is designed to work with or without email infrastructure:
|
||||
- If email is configured, it sends actual emails
|
||||
- If email is not available, it logs notifications for debugging/auditing
|
||||
|
||||
Usage:
|
||||
from gatehouse_app.services.notification_service import NotificationService
|
||||
NotificationService.send_mfa_deadline_reminder(user, compliance, org_policy)
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
import json
|
||||
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.mfa_policy_compliance import MfaPolicyCompliance
|
||||
from gatehouse_app.models.organization_security_policy import OrganizationSecurityPolicy
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
from gatehouse_app.utils.constants import AuditAction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for sending MFA compliance notifications."""
|
||||
|
||||
# Configuration keys for email settings
|
||||
EMAIL_ENABLED_KEY = "EMAIL_ENABLED"
|
||||
SMTP_HOST_KEY = "SMTP_HOST"
|
||||
SMTP_PORT_KEY = "SMTP_PORT"
|
||||
SMTP_USERNAME_KEY = "SMTP_USERNAME"
|
||||
SMTP_PASSWORD_KEY = "SMTP_PASSWORD"
|
||||
FROM_ADDRESS_KEY = "FROM_ADDRESS"
|
||||
|
||||
@staticmethod
|
||||
def send_mfa_deadline_reminder(
|
||||
user: User,
|
||||
compliance: MfaPolicyCompliance,
|
||||
org_policy: OrganizationSecurityPolicy,
|
||||
) -> bool:
|
||||
"""Send MFA deadline reminder notification to user.
|
||||
|
||||
Sends a reminder email to users who are approaching their MFA
|
||||
compliance deadline. The reminder includes:
|
||||
- Days remaining until deadline
|
||||
- Required MFA methods
|
||||
- Link to MFA enrollment
|
||||
|
||||
Args:
|
||||
user: User to notify
|
||||
compliance: User's compliance record
|
||||
org_policy: Organization's MFA policy
|
||||
|
||||
Returns:
|
||||
True if notification was sent successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Calculate days until deadline
|
||||
deadline = compliance.deadline_at
|
||||
if deadline.tzinfo is None:
|
||||
deadline = deadline.replace(tzinfo=timezone.utc)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
days_until_deadline = (deadline - now).days
|
||||
|
||||
# Build notification content
|
||||
subject = f"Action Required: MFA enrollment deadline in {days_until_deadline} days"
|
||||
body = NotificationService._build_deadline_reminder_body(
|
||||
user, compliance, org_policy, days_until_deadline
|
||||
)
|
||||
|
||||
# Send the notification
|
||||
success = NotificationService._send_email(
|
||||
to_address=user.email,
|
||||
subject=subject,
|
||||
body=body,
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(
|
||||
f"Sent MFA deadline reminder to {user.email} "
|
||||
f"({days_until_deadline} days remaining # Audit log
|
||||
)"
|
||||
)
|
||||
AuditService.log_action(
|
||||
action=AuditAction.MFA_POLICY_USER_COMPLIANT,
|
||||
user_id=user.id,
|
||||
organization_id=compliance.organization_id,
|
||||
description=f"MFA deadline reminder sent. Days remaining: {days_until_deadline}",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to send MFA deadline reminder to {user.email}"
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error sending MFA deadline reminder to {user.email}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def send_mfa_suspended_notification(
|
||||
user: User,
|
||||
compliance: MfaPolicyCompliance,
|
||||
org_policy: OrganizationSecurityPolicy,
|
||||
) -> bool:
|
||||
"""Send MFA suspension notification to user.
|
||||
|
||||
Notifies users that their account has been suspended due to
|
||||
failure to comply with MFA requirements. The notification includes:
|
||||
- Explanation of suspension
|
||||
- Steps to restore access
|
||||
- Link to MFA enrollment
|
||||
|
||||
Args:
|
||||
user: User to notify
|
||||
compliance: User's compliance record
|
||||
org_policy: Organization's MFA policy
|
||||
|
||||
Returns:
|
||||
True if notification was sent successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Build notification content
|
||||
subject = "Account Access Restricted - MFA Enrollment Required"
|
||||
body = NotificationService._build_suspension_body(
|
||||
user, compliance, org_policy
|
||||
)
|
||||
|
||||
# Send the notification
|
||||
success = NotificationService._send_email(
|
||||
to_address=user.email,
|
||||
subject=subject,
|
||||
body=body,
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"Sent MFA suspension notification to {user.email}")
|
||||
# Audit log
|
||||
AuditService.log_action(
|
||||
action=AuditAction.MFA_POLICY_USER_SUSPENDED,
|
||||
user_id=user.id,
|
||||
organization_id=compliance.organization_id,
|
||||
description="MFA compliance suspension notification sent",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to send MFA suspension notification to {user.email}"
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Error sending MFA suspension notification to {user.email}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _build_deadline_reminder_body(
|
||||
user: User,
|
||||
compliance: MfaPolicyCompliance,
|
||||
org_policy: OrganizationSecurityPolicy,
|
||||
days_until_deadline: int,
|
||||
) -> str:
|
||||
"""Build the email body for deadline reminder.
|
||||
|
||||
Args:
|
||||
user: User being notified
|
||||
compliance: Compliance record
|
||||
org_policy: Organization policy
|
||||
days_until_deadline: Days remaining until deadline
|
||||
|
||||
Returns:
|
||||
Email body string
|
||||
"""
|
||||
org_name = compliance.organization_id # In real impl, fetch org name
|
||||
|
||||
body = f"""
|
||||
Dear {user.full_name or user.email},
|
||||
|
||||
This is a reminder that you need to set up multi-factor authentication (MFA)
|
||||
to maintain access to your account in the organization "{org_name}".
|
||||
|
||||
**Important Details:**
|
||||
- Days remaining: {days_until_deadline}
|
||||
- Deadline: {compliance.deadline_at.strftime('%Y-%m-%d %H:%M UTC') if compliance.deadline_at else 'Not set'}
|
||||
|
||||
**Required MFA Methods:**
|
||||
"""
|
||||
|
||||
# Add required methods based on policy mode
|
||||
from gatehouse_app.utils.constants import MfaPolicyMode
|
||||
|
||||
mode = org_policy.mfa_policy_mode
|
||||
if mode == MfaPolicyMode.REQUIRE_TOTP:
|
||||
body += "- Authenticator app (TOTP)\n"
|
||||
elif mode == MfaPolicyMode.REQUIRE_WEBAUTHN:
|
||||
body += "- Passkey (WebAuthn)\n"
|
||||
elif mode == MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN:
|
||||
body += "- Authenticator app (TOTP) OR Passkey (WebAuthn)\n"
|
||||
else:
|
||||
body += "- Multi-factor authentication\n"
|
||||
|
||||
body += """
|
||||
**How to Set Up MFA:**
|
||||
1. Log in to your account
|
||||
2. Navigate to Settings > Security
|
||||
3. Follow the prompts to set up an authenticator app or passkey
|
||||
|
||||
If you do not set up MFA by the deadline, your account access will be restricted.
|
||||
|
||||
If you have any questions, please contact your organization administrator.
|
||||
|
||||
Best regards,
|
||||
Gatehouse Security Team
|
||||
"""
|
||||
return body
|
||||
|
||||
@staticmethod
|
||||
def _build_suspension_body(
|
||||
user: User,
|
||||
compliance: MfaPolicyCompliance,
|
||||
org_policy: OrganizationSecurityPolicy,
|
||||
) -> str:
|
||||
"""Build the email body for suspension notification.
|
||||
|
||||
Args:
|
||||
user: User being notified
|
||||
compliance: Compliance record
|
||||
org_policy: Organization policy
|
||||
|
||||
Returns:
|
||||
Email body string
|
||||
"""
|
||||
org_name = compliance.organization_id # In real impl, fetch org name
|
||||
|
||||
body = f"""
|
||||
Dear {user.full_name or user.email},
|
||||
|
||||
Your account access has been restricted because you did not set up
|
||||
multi-factor authentication (MFA) within the required timeframe for
|
||||
the organization "{org_name}".
|
||||
|
||||
**What Happened:**
|
||||
Your MFA compliance deadline passed without MFA being configured.
|
||||
As a result, your account has been placed in a suspended state.
|
||||
|
||||
**How to Restore Access:**
|
||||
1. Log in to your account (you will see a compliance enrollment screen)
|
||||
2. Follow the prompts to set up an authenticator app or passkey
|
||||
3. Once MFA is configured, your access will be restored
|
||||
|
||||
**Required MFA Methods:
|
||||
"""
|
||||
|
||||
# Add required methods based on policy mode
|
||||
from gatehouse_app.utils.constants import MfaPolicyMode
|
||||
|
||||
mode = org_policy.mfa_policy_mode
|
||||
if mode == MfaPolicyMode.REQUIRE_TOTP:
|
||||
body += "- Authenticator app (TOTP)\n"
|
||||
elif mode == MfaPolicyMode.REQUIRE_WEBAUTHN:
|
||||
body += "- Passkey (WebAuthn)\n"
|
||||
elif mode == MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN:
|
||||
body += "- Authenticator app (TOTP) OR Passkey (WebAuthn)\n"
|
||||
else:
|
||||
body += "- Multi-factor authentication\n"
|
||||
|
||||
body += """
|
||||
**Need Help?**
|
||||
Contact your organization administrator if you have questions.
|
||||
|
||||
Best regards,
|
||||
Gatehouse Security Team
|
||||
"""
|
||||
return body
|
||||
|
||||
@staticmethod
|
||||
def _send_email(
|
||||
to_address: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Send an email notification.
|
||||
|
||||
This method attempts to send an email using configured SMTP settings.
|
||||
If email is not configured, it logs the notification instead.
|
||||
|
||||
Args:
|
||||
to_address: Recipient email address
|
||||
subject: Email subject
|
||||
body: Plain text email body
|
||||
html_body: Optional HTML email body
|
||||
|
||||
Returns:
|
||||
True if email was sent (or logged), False on error
|
||||
"""
|
||||
try:
|
||||
from flask import current_app
|
||||
|
||||
# Check if email is configured
|
||||
email_enabled = current_app.config.get(
|
||||
NotificationService.EMAIL_ENABLED_KEY, False
|
||||
)
|
||||
|
||||
if not email_enabled:
|
||||
# Log the notification instead of sending
|
||||
logger.info(
|
||||
f"[EMAIL SIMULATION] To: {to_address}\n"
|
||||
f"Subject: {subject}\n"
|
||||
f"Body: {body[:200]}..." if len(body) > 200 else f"Body: {body}"
|
||||
)
|
||||
return True
|
||||
|
||||
# Get email configuration
|
||||
smtp_host = current_app.config.get(NotificationService.SMTP_HOST_KEY)
|
||||
smtp_port = current_app.config.get(NotificationService.SMTP_PORT_KEY, 587)
|
||||
smtp_username = current_app.config.get(NotificationService.SMTP_USERNAME_KEY)
|
||||
smtp_password = current_app.config.get(NotificationService.SMTP_PASSWORD_KEY)
|
||||
from_address = current_app.config.get(
|
||||
NotificationService.FROM_ADDRESS_KEY, "noreply@gatehouse.local"
|
||||
)
|
||||
|
||||
# Import send_email based on available mail library
|
||||
try:
|
||||
from flask_mail import Message
|
||||
|
||||
from gatehouse_app import mail
|
||||
|
||||
msg = Message(
|
||||
subject=subject,
|
||||
recipients=[to_address],
|
||||
body=body,
|
||||
html=html_body,
|
||||
sender=from_address,
|
||||
)
|
||||
mail.send(msg)
|
||||
logger.info(f"Email sent successfully to {to_address}")
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
# Flask-Mail not available, use SMTP directly
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = from_address
|
||||
msg["To"] = to_address
|
||||
|
||||
# Attach plain text and HTML versions
|
||||
part1 = MIMEText(body, "plain")
|
||||
msg.attach(part1)
|
||||
|
||||
if html_body:
|
||||
part2 = MIMEText(html_body, "html")
|
||||
msg.attach(part2)
|
||||
|
||||
# Send via SMTP
|
||||
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
||||
server.starttls()
|
||||
if smtp_username and smtp_password:
|
||||
server.login(smtp_username, smtp_password)
|
||||
server.send_message(msg)
|
||||
|
||||
logger.info(f"Email sent successfully to {to_address}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to send email to {to_address}: {e}")
|
||||
# Log the notification as fallback
|
||||
logger.info(
|
||||
f"[EMAIL FALLBACK] To: {to_address}\n"
|
||||
f"Subject: {subject}\n"
|
||||
f"Body: {body[:500]}..." if len(body) > 500 else f"Body: {body}"
|
||||
)
|
||||
return True # Return True to continue processing
|
||||
|
||||
@staticmethod
|
||||
def get_notification_stats(user_id: str) -> Dict[str, Any]:
|
||||
"""Get notification statistics for a user.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Dictionary with notification statistics
|
||||
"""
|
||||
from gatehouse_app.models.mfa_policy_compliance import MfaPolicyCompliance
|
||||
|
||||
stats = {
|
||||
"total_notifications": 0,
|
||||
"last_notification": None,
|
||||
"by_organization": [],
|
||||
}
|
||||
|
||||
compliance_records = MfaPolicyCompliance.query.filter_by(
|
||||
user_id=user_id, deleted_at=None
|
||||
).all()
|
||||
|
||||
total_notifications = 0
|
||||
last_notification = None
|
||||
|
||||
for record in compliance_records:
|
||||
total_notifications += record.notification_count
|
||||
if record.last_notified_at:
|
||||
if last_notification is None or record.last_notified_at > last_notification:
|
||||
last_notification = record.last_notified_at
|
||||
|
||||
stats["by_organization"].append({
|
||||
"organization_id": record.organization_id,
|
||||
"notification_count": record.notification_count,
|
||||
"last_notified_at": record.last_notified_at.isoformat() if record.last_notified_at else None,
|
||||
})
|
||||
|
||||
stats["total_notifications"] = total_notifications
|
||||
stats["last_notification"] = last_notification.isoformat() if last_notification else None
|
||||
|
||||
return stats
|
||||
Reference in New Issue
Block a user