Feat(Chore): Verify Flow, Invites, Suspend, Depart Cert Policy

feat: add password reset and email verification flow
feat: add org invite listing, cancellation, and invite link fallback
feat: add user suspend/unsuspend with audit logging
feat: add department certificate policy (expiry, extensions)
feat: enforce dept cert policy on SSH certificate signing
feat: wire up OIDC consent and token flow (replace mocks)
feat: rework CLI auth bridge to use frontend login flow
feat: add admin OAuth provider management (CRUD)
chore: refactor model import paths after module reorganisation
chore: clean up config, decorators, and dev tooling
This commit is contained in:
2026-03-01 16:50:27 +05:45
parent 07193a2d2e
commit a0d4e59c24
39 changed files with 2035 additions and 611 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
"""Audit service."""
from flask import request, g
from gatehouse_app.models.audit_log import AuditLog
from gatehouse_app.models.auth.audit_log import AuditLog
from gatehouse_app.utils.constants import AuditAction
+3 -3
View File
@@ -5,9 +5,9 @@ from datetime import datetime, timedelta, timezone
from typing import Optional
from flask import request, g, current_app
from gatehouse_app.extensions import db, bcrypt
from gatehouse_app.models.user import User
from gatehouse_app.models.authentication_method import AuthenticationMethod
from gatehouse_app.models.session import Session
from gatehouse_app.models.user.user import User
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
from gatehouse_app.models.user.session import Session
from gatehouse_app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError
from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError
@@ -8,7 +8,7 @@ from flask import current_app
from gatehouse_app.extensions import db
from gatehouse_app.models import User, AuthenticationMethod
from gatehouse_app.models.authentication_method import (
from gatehouse_app.models.auth.authentication_method import (
OAuthState,
ApplicationProviderConfig,
OrganizationProviderOverride
@@ -1210,12 +1210,35 @@ class ExternalAuthService:
else:
email_verified = data.get("email_verified", False)
sub = data.get("sub")
# Derive email from sub when the provider omits the email claim.
# This happens with some OIDC servers (including the nav-security mock)
# that only return the minimal {sub, iss, iat, exp} set.
# Rule: if sub looks like an email address, use it directly.
# Otherwise, construct a deterministic fallback so we never get NULL.
raw_email = data.get("email")
if not raw_email and sub:
import re as _re
if _re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", sub):
raw_email = sub
email_verified = True # if sub IS the email it's already verified
else:
# e.g. "12345" → "12345@google.local" so we can store it
raw_email = f"{sub}@{provider or 'oauth'}.local"
email_verified = False
# Derive display name when omitted
raw_name = data.get("name") or data.get("display_name")
if not raw_name and raw_email:
raw_name = raw_email.split("@")[0]
# Standardize user info
return {
"provider_user_id": data.get("sub"),
"email": data.get("email"),
"provider_user_id": sub,
"email": raw_email,
"email_verified": email_verified,
"name": data.get("name"),
"name": raw_name,
"first_name": data.get("given_name"),
"last_name": data.get("family_name"),
"picture": data.get("picture"),
+6 -6
View File
@@ -4,11 +4,11 @@ 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.models.security.organization_security_policy import OrganizationSecurityPolicy
from gatehouse_app.models.security.user_security_policy import UserSecurityPolicy
from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyCompliance
from gatehouse_app.models.user.user import User
from gatehouse_app.models.organization.organization import Organization
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.utils.constants import (
MfaPolicyMode,
@@ -702,7 +702,7 @@ class MfaPolicyService:
if now is None:
now = datetime.now(timezone.utc)
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.organization.organization_member import OrganizationMember
updated_count = 0
+55 -94
View File
@@ -19,9 +19,9 @@ 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.models.security.mfa_policy_compliance import MfaPolicyCompliance
from gatehouse_app.models.security.organization_security_policy import OrganizationSecurityPolicy
from gatehouse_app.models.user.user import User
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.utils.constants import AuditAction
@@ -37,6 +37,7 @@ class NotificationService:
SMTP_PORT_KEY = "SMTP_PORT"
SMTP_USERNAME_KEY = "SMTP_USERNAME"
SMTP_PASSWORD_KEY = "SMTP_PASSWORD"
SMTP_USE_TLS_KEY = "SMTP_USE_TLS"
FROM_ADDRESS_KEY = "FROM_ADDRESS"
@staticmethod
@@ -86,10 +87,9 @@ class NotificationService:
if success:
logger.info(
f"Sent MFA deadline reminder to {user.email} "
f"({days_until_deadline} days remaining # Audit log
)"
f"({days_until_deadline} days remaining)"
)
AuditService.log_action(
AuditService.log_action(
action=AuditAction.MFA_POLICY_USER_COMPLIANT,
user_id=user.id,
organization_id=compliance.organization_id,
@@ -291,101 +291,62 @@ Gatehouse Security Team
body: str,
html_body: Optional[str] = None,
) -> bool:
"""Send an email notification.
"""Send an email via SMTP.
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
Returns True if the email was sent successfully, False otherwise.
If EMAIL_ENABLED is False, logs the email body instead (simulation mode).
"""
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from flask import current_app
email_enabled = current_app.config.get(NotificationService.EMAIL_ENABLED_KEY, False)
if not email_enabled:
logger.info(
f"[EMAIL DISABLED] Would have sent to: {to_address} | Subject: {subject}\n"
f"Body: {body[:500]}"
)
return False
smtp_host = current_app.config.get(NotificationService.SMTP_HOST_KEY, "localhost")
smtp_port = int(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)
smtp_use_tls = current_app.config.get(
NotificationService.SMTP_USE_TLS_KEY,
smtp_port not in (25, 1025),
)
from_address = current_app.config.get(
NotificationService.FROM_ADDRESS_KEY, "noreply@gatehouse.local"
)
try:
from flask import current_app
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = from_address
msg["To"] = to_address
msg.attach(MIMEText(body, "plain"))
if html_body:
msg.attach(MIMEText(html_body, "html"))
# 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:
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.ehlo()
if smtp_use_tls:
server.starttls()
if smtp_username and smtp_password:
server.login(smtp_username, smtp_password)
server.send_message(msg)
server.ehlo()
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
logger.info(f"[EMAIL] Sent to {to_address} | Subject: {subject}")
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
logger.error(f"[EMAIL] Failed to send to {to_address}: {e}")
return False
@staticmethod
def get_notification_stats(user_id: str) -> Dict[str, Any]:
@@ -397,7 +358,7 @@ Gatehouse Security Team
Returns:
Dictionary with notification statistics
"""
from gatehouse_app.models.mfa_policy_compliance import MfaPolicyCompliance
from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyCompliance
stats = {
"total_notifications": 0,
+5 -5
View File
@@ -9,9 +9,9 @@ from flask import current_app, request, g, redirect
from gatehouse_app.extensions import db
from gatehouse_app.models import User, AuthenticationMethod
from gatehouse_app.models.authentication_method import OAuthState
from gatehouse_app.models.auth.authentication_method import OAuthState
from gatehouse_app.models.base import BaseModel
from gatehouse_app.models.oidc_authorization_code import OIDCAuthCode
from gatehouse_app.models.oidc.oidc_authorization_code import OIDCAuthCode
from gatehouse_app.utils.constants import AuthMethodType, AuditAction
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.services.external_auth_service import (
@@ -776,7 +776,7 @@ class OAuthFlowService:
# If organization_id hint was provided and valid, create session for that org
if state_record.organization_id:
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization.organization import Organization
org = Organization.query.get(state_record.organization_id)
if org:
from gatehouse_app.services.auth_service import AuthService
@@ -987,8 +987,8 @@ class OAuthFlowService:
)
# Determine organization
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.organization.organization import Organization
from gatehouse_app.models.organization.organization_member import OrganizationMember
# Get user's organizations
user_orgs = user.get_organizations()
+1 -1
View File
@@ -8,7 +8,7 @@ from typing import Dict, List, Optional, Tuple
from flask import current_app
from gatehouse_app.extensions import db
from gatehouse_app.models.oidc_jwks_key import OidcJwksKey
from gatehouse_app.models.oidc.oidc_jwks_key import OidcJwksKey
class JWKSKey:
+1 -1
View File
@@ -14,7 +14,7 @@ from gatehouse_app.models import (
User, OIDCClient, OIDCAuthCode, OIDCRefreshToken,
OIDCSession, OIDCTokenMetadata
)
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.exceptions.validation_exceptions import (
ValidationError, NotFoundError, BadRequestError
)
+1 -1
View File
@@ -11,7 +11,7 @@ import jwt
from flask import current_app, g
from gatehouse_app.models import User, OIDCClient
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
logger = logging.getLogger(__name__)
@@ -3,8 +3,8 @@ import logging
from datetime import datetime, timezone
from flask import current_app
from gatehouse_app.extensions import db
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.organization.organization import Organization
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.exceptions.validation_exceptions import OrganizationNotFoundError, ConflictError
from gatehouse_app.utils.constants import OrganizationRole, AuditAction
from gatehouse_app.services.audit_service import AuditService
+2 -2
View File
@@ -1,6 +1,6 @@
"""Session service."""
from datetime import datetime, timezone
from gatehouse_app.models.session import Session
from gatehouse_app.models.user.session import Session
from gatehouse_app.utils.constants import SessionStatus
@@ -17,7 +17,7 @@ class SessionService:
Returns:
Session object if found and active, None otherwise
"""
from gatehouse_app.models.session import Session
from gatehouse_app.models.user.session import Session
from gatehouse_app.utils.constants import SessionStatus
return Session.query.filter_by(
token=token,
@@ -253,12 +253,13 @@ class SSHCASigningService:
certificate.fields.valid_after = now
certificate.fields.valid_before = valid_before
# Set extensions
# Set extensions — prefer policy-provided list, fall back to standard set
extensions = signing_request.extensions
if not extensions and self.config.get_bool('extensions_enabled'):
extensions = self.config.get_list('extensions')
certificate.fields.extensions = extensions or []
if not extensions:
from gatehouse_app.models.organization.department_cert_policy import STANDARD_EXTENSIONS
extensions = list(STANDARD_EXTENSIONS)
certificate.fields.extensions = extensions
certificate.fields.critical_options = signing_request.critical_options or {}
# Validate certificate before signing
@@ -291,6 +291,8 @@ class SSHKeyService:
logger.info(f"SSH key verified: {key_id}")
return True
except SSHKeyError:
raise
except Exception as e:
logger.error(f"SSH key verification failed: {str(e)}")
raise SSHKeyError(f"Signature verification failed: {str(e)}")
+1 -1
View File
@@ -2,7 +2,7 @@
import logging
from flask import current_app
from gatehouse_app.extensions import db
from gatehouse_app.models.user import User
from gatehouse_app.models.user.user import User
from gatehouse_app.exceptions.validation_exceptions import UserNotFoundError
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
+2 -2
View File
@@ -10,8 +10,8 @@ from flask import current_app
from sqlalchemy.orm.attributes import flag_modified
from gatehouse_app.extensions import db, redis_client
from gatehouse_app.models.user import User
from gatehouse_app.models.authentication_method import AuthenticationMethod
from gatehouse_app.models.user.user import User
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
from gatehouse_app.utils.constants import AuthMethodType, AuditAction
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
from gatehouse_app.services.audit_service import AuditService