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
+18
View File
@@ -16,6 +16,7 @@ from gatehouse_app.services.oidc_service import (
OIDCService, InvalidClientError, InvalidGrantError, InvalidRequestError
)
from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.extensions import db
from gatehouse_app.extensions import bcrypt as flask_bcrypt
from gatehouse_app.models import User, OIDCClient
@@ -372,6 +373,23 @@ def oidc_authorize():
logger.debug("[OIDC] Attempting user authentication for email: %s", email)
try:
user = AuthService.authenticate(email, password)
# Evaluate MFA policy after primary authentication
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me=False)
# Check if user can create full session
if not policy_result.can_create_full_session:
logger.debug("[OIDC] User cannot create full session due to MFA compliance: user_id=%s, email=%s", user.id, email)
return _show_login_page(
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
state=state,
nonce=nonce,
response_type=response_type,
error="Your account requires multi factor enrollment before using single sign on"
)
user_id = user.id
session["oidc_user_id"] = user_id
+1 -1
View File
@@ -5,4 +5,4 @@ from flask import Blueprint
api_v1_bp = Blueprint("api_v1", __name__)
# Import route modules to register them
from gatehouse_app.api.v1 import auth, users, organizations
from gatehouse_app.api.v1 import auth, users, organizations, policies
+133 -24
View File
@@ -22,6 +22,7 @@ from gatehouse_app.schemas.webauthn_schema import (
from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.webauthn_service import WebAuthnService
from gatehouse_app.services.user_service import UserService
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.utils.decorators import login_required
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
@@ -94,6 +95,9 @@ def login():
400: Validation error
401: Invalid credentials
"""
import logging
logger = logging.getLogger(__name__)
try:
# Validate request data
schema = LoginSchema()
@@ -105,6 +109,11 @@ def login():
password=data["password"],
)
# SECURITY CHECK: Log MFA enrollment status to validate the vulnerability
has_totp = user.has_totp_enabled()
has_webauthn = user.has_webauthn_enabled()
logger.warning(f"[SECURITY DIAGNOSTIC] Login attempt for user {user.email} - TOTP enabled: {has_totp}, WebAuthn enabled: {has_webauthn}")
# Check if user has TOTP enabled for two-factor authentication
if user.has_totp_enabled():
# TOTP is enabled - store user_id in session for TOTP verification
@@ -121,16 +130,56 @@ def login():
)
# TOTP is NOT enabled - proceed with normal login flow
# SECURITY DIAGNOSTIC: This is where the vulnerability occurs - no WebAuthn check!
if has_webauthn:
logger.error(f"[SECURITY VULNERABILITY DETECTED] User {user.email} has WebAuthn enrolled but is bypassing it! Creating session without MFA verification.")
# Evaluate MFA policy after primary authentication
remember_me = data.get("remember_me", False)
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me)
# Create session with appropriate duration based on remember_me preference
duration = 2592000 if data.get("remember_me") else 86400 # 30 days vs 1 day
user_session = AuthService.create_session(user, duration_seconds=duration)
duration = 2592000 if remember_me else 86400 # 30 days vs 1 day
# Determine if this should be a compliance-only session
is_compliance_only = policy_result.create_compliance_only_session
user_session = AuthService.create_session(
user,
duration_seconds=duration,
is_compliance_only=is_compliance_only
)
# Build response data
response_data = {
"user": user.to_dict(),
"token": user_session.token,
"expires_at": user_session.expires_at.isoformat() + "Z" if user_session.expires_at.isoformat()[-1] != "Z" else user_session.expires_at.isoformat(),
}
# Add MFA compliance information
if policy_result.compliance_summary:
response_data["mfa_compliance"] = {
"overall_status": policy_result.compliance_summary.overall_status,
"missing_methods": policy_result.compliance_summary.missing_methods,
"deadline_at": policy_result.compliance_summary.deadline_at,
"orgs": [
{
"organization_id": org.organization_id,
"organization_name": org.organization_name,
"status": org.status,
"deadline_at": org.deadline_at,
}
for org in policy_result.compliance_summary.orgs
],
}
# Add requires_mfa_enrollment flag if compliance-only session
if is_compliance_only:
response_data["requires_mfa_enrollment"] = True
return api_response(
data={
"user": user.to_dict(),
"token": user_session.token,
"expires_at": user_session.expires_at.isoformat() + "Z" if user_session.expires_at.isoformat()[-1] != "Z" else user_session.expires_at.isoformat(),
},
data=response_data,
message="Login successful",
)
@@ -380,20 +429,50 @@ def verify_totp():
client_utc_timestamp=data.get("client_timestamp"),
)
# Create full session
user_session = AuthService.create_session(user)
# Evaluate MFA policy after primary authentication
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me=False)
# Determine if this should be a compliance-only session
is_compliance_only = policy_result.create_compliance_only_session
# Create session
user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only)
# Clear temporary session
session.pop("totp_pending_user_id", None)
# Build response data
response_data = {
"user": user.to_dict(),
"token": user_session.token,
"expires_at": user_session.expires_at.isoformat() + "Z"
if user_session.expires_at.isoformat()[-1] != "Z"
else user_session.expires_at.isoformat(),
}
# Add MFA compliance information
if policy_result.compliance_summary:
response_data["mfa_compliance"] = {
"overall_status": policy_result.compliance_summary.overall_status,
"missing_methods": policy_result.compliance_summary.missing_methods,
"deadline_at": policy_result.compliance_summary.deadline_at,
"orgs": [
{
"organization_id": org.organization_id,
"organization_name": org.organization_name,
"status": org.status,
"deadline_at": org.deadline_at,
}
for org in policy_result.compliance_summary.orgs
],
}
# Add requires_mfa_enrollment flag if compliance-only session
if is_compliance_only:
response_data["requires_mfa_enrollment"] = True
return api_response(
data={
"user": user.to_dict(),
"token": user_session.token,
"expires_at": user_session.expires_at.isoformat() + "Z"
if user_session.expires_at.isoformat()[-1] != "Z"
else user_session.expires_at.isoformat(),
},
data=response_data,
message="TOTP verification successful",
)
@@ -835,22 +914,52 @@ def complete_webauthn_login():
challenge
)
# Evaluate MFA policy after primary authentication
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me=False)
# Determine if this should be a compliance-only session
is_compliance_only = policy_result.create_compliance_only_session
# Create session
user_session = AuthService.create_session(user)
user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only)
# Clear pending session
session.pop("webauthn_pending_user_id", None)
logger.info(f"WebAuthn login completed successfully for user: {user.email}")
# Build response data
response_data = {
"user": user.to_dict(),
"token": user_session.token,
"expires_at": user_session.expires_at.isoformat() + "Z"
if user_session.expires_at.isoformat()[-1] != "Z"
else user_session.expires_at.isoformat(),
}
# Add MFA compliance information
if policy_result.compliance_summary:
response_data["mfa_compliance"] = {
"overall_status": policy_result.compliance_summary.overall_status,
"missing_methods": policy_result.compliance_summary.missing_methods,
"deadline_at": policy_result.compliance_summary.deadline_at,
"orgs": [
{
"organization_id": org.organization_id,
"organization_name": org.organization_name,
"status": org.status,
"deadline_at": org.deadline_at,
}
for org in policy_result.compliance_summary.orgs
],
}
# Add requires_mfa_enrollment flag if compliance-only session
if is_compliance_only:
response_data["requires_mfa_enrollment"] = True
return api_response(
data={
"user": user.to_dict(),
"token": user_session.token,
"expires_at": user_session.expires_at.isoformat() + "Z"
if user_session.expires_at.isoformat()[-1] != "Z"
else user_session.expires_at.isoformat(),
},
data=response_data,
message="Login successful",
)
+9 -1
View File
@@ -3,7 +3,7 @@ from flask import g, request
from marshmallow import ValidationError
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.decorators import login_required, require_admin, require_owner
from gatehouse_app.utils.decorators import login_required, require_admin, require_owner, full_access_required
from gatehouse_app.schemas.organization_schema import (
OrganizationCreateSchema,
OrganizationUpdateSchema,
@@ -17,6 +17,7 @@ from gatehouse_app.utils.constants import OrganizationRole
@api_v1_bp.route("/organizations", methods=["POST"])
@login_required
@full_access_required
def create_organization():
"""
Create a new organization.
@@ -65,6 +66,7 @@ def create_organization():
@api_v1_bp.route("/organizations/<org_id>", methods=["GET"])
@login_required
@full_access_required
def get_organization(org_id):
"""
Get organization by ID.
@@ -101,6 +103,7 @@ def get_organization(org_id):
@api_v1_bp.route("/organizations/<org_id>", methods=["PATCH"])
@login_required
@require_admin
@full_access_required
def update_organization(org_id):
"""
Update organization.
@@ -152,6 +155,7 @@ def update_organization(org_id):
@api_v1_bp.route("/organizations/<org_id>", methods=["DELETE"])
@login_required
@require_owner
@full_access_required
def delete_organization(org_id):
"""
Delete organization (soft delete).
@@ -180,6 +184,7 @@ def delete_organization(org_id):
@api_v1_bp.route("/organizations/<org_id>/members", methods=["GET"])
@login_required
@full_access_required
def get_organization_members(org_id):
"""
Get all members of an organization.
@@ -223,6 +228,7 @@ def get_organization_members(org_id):
@api_v1_bp.route("/organizations/<org_id>/members", methods=["POST"])
@login_required
@require_admin
@full_access_required
def add_organization_member(org_id):
"""
Add a member to the organization.
@@ -290,6 +296,7 @@ def add_organization_member(org_id):
@api_v1_bp.route("/organizations/<org_id>/members/<user_id>", methods=["DELETE"])
@login_required
@require_admin
@full_access_required
def remove_organization_member(org_id, user_id):
"""
Remove a member from the organization.
@@ -320,6 +327,7 @@ def remove_organization_member(org_id, user_id):
@api_v1_bp.route("/organizations/<org_id>/members/<user_id>/role", methods=["PATCH"])
@login_required
@require_admin
@full_access_required
def update_member_role(org_id, user_id):
"""
Update a member's role.
+336
View File
@@ -0,0 +1,336 @@
"""Security policy endpoints."""
from flask import g, request
from marshmallow import Schema, fields, validate, ValidationError
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.utils.constants import MfaPolicyMode, MfaRequirementOverride, MfaComplianceStatus, AuditAction
class UpdateOrgPolicySchema(Schema):
"""Schema for updating organization security policy."""
mfa_policy_mode = fields.String(
required=False,
validate=validate.OneOf([m.value for m in MfaPolicyMode])
)
mfa_grace_period_days = fields.Integer(
required=False,
validate=validate.Range(min=0, max=365)
)
notify_days_before = fields.Integer(
required=False,
validate=validate.Range(min=0, max=30)
)
class UpdateUserPolicySchema(Schema):
"""Schema for updating user security policy override."""
mfa_override_mode = fields.String(
required=True,
validate=validate.OneOf([m.value for m in MfaRequirementOverride])
)
force_totp = fields.Boolean(required=False, load_default=False)
force_webauthn = fields.Boolean(required=False, load_default=False)
class ComplianceListQuerySchema(Schema):
"""Schema for compliance list query parameters."""
status = fields.String(required=False)
limit = fields.Integer(required=False, load_default=100)
offset = fields.Integer(required=False, load_default=0)
@api_v1_bp.route("/organizations/<org_id>/security-policy", methods=["GET"])
@login_required
def get_org_security_policy(org_id):
"""
Get organization security policy.
Args:
org_id: Organization ID
Returns:
200: Organization security policy
401: Not authenticated
403: Not a member
404: Organization not found
"""
org = OrganizationService.get_organization_by_id(org_id)
# Check if user is a member
if not org.is_member(g.current_user.id):
return api_response(
success=False,
message="You are not a member of this organization",
status=403,
error_type="AUTHORIZATION_ERROR",
)
policy_dto = MfaPolicyService.get_org_policy(org_id)
if policy_dto:
data = {
"organization_id": policy_dto.organization_id,
"mfa_policy_mode": policy_dto.mfa_policy_mode,
"mfa_grace_period_days": policy_dto.mfa_grace_period_days,
"notify_days_before": policy_dto.notify_days_before,
"policy_version": policy_dto.policy_version,
}
else:
# Return default policy if none exists
data = {
"organization_id": org_id,
"mfa_policy_mode": MfaPolicyMode.OPTIONAL.value,
"mfa_grace_period_days": 14,
"notify_days_before": 7,
"policy_version": 0,
}
return api_response(
data={"security_policy": data},
message="Security policy retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/security-policy", methods=["PUT"])
@login_required
@require_admin
@full_access_required
def update_org_security_policy(org_id):
"""
Update organization security policy.
Args:
org_id: Organization ID
Request body:
mfa_policy_mode: MFA policy mode (disabled, optional, require_totp, require_webauthn, require_totp_or_webauthn)
mfa_grace_period_days: Grace period in days (0-365)
notify_days_before: Days before deadline to notify (0-30)
Returns:
200: Security policy updated successfully
400: Validation error
401: Not authenticated
403: Not an admin
404: Organization not found
"""
try:
schema = UpdateOrgPolicySchema()
data = schema.load(request.json)
org = OrganizationService.get_organization_by_id(org_id)
# Update policy
policy = MfaPolicyService.create_org_policy(
organization_id=org_id,
mfa_policy_mode=MfaPolicyMode(data.get("mfa_policy_mode", MfaPolicyMode.OPTIONAL.value)),
mfa_grace_period_days=data.get("mfa_grace_period_days", 14),
notify_days_before=data.get("notify_days_before", 7),
updated_by_user_id=g.current_user.id,
)
return api_response(
data={
"security_policy": {
"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,
}
},
message="Security policy updated successfully",
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
@api_v1_bp.route("/organizations/<org_id>/mfa-compliance", methods=["GET"])
@login_required
@require_admin
@full_access_required
def get_org_mfa_compliance(org_id):
"""
Get MFA compliance list for an organization.
Args:
org_id: Organization ID
Query params:
status: Optional status filter (not_applicable, pending, in_grace, compliant, past_due, suspended)
limit: Maximum records to return (default 100)
offset: Offset for pagination (default 0)
Returns:
200: List of compliance records
401: Not authenticated
403: Not an admin
404: Organization not found
"""
org = OrganizationService.get_organization_by_id(org_id)
# Parse query params
status = None
if request.args.get("status"):
try:
status = MfaComplianceStatus(request.args.get("status"))
except ValueError:
return api_response(
success=False,
message="Invalid status value",
status=400,
error_type="VALIDATION_ERROR",
)
limit = min(int(request.args.get("limit", 100)), 100)
offset = int(request.args.get("offset", 0))
compliance_list = MfaPolicyService.get_org_compliance_list(
organization_id=org_id,
status=status,
limit=limit,
offset=offset,
)
return api_response(
data={
"compliance": compliance_list,
"count": len(compliance_list),
"limit": limit,
"offset": offset,
},
message="Compliance records retrieved successfully",
)
@api_v1_bp.route(
"/organizations/<org_id>/users/<user_id>/security-policy", methods=["PATCH"]
)
@login_required
@require_admin
@full_access_required
def update_user_security_policy(org_id, user_id):
"""
Update user security policy override.
Args:
org_id: Organization ID
user_id: User ID
Request body:
mfa_override_mode: Override mode (inherit, required, exempt)
force_totp: Force TOTP requirement (default false)
force_webauthn: Force WebAuthn requirement (default false)
Returns:
200: User policy updated successfully
400: Validation error
401: Not authenticated
403: Not an admin
404: Organization or user not found
"""
try:
schema = UpdateUserPolicySchema()
data = schema.load(request.json)
org = OrganizationService.get_organization_by_id(org_id)
# Check if user is a member of the organization
if not org.is_member(user_id):
return api_response(
success=False,
message="User is not a member of this organization",
status=404,
error_type="NOT_FOUND",
)
# Update user policy
policy = MfaPolicyService.set_user_override(
user_id=user_id,
organization_id=org_id,
mfa_override_mode=MfaRequirementOverride(data["mfa_override_mode"]),
force_totp=data.get("force_totp", False),
force_webauthn=data.get("force_webauthn", False),
updated_by_user_id=g.current_user.id,
)
# Log the override change with details
AuditService.log_action(
action=AuditAction.USER_SECURITY_POLICY_OVERRIDE_UPDATE,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="user",
resource_id=user_id,
description=f"User security policy override changed to {data['mfa_override_mode']} for user {user_id}",
)
return api_response(
data={
"user_security_policy": {
"user_id": policy.user_id,
"organization_id": policy.organization_id,
"mfa_override_mode": policy.mfa_override_mode.value,
"force_totp": policy.force_totp,
"force_webauthn": policy.force_webauthn,
}
},
message="User security policy updated successfully",
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
@api_v1_bp.route("/users/me/mfa-compliance", methods=["GET"])
@login_required
def get_my_mfa_compliance():
"""
Get current user's MFA compliance across all organizations.
Returns:
200: MFA compliance summary
401: Not authenticated
"""
user = g.current_user
compliance_summary = MfaPolicyService.evaluate_user_mfa_state(user)
orgs = []
for org_state in compliance_summary.orgs:
orgs.append({
"organization_id": org_state.organization_id,
"organization_name": org_state.organization_name,
"status": org_state.status,
"effective_mode": org_state.effective_mode,
"deadline_at": org_state.deadline_at,
"applied_at": org_state.applied_at,
})
return api_response(
data={
"mfa_compliance": {
"overall_status": compliance_summary.overall_status,
"missing_methods": compliance_summary.missing_methods,
"deadline_at": compliance_summary.deadline_at,
"orgs": orgs,
}
},
message="MFA compliance retrieved successfully",
)
+5 -1
View File
@@ -3,7 +3,7 @@ from flask import g, request
from marshmallow import ValidationError
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.decorators import login_required
from gatehouse_app.utils.decorators import login_required, full_access_required
from gatehouse_app.schemas.user_schema import UserUpdateSchema, ChangePasswordSchema
from gatehouse_app.services.user_service import UserService
from gatehouse_app.services.auth_service import AuthService
@@ -29,6 +29,7 @@ def get_me():
@api_v1_bp.route("/users/me", methods=["PATCH"])
@login_required
@full_access_required
def update_me():
"""
Update current user profile.
@@ -67,6 +68,7 @@ def update_me():
@api_v1_bp.route("/users/me", methods=["DELETE"])
@login_required
@full_access_required
def delete_me():
"""
Delete current user account (soft delete).
@@ -84,6 +86,7 @@ def delete_me():
@api_v1_bp.route("/users/me/password", methods=["POST"])
@login_required
@full_access_required
def change_password():
"""
Change current user password.
@@ -136,6 +139,7 @@ def change_password():
@api_v1_bp.route("/users/me/organizations", methods=["GET"])
@login_required
@full_access_required
def get_my_organizations():
"""
Get all organizations current user is a member of.
+1
View File
@@ -0,0 +1 @@
Jobs module for scheduled tasks.
+279
View File
@@ -0,0 +1,279 @@
"""MFA Compliance Scheduled Job.
This module implements the scheduled job for processing MFA compliance transitions,
sending notifications to users approaching deadlines, and handling edge cases.
The job is designed to be run periodically (e.g., via cron) to:
1. Transition users from PAST_DUE to SUSPENDED status
2. Send deadline reminder notifications to users in grace period
3. Update notification tracking metadata
Usage:
python manage.py run_mfa_compliance_job
Or call directly:
from gatehouse_app.jobs.mfa_compliance_job import process_mfa_compliance
process_mfa_compliance()
"""
from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, Any, List
import logging
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.mfa_policy_service import MfaPolicyService
from gatehouse_app.services.notification_service import NotificationService
from gatehouse_app.utils.constants import MfaComplianceStatus
logger = logging.getLogger(__name__)
def process_mfa_compliance(now: Optional[datetime] = None) -> Dict[str, Any]:
"""Process MFA compliance transitions and send notifications.
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
Args:
now: Current time, defaults to now (UTC)
Returns:
Dictionary with job execution statistics:
- suspended_count: Number of users transitioned to suspended
- notified_count: Number of notifications sent
- processed_count: Total compliance records processed
"""
if now is None:
now = datetime.now(timezone.utc)
logger.info(f"Starting MFA compliance job at {now.isoformat()}")
stats = {
"suspended_count": 0,
"notified_count": 0,
"processed_count": 0,
"errors": [],
}
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")
# Step 2: 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
processed_count = _evaluate_pending_compliance(now)
stats["processed_count"] = processed_count
logger.info(f"Processed {processed_count} compliance records")
except Exception as e:
logger.exception(f"Error during MFA compliance job: {e}")
stats["errors"].append(str(e))
logger.info(
f"MFA compliance job completed: suspended={stats['suspended_count']}, "
f"notified={stats['notified_count']}, processed={stats['processed_count']}"
)
return stats
def _send_deadline_reminders(now: datetime) -> int:
"""Send deadline reminder notifications to users approaching deadline.
Identifies users in grace period who are within their organization's
notify_days_before threshold and sends them reminder notifications.
Args:
now: Current time (UTC)
Returns:
Number of notifications sent
"""
notified_count = 0
# Find all compliance records in grace period
grace_records = MfaPolicyCompliance.query.filter(
MfaPolicyCompliance.status == MfaComplianceStatus.IN_GRACE,
MfaPolicyCompliance.deadline_at != None,
MfaPolicyCompliance.deleted_at == None,
).all()
for record in grace_records:
try:
# Get organization policy for notify_days_before
org_policy = OrganizationSecurityPolicy.query.filter_by(
organization_id=record.organization_id, deleted_at=None
).first()
if not org_policy:
continue
notify_threshold = org_policy.notify_days_before
deadline = record.deadline_at
# Ensure deadline has timezone
if deadline.tzinfo is None:
deadline = deadline.replace(tzinfo=timezone.utc)
# Calculate time until deadline
time_until_deadline = deadline - now
days_until_deadline = time_until_deadline.total_seconds() / 86400
# Check if we should send a reminder
should_notify = False
if days_until_deadline <= notify_threshold:
# Check if we've already notified recently (within last 24 hours)
if record.last_notified_at:
hours_since_notification = (
now - record.last_notified_at
).total_seconds() / 3600
if hours_since_notification < 24:
continue # Already notified recently
should_notify = True
if should_notify:
# Get user
user = User.query.get(record.user_id)
if not user:
continue
# Send notification
success = NotificationService.send_mfa_deadline_reminder(
user=user,
compliance=record,
org_policy=org_policy,
)
if success:
# Update notification tracking
record.last_notified_at = now
record.notification_count += 1
db.session.commit()
notified_count += 1
logger.info(
f"Sent deadline reminder to user {user.email} "
f"(days until deadline: {days_until_deadline:.1f})"
)
except Exception as e:
logger.warning(
f"Error sending reminder for compliance record "
f"{record.id}: {e}"
)
continue
return notified_count
def _evaluate_pending_compliance(now: datetime) -> int:
"""Evaluate and update pending compliance records.
This handles edge cases where compliance records may need
status updates due to policy changes or other factors.
Args:
now: Current time (UTC)
Returns:
Number of records processed
"""
processed_count = 0
# Find all non-deleted compliance records
records = MfaPolicyCompliance.query.filter(
MfaPolicyCompliance.deleted_at == None,
).all()
for record in records:
try:
# Get the user and evaluate their current state
user = User.query.get(record.user_id)
if not user:
continue
# Re-evaluate compliance status
# This handles cases where policy changed or user enrolled in MFA
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
effective_policy = MfaPolicyService.get_effective_user_policy(
user.id, record.organization_id
)
new_status = MfaPolicyService._evaluate_compliance_status(
user, effective_policy, record
)
# Update status if changed
if record.status != new_status:
old_status = record.status.value if hasattr(record.status, 'value') else str(record.status)
record.status = MfaComplianceStatus(new_status)
db.session.commit()
logger.info(
f"Updated compliance status for user {user.email} "
f"in org {record.organization_id}: {old_status} -> {new_status}"
)
processed_count += 1
except Exception as e:
logger.warning(
f"Error evaluating compliance record {record.id}: {e}"
)
continue
return processed_count
def get_job_status(now: Optional[datetime] = None) -> Dict[str, Any]:
"""Get current status of MFA compliance for monitoring.
Args:
now: Current time, defaults to now (UTC)
Returns:
Dictionary with compliance statistics
"""
if now is None:
now = datetime.now(timezone.utc)
# Count records by status
status_counts = {}
for status in MfaComplianceStatus:
count = MfaPolicyCompliance.query.filter(
MfaPolicyCompliance.status == status,
MfaPolicyCompliance.deleted_at == None,
).count()
status_counts[status.value] = count
# Count users approaching deadline (within 7 days by default)
approaching_deadline = MfaPolicyCompliance.query.filter(
MfaPolicyCompliance.status == MfaComplianceStatus.IN_GRACE,
MfaPolicyCompliance.deadline_at != None,
MfaPolicyCompliance.deleted_at == None,
).count()
# Count past-due records
past_due_count = MfaPolicyCompliance.query.filter(
MfaPolicyCompliance.status == MfaComplianceStatus.PAST_DUE,
MfaPolicyCompliance.deleted_at == None,
).count()
return {
"status_counts": status_counts,
"approaching_deadline_count": approaching_deadline,
"past_due_count": past_due_count,
"timestamp": now.isoformat(),
}
+6
View File
@@ -12,6 +12,9 @@ from gatehouse_app.models.oidc_refresh_token import OIDCRefreshToken
from gatehouse_app.models.oidc_session import OIDCSession
from gatehouse_app.models.oidc_token_metadata import OIDCTokenMetadata
from gatehouse_app.models.oidc_audit_log import OIDCAuditLog
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
__all__ = [
"BaseModel",
@@ -27,4 +30,7 @@ __all__ = [
"OIDCSession",
"OIDCTokenMetadata",
"OIDCAuditLog",
"OrganizationSecurityPolicy",
"UserSecurityPolicy",
"MfaPolicyCompliance",
]
@@ -0,0 +1,66 @@
"""MfaPolicyCompliance model."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import MfaComplianceStatus
class MfaPolicyCompliance(BaseModel):
"""MFA policy compliance tracking per user per organization.
Tracks each user's MFA compliance state separately for each organization membership.
"""
__tablename__ = "mfa_policy_compliance"
user_id = db.Column(
db.String(36), db.ForeignKey("users.id"), nullable=False, index=True
)
organization_id = db.Column(
db.String(36), db.ForeignKey("organizations.id"), nullable=False, index=True
)
status = db.Column(
db.Enum(MfaComplianceStatus),
nullable=False,
default=MfaComplianceStatus.NOT_APPLICABLE,
)
# Snapshot of org policy at the time this record became active
policy_version = db.Column(db.Integer, nullable=False)
# When policy started applying to this user
applied_at = db.Column(db.DateTime, nullable=True)
# Final deadline for this user to comply (per user, not global)
deadline_at = db.Column(db.DateTime, nullable=True)
# When they became compliant under this policy_version
compliant_at = db.Column(db.DateTime, nullable=True)
# When suspended enforcement started for this user
suspended_at = db.Column(db.DateTime, nullable=True)
# Notification tracking
last_notified_at = db.Column(db.DateTime, nullable=True)
notification_count = db.Column(db.Integer, nullable=False, default=0)
__table_args__ = (
db.UniqueConstraint(
"user_id", "organization_id", name="uix_user_org_compliance"
),
)
# Relationships
user = db.relationship(
"User", back_populates="mfa_compliance", foreign_keys=[user_id]
)
organization = db.relationship("Organization", foreign_keys=[organization_id])
def __repr__(self):
"""String representation of MfaPolicyCompliance."""
return f"<MfaPolicyCompliance user={self.user_id} org={self.organization_id} status={self.status}>"
def to_dict(self, exclude=None):
"""Convert to dictionary."""
exclude = exclude or []
return super().to_dict(exclude=exclude)
+7
View File
@@ -24,6 +24,13 @@ class Organization(BaseModel):
oidc_clients = db.relationship(
"OIDCClient", back_populates="organization", cascade="all, delete-orphan"
)
security_policy = db.relationship(
"OrganizationSecurityPolicy",
back_populates="organization",
uselist=False,
cascade="all, delete-orphan",
foreign_keys="OrganizationSecurityPolicy.organization_id",
)
def __repr__(self):
"""String representation of Organization."""
@@ -0,0 +1,53 @@
"""OrganizationSecurityPolicy model."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import MfaPolicyMode
class OrganizationSecurityPolicy(BaseModel):
"""Organization security policy model for MFA configuration.
One row per organization capturing its current security requirements.
"""
__tablename__ = "organization_security_policies"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
unique=True,
)
# MFA policy configuration
mfa_policy_mode = db.Column(
db.Enum(MfaPolicyMode), nullable=False, default=MfaPolicyMode.OPTIONAL
)
# Grace period for members in days
mfa_grace_period_days = db.Column(db.Integer, nullable=False, default=14)
# Notification settings (in days before individual user deadline)
notify_days_before = db.Column(db.Integer, nullable=False, default=7)
# Versioning for compatibility tracking
policy_version = db.Column(db.Integer, nullable=False, default=1)
# Audit metadata
updated_by_user_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=True)
# Relationships
organization = db.relationship(
"Organization", back_populates="security_policy", foreign_keys=[organization_id]
)
updated_by_user = db.relationship("User", foreign_keys=[updated_by_user_id])
def __repr__(self):
"""String representation of OrganizationSecurityPolicy."""
return f"<OrganizationSecurityPolicy org={self.organization_id} mode={self.mfa_policy_mode}>"
def to_dict(self, exclude=None):
"""Convert to dictionary."""
exclude = exclude or []
return super().to_dict(exclude=exclude)
+3
View File
@@ -25,6 +25,9 @@ class Session(BaseModel):
revoked_at = db.Column(db.DateTime, nullable=True)
revoked_reason = db.Column(db.String(255), nullable=True)
# Compliance session flag
is_compliance_only = db.Column(db.Boolean, nullable=False, default=False)
# Relationships
user = db.relationship("User", back_populates="sessions")
+12
View File
@@ -31,6 +31,18 @@ class User(BaseModel):
foreign_keys="OrganizationMember.user_id",
)
audit_logs = db.relationship("AuditLog", back_populates="user", cascade="all, delete-orphan")
security_policies = db.relationship(
"UserSecurityPolicy",
back_populates="user",
cascade="all, delete-orphan",
foreign_keys="UserSecurityPolicy.user_id",
)
mfa_compliance = db.relationship(
"MfaPolicyCompliance",
back_populates="user",
cascade="all, delete-orphan",
foreign_keys="MfaPolicyCompliance.user_id",
)
def __repr__(self):
"""String representation of User."""
@@ -0,0 +1,53 @@
"""UserSecurityPolicy model."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import MfaRequirementOverride
class UserSecurityPolicy(BaseModel):
"""User security policy model for per-user MFA overrides.
Stores per user overrides of organization level MFA requirements.
"""
__tablename__ = "user_security_policies"
user_id = db.Column(
db.String(36), db.ForeignKey("users.id"), nullable=False, index=True
)
organization_id = db.Column(
db.String(36), db.ForeignKey("organizations.id"), nullable=False, index=True
)
mfa_override_mode = db.Column(
db.Enum(MfaRequirementOverride),
nullable=False,
default=MfaRequirementOverride.INHERIT,
)
# If override is REQUIRED and you want to force a specific factor set
force_totp = db.Column(db.Boolean, nullable=False, default=False)
force_webauthn = db.Column(db.Boolean, nullable=False, default=False)
__table_args__ = (
db.UniqueConstraint(
"user_id", "organization_id", name="uix_user_org_policy"
),
)
# Relationships
user = db.relationship(
"User", back_populates="security_policies", foreign_keys=[user_id]
)
organization = db.relationship(
"Organization", foreign_keys=[organization_id]
)
def __repr__(self):
"""String representation of UserSecurityPolicy."""
return f"<UserSecurityPolicy user={self.user_id} org={self.organization_id} mode={self.mfa_override_mode}>"
def to_dict(self, exclude=None):
"""Convert to dictionary."""
exclude = exclude or []
return super().to_dict(exclude=exclude)
+26
View File
@@ -96,3 +96,29 @@ class TOTPRegenerateBackupCodesSchema(Schema):
"""Schema for regenerating backup codes."""
password = fields.Str(required=True, validate=validate.Length(min=1))
class MfaComplianceOrgSchema(Schema):
"""Schema for MFA compliance per organization."""
organization_id = fields.Str(required=True)
organization_name = fields.Str(required=True)
status = fields.Str(required=True)
deadline_at = fields.Str(allow_none=True)
class MfaComplianceSchema(Schema):
"""Schema for MFA compliance summary in login response."""
overall_status = fields.Str(required=True)
missing_methods = fields.List(fields.Str(), required=True)
deadline_at = fields.Str(allow_none=True)
orgs = fields.List(fields.Nested(MfaComplianceOrgSchema), required=True)
class LoginResponseSchema(Schema):
"""Schema for login response."""
user = fields.Dict(required=True)
token = fields.Str(required=True)
expires_at = fields.Str(required=True)
requires_totp = fields.Bool(required=False)
requires_mfa_enrollment = fields.Bool(required=False)
mfa_compliance = fields.Nested(MfaComplianceSchema, required=False)
+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
+36
View File
@@ -9,6 +9,7 @@ class UserStatus(str, Enum):
INACTIVE = "inactive"
SUSPENDED = "suspended"
PENDING = "pending"
COMPLIANCE_SUSPENDED = "compliance_suspended"
class OrganizationRole(str, Enum):
@@ -86,6 +87,12 @@ class AuditAction(str, Enum):
WEBAUTHN_CREDENTIAL_DELETED = "webauthn.credential.deleted"
WEBAUTHN_CREDENTIAL_RENAMED = "webauthn.credential.renamed"
# Security policy actions
ORG_SECURITY_POLICY_UPDATE = "org.security_policy.update"
USER_SECURITY_POLICY_OVERRIDE_UPDATE = "user.security_policy.override_update"
MFA_POLICY_USER_SUSPENDED = "mfa.policy.user_suspended"
MFA_POLICY_USER_COMPLIANT = "mfa.policy.user_compliant"
class OIDCGrantType(str, Enum):
"""OIDC grant types."""
@@ -116,3 +123,32 @@ class ErrorType:
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
INTERNAL_ERROR = "INTERNAL_ERROR"
BAD_REQUEST = "BAD_REQUEST"
class MfaPolicyMode(str, Enum):
"""MFA policy mode for organizations."""
DISABLED = "disabled"
OPTIONAL = "optional"
REQUIRE_TOTP = "require_totp"
REQUIRE_WEBAUTHN = "require_webauthn"
REQUIRE_TOTP_OR_WEBAUTHN = "require_totp_or_webauthn"
class MfaComplianceStatus(str, Enum):
"""MFA compliance status for users per organization."""
NOT_APPLICABLE = "not_applicable"
PENDING = "pending"
IN_GRACE = "in_grace"
COMPLIANT = "compliant"
PAST_DUE = "past_due"
SUSPENDED = "suspended"
class MfaRequirementOverride(str, Enum):
"""User override for organization MFA requirements."""
INHERIT = "inherit"
REQUIRED = "required"
EXEMPT = "exempt"
+40 -1
View File
@@ -2,7 +2,8 @@
from functools import wraps
from flask import request, g
from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.constants import OrganizationRole
from gatehouse_app.utils.constants import OrganizationRole, UserStatus
from gatehouse_app.exceptions.auth_exceptions import UnauthorizedError, ForbiddenError
def login_required(f):
@@ -127,3 +128,41 @@ def require_owner(f):
def require_admin(f):
"""Decorator to require organization admin or owner role."""
return require_role(OrganizationRole.OWNER, OrganizationRole.ADMIN)(f)
def full_access_required(f):
"""Decorator to require full access session (not compliance-only).
This decorator checks if the user has a compliance-only session or
is in COMPLIANCE_SUSPENDED status. If so, it returns a 403 error
with error_type "MFA_COMPLIANCE_REQUIRED".
Use this decorator on endpoints that require full MFA compliance.
Endpoints for MFA enrollment, status, and logout should NOT use this decorator.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user = getattr(g, "current_user", None)
session = getattr(g, "current_session", None)
if not user or not session:
return api_response(
success=False,
message="Authentication required",
status=401,
error_type="AUTH_REQUIRED",
)
# Check for compliance-only session or compliance suspended status
if session.is_compliance_only or user.status == UserStatus.COMPLIANCE_SUSPENDED:
return api_response(
success=False,
message="MFA compliance required to access this resource",
status=403,
error_type="MFA_COMPLIANCE_REQUIRED",
error_details={"overall_status": "suspended"},
)
return f(*args, **kwargs)
return decorated_function