enable policies

This commit is contained in:
2026-01-16 17:31:20 +10:30
parent b2e084db33
commit d063a0ca81
28 changed files with 4296 additions and 224 deletions
+3 -1
View File
@@ -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