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:
@@ -21,7 +21,7 @@ from gatehouse_app.extensions import db
|
||||
from gatehouse_app.extensions import bcrypt as flask_bcrypt
|
||||
from gatehouse_app.extensions import redis_client as _redis_client_ref # may be None until app init
|
||||
from gatehouse_app.models import User, OIDCClient
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -326,7 +326,7 @@ def oidc_complete():
|
||||
400: invalid request
|
||||
401: invalid token
|
||||
"""
|
||||
from gatehouse_app.models.session import Session as GHSession
|
||||
from gatehouse_app.models.user.session import Session as GHSession
|
||||
from gatehouse_app.utils.constants import SessionStatus
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Authentication endpoints."""
|
||||
import json
|
||||
from flask import request, session, g, jsonify
|
||||
import logging
|
||||
from flask import request, session, g, jsonify, current_app
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
@@ -23,6 +24,7 @@ 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.services.notification_service import NotificationService
|
||||
from gatehouse_app.utils.decorators import login_required
|
||||
from gatehouse_app.utils.constants import AuditAction
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||
@@ -57,6 +59,23 @@ def register():
|
||||
full_name=data.get("full_name"),
|
||||
)
|
||||
|
||||
# Send verification email
|
||||
try:
|
||||
from gatehouse_app.models import EmailVerificationToken
|
||||
verify_token = EmailVerificationToken.generate(user_id=user.id)
|
||||
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
||||
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
|
||||
subject = "Verify your Gatehouse email address"
|
||||
body = (
|
||||
f"Hi {user.full_name or user.email},\n\n"
|
||||
f"Welcome to Gatehouse! Please verify your email address by clicking the link below (valid for 24 hours):\n"
|
||||
f"{verify_link}\n\n"
|
||||
f"Gatehouse Security Team"
|
||||
)
|
||||
NotificationService._send_email(to_address=user.email, subject=subject, body=body)
|
||||
except Exception as exc:
|
||||
logging.getLogger(__name__).warning(f"Failed to send verification email on register: {exc}")
|
||||
|
||||
# Create session
|
||||
user_session = AuthService.create_session(user)
|
||||
|
||||
@@ -179,7 +198,9 @@ def login():
|
||||
"organization_id": org.organization_id,
|
||||
"organization_name": org.organization_name,
|
||||
"status": org.status,
|
||||
"effective_mode": org.effective_mode,
|
||||
"deadline_at": org.deadline_at,
|
||||
"applied_at": org.applied_at,
|
||||
}
|
||||
for org in policy_result.compliance_summary.orgs
|
||||
],
|
||||
@@ -284,7 +305,7 @@ def revoke_session(session_id):
|
||||
401: Not authenticated
|
||||
404: Session not found
|
||||
"""
|
||||
from gatehouse_app.models.session import Session
|
||||
from gatehouse_app.models.user.session import Session
|
||||
|
||||
# Ensure session belongs to current user
|
||||
user_session = Session.query.filter_by(
|
||||
@@ -424,7 +445,7 @@ def verify_totp():
|
||||
)
|
||||
|
||||
# Get user from database
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.models.user.user import User
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return api_response(
|
||||
@@ -475,7 +496,9 @@ def verify_totp():
|
||||
"organization_id": org.organization_id,
|
||||
"organization_name": org.organization_name,
|
||||
"status": org.status,
|
||||
"effective_mode": org.effective_mode,
|
||||
"deadline_at": org.deadline_at,
|
||||
"applied_at": org.applied_at,
|
||||
}
|
||||
for org in policy_result.compliance_summary.orgs
|
||||
],
|
||||
@@ -806,7 +829,7 @@ def begin_webauthn_login():
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Find user by email
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.models.user.user import User
|
||||
user = User.query.filter_by(
|
||||
email=data["email"].lower(),
|
||||
deleted_at=None
|
||||
@@ -893,7 +916,7 @@ def complete_webauthn_login():
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Get user from database
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.models.user.user import User
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
logger.error(f"WebAuthn login complete - user not found: {user_id}")
|
||||
@@ -962,7 +985,9 @@ def complete_webauthn_login():
|
||||
"organization_id": org.organization_id,
|
||||
"organization_name": org.organization_name,
|
||||
"status": org.status,
|
||||
"effective_mode": org.effective_mode,
|
||||
"deadline_at": org.deadline_at,
|
||||
"applied_at": org.applied_at,
|
||||
}
|
||||
for org in policy_result.compliance_summary.orgs
|
||||
],
|
||||
@@ -1142,3 +1167,381 @@ def get_webauthn_status():
|
||||
},
|
||||
message="WebAuthn status retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
_pw_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/forgot-password", methods=["POST"])
|
||||
def forgot_password():
|
||||
"""Request a password reset email.
|
||||
|
||||
Always returns 200 to avoid leaking account existence.
|
||||
|
||||
Request body:
|
||||
email: User email address
|
||||
|
||||
Returns:
|
||||
200: Password reset email sent (or silently no-op if email not found)
|
||||
"""
|
||||
from gatehouse_app.models import User, PasswordResetToken
|
||||
|
||||
data = request.get_json() or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
|
||||
if not email:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Email is required",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
# Always return 200 — don't leak whether the email exists
|
||||
user = User.query.filter_by(email=email, deleted_at=None).first()
|
||||
if user:
|
||||
try:
|
||||
reset_token = PasswordResetToken.generate(user_id=user.id)
|
||||
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
||||
reset_link = f"{app_url}/reset-password?token={reset_token.token}"
|
||||
subject = "Reset your Gatehouse password"
|
||||
body = (
|
||||
f"Hi {user.full_name or user.email},\n\n"
|
||||
f"You requested a password reset for your Gatehouse account.\n\n"
|
||||
f"Click the link below to reset your password (valid for 2 hours):\n"
|
||||
f"{reset_link}\n\n"
|
||||
f"If you did not request this, you can safely ignore this email.\n\n"
|
||||
f"Gatehouse Security Team"
|
||||
)
|
||||
NotificationService._send_email(
|
||||
to_address=user.email,
|
||||
subject=subject,
|
||||
body=body,
|
||||
)
|
||||
_pw_logger.info(f"Password reset token generated for user {user.id}")
|
||||
except Exception as exc:
|
||||
_pw_logger.exception(f"Error generating password reset token: {exc}")
|
||||
|
||||
return api_response(
|
||||
data={},
|
||||
message="If an account exists for this email, you will receive a password reset link shortly.",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/reset-password", methods=["POST"])
|
||||
def reset_password():
|
||||
"""Reset a user's password using a reset token.
|
||||
|
||||
Request body:
|
||||
token: Password reset token from email
|
||||
password: New password
|
||||
password_confirm: Password confirmation
|
||||
|
||||
Returns:
|
||||
200: Password reset successfully
|
||||
400: Invalid or expired token / validation error
|
||||
"""
|
||||
import bcrypt as _bcrypt
|
||||
from gatehouse_app.extensions import bcrypt
|
||||
from gatehouse_app.models import PasswordResetToken, AuthenticationMethod
|
||||
from gatehouse_app.utils.constants import AuthMethodType
|
||||
|
||||
data = request.get_json() or {}
|
||||
token_value = (data.get("token") or "").strip()
|
||||
new_password = data.get("password") or ""
|
||||
password_confirm = data.get("password_confirm") or ""
|
||||
|
||||
if not token_value or not new_password:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Token and new password are required",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
if new_password != password_confirm:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Passwords do not match",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
if len(new_password) < 8:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Password must be at least 8 characters",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
reset_token = PasswordResetToken.query.filter_by(token=token_value).first()
|
||||
if not reset_token or not reset_token.is_valid:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="This password reset link is invalid or has expired.",
|
||||
status=400,
|
||||
error_type="INVALID_TOKEN",
|
||||
)
|
||||
|
||||
try:
|
||||
user = reset_token.user
|
||||
# Update the password hash on the authentication method
|
||||
auth_method = AuthenticationMethod.query.filter_by(
|
||||
user_id=user.id,
|
||||
method_type=AuthMethodType.PASSWORD,
|
||||
deleted_at=None,
|
||||
).first()
|
||||
if auth_method:
|
||||
auth_method.password_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
|
||||
from gatehouse_app.extensions import db
|
||||
db.session.add(auth_method)
|
||||
|
||||
reset_token.consume()
|
||||
_pw_logger.info(f"Password reset for user {user.id}")
|
||||
|
||||
return api_response(
|
||||
data={},
|
||||
message="Your password has been reset. You can now sign in with your new password.",
|
||||
)
|
||||
except Exception as exc:
|
||||
_pw_logger.exception(f"Error resetting password: {exc}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred while resetting your password.",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/verify-email", methods=["POST"])
|
||||
def verify_email():
|
||||
"""Verify a user's email address using a verification token.
|
||||
|
||||
Request body:
|
||||
token: Email verification token
|
||||
|
||||
Returns:
|
||||
200: Email verified successfully
|
||||
400: Invalid or expired token
|
||||
"""
|
||||
from gatehouse_app.models import EmailVerificationToken
|
||||
|
||||
data = request.get_json() or {}
|
||||
token_value = (data.get("token") or "").strip()
|
||||
|
||||
if not token_value:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Verification token is required",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
verify_token = EmailVerificationToken.query.filter_by(token=token_value).first()
|
||||
if not verify_token or not verify_token.is_valid:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="This verification link is invalid or has expired.",
|
||||
status=400,
|
||||
error_type="INVALID_TOKEN",
|
||||
)
|
||||
|
||||
try:
|
||||
user = verify_token.user
|
||||
user.email_verified = True
|
||||
from gatehouse_app.extensions import db
|
||||
db.session.add(user)
|
||||
verify_token.consume()
|
||||
_pw_logger.info(f"Email verified for user {user.id}")
|
||||
|
||||
return api_response(
|
||||
data={},
|
||||
message="Your email has been verified. You can now sign in.",
|
||||
)
|
||||
except Exception as exc:
|
||||
_pw_logger.exception(f"Error verifying email: {exc}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred while verifying your email.",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/resend-verification", methods=["POST"])
|
||||
def resend_verification():
|
||||
"""Resend email verification link.
|
||||
|
||||
Always returns 200 to avoid leaking account existence.
|
||||
|
||||
Request body:
|
||||
email: User email address
|
||||
|
||||
Returns:
|
||||
200: Verification email sent (or silently no-op)
|
||||
"""
|
||||
from gatehouse_app.models import User, EmailVerificationToken
|
||||
|
||||
data = request.get_json() or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
|
||||
if not email:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Email is required",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
user = User.query.filter_by(email=email, deleted_at=None).first()
|
||||
if user and not user.email_verified:
|
||||
try:
|
||||
verify_token = EmailVerificationToken.generate(user_id=user.id)
|
||||
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
||||
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
|
||||
subject = "Verify your Gatehouse email address"
|
||||
body = (
|
||||
f"Hi {user.full_name or user.email},\n\n"
|
||||
f"Please verify your email address by clicking the link below (valid for 24 hours):\n"
|
||||
f"{verify_link}\n\n"
|
||||
f"Gatehouse Security Team"
|
||||
)
|
||||
NotificationService._send_email(
|
||||
to_address=user.email,
|
||||
subject=subject,
|
||||
body=body,
|
||||
)
|
||||
_pw_logger.info(f"Verification email sent for user {user.id}")
|
||||
except Exception as exc:
|
||||
_pw_logger.exception(f"Error sending verification email: {exc}")
|
||||
|
||||
return api_response(
|
||||
data={},
|
||||
message="If an account exists for this email and is not yet verified, you will receive a verification link shortly.",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Account Activation (separate from email-verification)
|
||||
# =============================================================================
|
||||
|
||||
@api_v1_bp.route("/auth/activate", methods=["POST"])
|
||||
def activate_account():
|
||||
"""Activate a user account via a one-time activation code.
|
||||
|
||||
Request body:
|
||||
code – the activation_key from the welcome email
|
||||
|
||||
Returns:
|
||||
200: Account activated, session token returned
|
||||
400: Missing code
|
||||
404: Invalid or already-used code
|
||||
"""
|
||||
import secrets
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
data = request.get_json() or {}
|
||||
code = (data.get("code") or "").strip()
|
||||
if not code:
|
||||
return api_response(success=False, message="Activation code is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
user = User.query.filter_by(activation_key=code, deleted_at=None).first()
|
||||
if not user:
|
||||
return api_response(success=False, message="Invalid or expired activation code", status=404, error_type="NOT_FOUND")
|
||||
|
||||
user.activated = True
|
||||
user.activation_key = None # one-time use
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
user_session = AuthService.create_session(user)
|
||||
_pw_logger.info(f"Account activated for user {user.id}")
|
||||
|
||||
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(),
|
||||
},
|
||||
message="Account activated successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/resend-activation", methods=["POST"])
|
||||
def resend_activation():
|
||||
"""Re-send an account activation email.
|
||||
|
||||
Always returns 200 to avoid leaking whether an account exists.
|
||||
|
||||
Request body:
|
||||
email – user email address
|
||||
"""
|
||||
import secrets
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
data = request.get_json() or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
if not email:
|
||||
return api_response(success=False, message="Email is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
user = User.query.filter_by(email=email, deleted_at=None).first()
|
||||
if user and not user.activated:
|
||||
try:
|
||||
code = secrets.token_urlsafe(32)
|
||||
user.activation_key = code
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
app_url = current_app.config.get("APP_URL", current_app.config.get("FRONTEND_URL", "http://localhost:8080"))
|
||||
activate_link = f"{app_url}/activate?code={code}"
|
||||
subject = "Activate your Gatehouse account"
|
||||
body = (
|
||||
f"Hi {user.full_name or user.email},\n\n"
|
||||
f"Please activate your Gatehouse account by clicking the link below:\n"
|
||||
f"{activate_link}\n\n"
|
||||
f"If you did not create an account, you can safely ignore this email.\n\n"
|
||||
f"Gatehouse Security Team"
|
||||
)
|
||||
NotificationService._send_email(to_address=user.email, subject=subject, body=body)
|
||||
_pw_logger.info(f"Activation email re-sent to {user.id}")
|
||||
except Exception as exc:
|
||||
_pw_logger.exception(f"Error re-sending activation email: {exc}")
|
||||
|
||||
return api_response(
|
||||
data={},
|
||||
message="If an unactivated account exists for this email, you will receive a new activation link shortly.",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Token retrieval / redirect (for CLI / external tools)
|
||||
# =============================================================================
|
||||
|
||||
@api_v1_bp.route("/auth/token", methods=["GET"])
|
||||
@login_required
|
||||
def get_token():
|
||||
"""Return the current session token, optionally redirecting to a URL.
|
||||
|
||||
Query parameters:
|
||||
redirect – optional URL to redirect to with the token appended as
|
||||
a query param: ``<redirect>?token=<token>``
|
||||
|
||||
Returns:
|
||||
200: JSON ``{"token": "<token>"}`` (no redirect given)
|
||||
302: Redirect to ``<redirect>?token=<token>``
|
||||
"""
|
||||
from flask import redirect as flask_redirect
|
||||
|
||||
token = g.current_session.token
|
||||
redirect_url = request.args.get("redirect", "").strip()
|
||||
|
||||
if redirect_url:
|
||||
sep = "&" if "?" in redirect_url else "?"
|
||||
return flask_redirect(f"{redirect_url}{sep}token={token}", code=302)
|
||||
|
||||
return api_response(data={"token": token}, message="Token retrieved")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Department endpoints."""
|
||||
from flask import g, request
|
||||
from marshmallow import Schema, fields, validate, ValidationError
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
@@ -422,7 +423,7 @@ def add_department_member(org_id, dept_id):
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Check if already a member
|
||||
# Check if already an active member
|
||||
existing = DepartmentMembership.query.filter_by(
|
||||
user_id=user.id,
|
||||
department_id=dept_id,
|
||||
@@ -437,12 +438,23 @@ def add_department_member(org_id, dept_id):
|
||||
error_type="CONFLICT",
|
||||
)
|
||||
|
||||
# Add member
|
||||
membership = DepartmentMembership(
|
||||
user_id=user.id,
|
||||
department_id=dept_id,
|
||||
)
|
||||
db.session.add(membership)
|
||||
# Check for a previously soft-deleted row and resurrect it instead of inserting
|
||||
soft_deleted = DepartmentMembership.query.filter(
|
||||
DepartmentMembership.user_id == user.id,
|
||||
DepartmentMembership.department_id == dept_id,
|
||||
DepartmentMembership.deleted_at.isnot(None)
|
||||
).first()
|
||||
|
||||
if soft_deleted:
|
||||
soft_deleted.deleted_at = None
|
||||
membership = soft_deleted
|
||||
else:
|
||||
membership = DepartmentMembership(
|
||||
user_id=user.id,
|
||||
department_id=dept_id,
|
||||
)
|
||||
db.session.add(membership)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
member_dict = membership.to_dict()
|
||||
@@ -560,3 +572,128 @@ def get_department_principals(org_id, dept_id):
|
||||
},
|
||||
message="Principals retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Department Certificate Policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/departments/<dept_id>/cert-policy", methods=["GET"])
|
||||
@login_required
|
||||
@require_admin
|
||||
@full_access_required
|
||||
def get_dept_cert_policy(org_id, dept_id):
|
||||
"""Get the certificate issuance policy for a department (admin only)."""
|
||||
from gatehouse_app.models.organization.department_cert_policy import DepartmentCertPolicy, STANDARD_EXTENSIONS
|
||||
|
||||
dept = Department.query.filter_by(
|
||||
id=dept_id, organization_id=org_id, deleted_at=None
|
||||
).first()
|
||||
if not dept:
|
||||
return api_response(success=False, message="Department not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
policy = DepartmentCertPolicy.query.filter(
|
||||
DepartmentCertPolicy.department_id == dept_id,
|
||||
DepartmentCertPolicy.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
if policy:
|
||||
data = policy.to_dict()
|
||||
else:
|
||||
# Return default (all standard extensions, no user expiry choice)
|
||||
data = {
|
||||
"department_id": str(dept_id),
|
||||
"allow_user_expiry": False,
|
||||
"default_expiry_hours": 1,
|
||||
"max_expiry_hours": 24,
|
||||
"allowed_extensions": list(STANDARD_EXTENSIONS),
|
||||
"custom_extensions": [],
|
||||
"all_extensions": list(STANDARD_EXTENSIONS),
|
||||
"standard_extensions": list(STANDARD_EXTENSIONS),
|
||||
}
|
||||
|
||||
return api_response(data={"cert_policy": data}, message="Certificate policy retrieved")
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/departments/<dept_id>/cert-policy", methods=["PUT"])
|
||||
@login_required
|
||||
@require_admin
|
||||
@full_access_required
|
||||
def set_dept_cert_policy(org_id, dept_id):
|
||||
"""Create or update the certificate issuance policy for a department (admin only)."""
|
||||
from gatehouse_app.models.organization.department_cert_policy import DepartmentCertPolicy, STANDARD_EXTENSIONS
|
||||
|
||||
dept = Department.query.filter_by(
|
||||
id=dept_id, organization_id=org_id, deleted_at=None
|
||||
).first()
|
||||
if not dept:
|
||||
return api_response(success=False, message="Department not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
body = request.get_json() or {}
|
||||
|
||||
# Validate expiry values
|
||||
default_expiry = body.get("default_expiry_hours")
|
||||
max_expiry = body.get("max_expiry_hours")
|
||||
if default_expiry is not None:
|
||||
try:
|
||||
default_expiry = int(default_expiry)
|
||||
if default_expiry < 1:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
return api_response(success=False, message="default_expiry_hours must be a positive integer", status=400, error_type="VALIDATION_ERROR")
|
||||
if max_expiry is not None:
|
||||
try:
|
||||
max_expiry = int(max_expiry)
|
||||
if max_expiry < 1:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
return api_response(success=False, message="max_expiry_hours must be a positive integer", status=400, error_type="VALIDATION_ERROR")
|
||||
if default_expiry and max_expiry and default_expiry > max_expiry:
|
||||
return api_response(success=False, message="default_expiry_hours cannot exceed max_expiry_hours", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
# Validate allowed_extensions — must be subset of STANDARD_EXTENSIONS
|
||||
allowed_extensions = body.get("allowed_extensions")
|
||||
if allowed_extensions is not None:
|
||||
if not isinstance(allowed_extensions, list):
|
||||
return api_response(success=False, message="allowed_extensions must be a list", status=400, error_type="VALIDATION_ERROR")
|
||||
invalid_ext = [e for e in allowed_extensions if e not in STANDARD_EXTENSIONS]
|
||||
if invalid_ext:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=f"Invalid standard extensions: {', '.join(invalid_ext)}. Valid: {', '.join(STANDARD_EXTENSIONS)}",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
# Validate custom_extensions — plain strings
|
||||
custom_extensions = body.get("custom_extensions")
|
||||
if custom_extensions is not None:
|
||||
if not isinstance(custom_extensions, list) or not all(isinstance(e, str) for e in custom_extensions):
|
||||
return api_response(success=False, message="custom_extensions must be a list of strings", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
policy = DepartmentCertPolicy.query.filter(
|
||||
DepartmentCertPolicy.department_id == dept_id,
|
||||
DepartmentCertPolicy.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
if policy is None:
|
||||
policy = DepartmentCertPolicy(department_id=dept_id)
|
||||
db.session.add(policy)
|
||||
|
||||
if "allow_user_expiry" in body:
|
||||
policy.allow_user_expiry = bool(body["allow_user_expiry"])
|
||||
if default_expiry is not None:
|
||||
policy.default_expiry_hours = default_expiry
|
||||
if max_expiry is not None:
|
||||
policy.max_expiry_hours = max_expiry
|
||||
if allowed_extensions is not None:
|
||||
policy.allowed_extensions = list(allowed_extensions)
|
||||
flag_modified(policy, "allowed_extensions")
|
||||
if custom_extensions is not None:
|
||||
policy.custom_extensions = list(custom_extensions)
|
||||
flag_modified(policy, "custom_extensions")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return api_response(data={"cert_policy": policy.to_dict()}, message="Certificate policy saved")
|
||||
|
||||
|
||||
@@ -101,24 +101,24 @@ def token_please():
|
||||
"""
|
||||
CLI token acquisition endpoint.
|
||||
|
||||
Initiates an OAuth login flow and, on success, redirects the user's browser
|
||||
to the CLI's local callback server (redirect_url) with the session token
|
||||
appended, e.g.: http://127.0.0.1:8250/?token=<SESSION_TOKEN>
|
||||
Redirects the user's browser to the Gatehouse login page so they can
|
||||
authenticate using any method (password, OAuth, passkey, TOTP, etc.).
|
||||
On successful login the frontend delivers the session token directly to
|
||||
the CLI's local callback server.
|
||||
|
||||
This endpoint is designed for CLI clients that:
|
||||
1. Start a local HTTP server on LISTENER_SERVER_PORT (e.g. 8250)
|
||||
2. Open a browser to /api/v1/token_please?redirect_url=http://127.0.0.1:8250/?token=
|
||||
3. Wait for the browser to POST the token back to their local server
|
||||
3. Wait for the browser to deliver the token to their local server
|
||||
|
||||
Query parameters:
|
||||
redirect_url: Local callback URL where the token will be appended
|
||||
provider: OAuth provider to use (default: 'google')
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
import secrets
|
||||
from urllib.parse import urlencode, quote
|
||||
from flask import current_app, redirect as flask_redirect
|
||||
|
||||
redirect_url = request.args.get("redirect_url", "").strip()
|
||||
provider = request.args.get("provider", "google").lower()
|
||||
|
||||
if not redirect_url:
|
||||
return api_response(
|
||||
@@ -139,26 +139,92 @@ def token_please():
|
||||
error_type="INVALID_REDIRECT_URL",
|
||||
)
|
||||
|
||||
# Store the CLI redirect URL in Redis keyed by a short-lived token so the
|
||||
# frontend can retrieve it after login without it being visible in the URL.
|
||||
cli_token = secrets.token_urlsafe(32)
|
||||
try:
|
||||
provider_type = get_provider_type(provider)
|
||||
auth_url, state = OAuthFlowService.initiate_login_flow(
|
||||
provider_type=provider_type,
|
||||
organization_id=None,
|
||||
redirect_uri=None,
|
||||
)
|
||||
except (OAuthFlowError, ExternalAuthError) as e:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
rc.setex(f"cli_redirect:{cli_token}", _OAUTH_BRIDGE_TTL, redirect_url)
|
||||
else:
|
||||
logger.warning("Redis not available; passing cli_redirect directly in URL")
|
||||
cli_token = None
|
||||
except Exception:
|
||||
cli_token = None
|
||||
|
||||
frontend_url = current_app.config.get("FRONTEND_URL", "http://localhost:8080")
|
||||
|
||||
if cli_token:
|
||||
# Pass an opaque token; the frontend exchanges it for the real URL via
|
||||
# GET /api/v1/cli/redirect-url?token=<cli_token>
|
||||
login_url = f"{frontend_url}/login?cli_token={cli_token}"
|
||||
else:
|
||||
# Fallback: put the redirect URL directly (still localhost-only, validated above)
|
||||
login_url = f"{frontend_url}/login?cli_redirect={quote(redirect_url, safe='')}"
|
||||
|
||||
logger.info(f"CLI token_please: redirecting browser to Gatehouse login page")
|
||||
return flask_redirect(login_url, code=302)
|
||||
|
||||
|
||||
@api_v1_bp.route("/cli/redirect-url", methods=["GET"])
|
||||
def cli_redirect_url_lookup():
|
||||
"""
|
||||
Exchange a short-lived cli_token for the CLI's local redirect URL.
|
||||
|
||||
Called by the frontend LoginPage after it detects the cli_token query
|
||||
param so it can obtain the actual CLI callback URL from Redis without
|
||||
exposing it in the browser URL bar.
|
||||
|
||||
Query parameters:
|
||||
token: The cli_token issued by /token_please
|
||||
|
||||
Returns:
|
||||
200: { "redirect_url": "http://127.0.0.1:8250/?token=" }
|
||||
400: Missing token
|
||||
404: Token not found or expired
|
||||
"""
|
||||
cli_token = request.args.get("token", "").strip()
|
||||
if not cli_token:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=getattr(e, "message", str(e)),
|
||||
status=getattr(e, "status_code", 400),
|
||||
error_type=getattr(e, "error_type", "OAUTH_ERROR"),
|
||||
message="token query parameter is required",
|
||||
status=400,
|
||||
error_type="MISSING_TOKEN",
|
||||
)
|
||||
|
||||
# Store the CLI redirect URL so the callback can use it
|
||||
_store_cli_redirect(state, redirect_url)
|
||||
try:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
key = f"cli_redirect:{cli_token}"
|
||||
val = rc.get(key)
|
||||
if val is None:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="CLI token not found or expired",
|
||||
status=404,
|
||||
error_type="TOKEN_NOT_FOUND",
|
||||
)
|
||||
# Keep the key alive until the login actually completes (consume on use
|
||||
# would break multi-step auth like TOTP), so we leave it as-is.
|
||||
redirect_url = val.decode() if isinstance(val, bytes) else val
|
||||
return api_response(data={"redirect_url": redirect_url})
|
||||
except Exception as e:
|
||||
logger.error(f"cli_redirect_url_lookup error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Internal error looking up CLI token",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
logger.info(f"CLI token_please: provider={provider}, redirect_url={redirect_url}, redirecting to OAuth")
|
||||
return flask_redirect(auth_url, code=302)
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Redis not available",
|
||||
status=503,
|
||||
error_type="SERVICE_UNAVAILABLE",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -175,7 +241,7 @@ def list_providers():
|
||||
200: List of providers with their configuration status
|
||||
401: Not authenticated
|
||||
"""
|
||||
from gatehouse_app.models.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.services.external_auth_service import ExternalProviderConfig
|
||||
|
||||
# Check app-level provider configs (ApplicationProviderConfig)
|
||||
@@ -1173,3 +1239,203 @@ def _get_provider_endpoints(provider_type: AuthMethodType):
|
||||
"UNSUPPORTED_PROVIDER",
|
||||
400,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Admin: Application-level OAuth Provider Management
|
||||
# =============================================================================
|
||||
|
||||
@api_v1_bp.route("/admin/oauth/providers", methods=["GET"])
|
||||
@login_required
|
||||
def admin_list_app_providers():
|
||||
"""List all application-level OAuth provider configurations (admin only).
|
||||
|
||||
Returns:
|
||||
200: List of providers with client_id and enabled status
|
||||
401: Not authenticated
|
||||
403: Not an admin
|
||||
"""
|
||||
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
# Verify caller is admin in any org
|
||||
admin_memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == g.current_user.id,
|
||||
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
|
||||
).all()
|
||||
|
||||
if not admin_memberships:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Admin access required",
|
||||
status=403,
|
||||
error_type="FORBIDDEN",
|
||||
)
|
||||
|
||||
PROVIDERS = [
|
||||
{"id": "google", "name": "Google"},
|
||||
{"id": "github", "name": "GitHub"},
|
||||
{"id": "microsoft", "name": "Microsoft"},
|
||||
]
|
||||
|
||||
db_configs = {
|
||||
c.provider_type: c
|
||||
for c in ApplicationProviderConfig.query.all()
|
||||
}
|
||||
|
||||
result = []
|
||||
for p in PROVIDERS:
|
||||
cfg = db_configs.get(p["id"])
|
||||
result.append({
|
||||
"id": p["id"],
|
||||
"name": p["name"],
|
||||
"is_configured": cfg is not None,
|
||||
"is_enabled": cfg.is_enabled if cfg else False,
|
||||
"client_id": cfg.client_id if cfg else None,
|
||||
})
|
||||
|
||||
return api_response(
|
||||
data={"providers": result},
|
||||
message="OAuth providers retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/oauth/providers/<provider>", methods=["PUT"])
|
||||
@login_required
|
||||
def admin_configure_app_provider(provider: str):
|
||||
"""Create or update an application-level OAuth provider config (admin only).
|
||||
|
||||
Args:
|
||||
provider: Provider type (google, github, microsoft)
|
||||
|
||||
Request body:
|
||||
client_id: OAuth client ID
|
||||
client_secret: OAuth client secret (optional — omit to keep existing)
|
||||
is_enabled: Whether the provider is enabled (default: true)
|
||||
|
||||
Returns:
|
||||
200: Provider configuration updated
|
||||
400: Validation error
|
||||
401: Not authenticated
|
||||
403: Not an admin
|
||||
"""
|
||||
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
SUPPORTED = ["google", "github", "microsoft"]
|
||||
if provider not in SUPPORTED:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=f"Unsupported provider. Must be one of: {', '.join(SUPPORTED)}",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
# Verify caller is admin in any org
|
||||
admin_memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == g.current_user.id,
|
||||
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
|
||||
).all()
|
||||
|
||||
if not admin_memberships:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Admin access required",
|
||||
status=403,
|
||||
error_type="FORBIDDEN",
|
||||
)
|
||||
|
||||
data = request.json or {}
|
||||
client_id = (data.get("client_id") or "").strip()
|
||||
client_secret = (data.get("client_secret") or "").strip()
|
||||
is_enabled = data.get("is_enabled", True)
|
||||
|
||||
if not client_id:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="client_id is required",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider).first()
|
||||
if cfg:
|
||||
cfg.client_id = client_id
|
||||
if client_secret:
|
||||
cfg.set_client_secret(client_secret)
|
||||
cfg.is_enabled = bool(is_enabled)
|
||||
db.session.commit()
|
||||
else:
|
||||
cfg = ApplicationProviderConfig(
|
||||
provider_type=provider,
|
||||
client_id=client_id,
|
||||
is_enabled=bool(is_enabled),
|
||||
)
|
||||
if client_secret:
|
||||
cfg.set_client_secret(client_secret)
|
||||
db.session.add(cfg)
|
||||
db.session.commit()
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"provider": {
|
||||
"id": provider,
|
||||
"client_id": cfg.client_id,
|
||||
"is_enabled": cfg.is_enabled,
|
||||
}
|
||||
},
|
||||
message=f"{provider.capitalize()} OAuth provider configured successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/oauth/providers/<provider>", methods=["DELETE"])
|
||||
@login_required
|
||||
def admin_delete_app_provider(provider: str):
|
||||
"""Delete an application-level OAuth provider config (admin only).
|
||||
|
||||
Args:
|
||||
provider: Provider type (google, github, microsoft)
|
||||
|
||||
Returns:
|
||||
200: Provider configuration deleted
|
||||
404: Provider not found
|
||||
401: Not authenticated
|
||||
403: Not an admin
|
||||
"""
|
||||
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
# Verify caller is admin in any org
|
||||
admin_memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == g.current_user.id,
|
||||
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
|
||||
).all()
|
||||
|
||||
if not admin_memberships:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Admin access required",
|
||||
status=403,
|
||||
error_type="FORBIDDEN",
|
||||
)
|
||||
|
||||
cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider).first()
|
||||
if not cfg:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=f"Provider '{provider}' is not configured",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
db.session.delete(cfg)
|
||||
db.session.commit()
|
||||
|
||||
return api_response(
|
||||
message=f"{provider.capitalize()} OAuth provider configuration removed",
|
||||
)
|
||||
|
||||
@@ -381,6 +381,7 @@ def update_member_role(org_id, user_id):
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/audit-logs", methods=["GET"])
|
||||
@login_required
|
||||
@require_admin
|
||||
@full_access_required
|
||||
def get_organization_audit_logs(org_id):
|
||||
"""
|
||||
@@ -397,7 +398,7 @@ def get_organization_audit_logs(org_id):
|
||||
403: Not a member / insufficient permissions
|
||||
404: Organization not found
|
||||
"""
|
||||
from gatehouse_app.models.audit_log import AuditLog
|
||||
from gatehouse_app.models.auth.audit_log import AuditLog
|
||||
|
||||
# Ensure org exists and user is a member (full_access_required handles this)
|
||||
OrganizationService.get_organization_by_id(org_id)
|
||||
@@ -492,7 +493,7 @@ def create_org_invite(org_id):
|
||||
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
||||
invite_link = f"{app_url}/invite?token={invite.token}"
|
||||
|
||||
NotificationService._send_email(
|
||||
email_sent = NotificationService._send_email(
|
||||
to_address=email,
|
||||
subject=f"You're invited to join {org.name} on Gatehouse",
|
||||
body=(
|
||||
@@ -503,13 +504,103 @@ def create_org_invite(org_id):
|
||||
),
|
||||
)
|
||||
|
||||
# In dev mode email may not be configured — always log the link so it's findable
|
||||
import logging
|
||||
if not email_sent:
|
||||
logging.getLogger(__name__).warning(
|
||||
f"[INVITE LINK] Email not sent (EMAIL_ENABLED=False or SMTP down). "
|
||||
f"Invite for {email} → {invite_link}"
|
||||
)
|
||||
else:
|
||||
logging.getLogger(__name__).info(
|
||||
f"[INVITE] Email sent successfully to {email}"
|
||||
)
|
||||
|
||||
response_data = {
|
||||
"invite": {
|
||||
"id": invite.id,
|
||||
"email": invite.email,
|
||||
"role": invite.role,
|
||||
"expires_at": invite.expires_at.isoformat() + "Z",
|
||||
# Only include invite_link when email delivery failed — signals frontend to show copy dialog
|
||||
**({"invite_link": invite_link} if not email_sent else {}),
|
||||
}
|
||||
}
|
||||
|
||||
return api_response(
|
||||
data={"invite": {"id": invite.id, "email": invite.email, "role": invite.role, "expires_at": invite.expires_at.isoformat() + "Z"}},
|
||||
data=response_data,
|
||||
message="Invite sent successfully",
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/invites", methods=["GET"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def list_org_invites(org_id):
|
||||
"""List pending invite tokens for an organization.
|
||||
|
||||
Returns:
|
||||
200: List of invites
|
||||
403: Not an admin
|
||||
404: Organization not found
|
||||
"""
|
||||
from gatehouse_app.models import OrgInviteToken, Organization
|
||||
|
||||
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
|
||||
if not org:
|
||||
return api_response(success=False, message="Organization not found", status=404)
|
||||
|
||||
invites = (
|
||||
OrgInviteToken.query.filter_by(organization_id=org_id)
|
||||
.filter(OrgInviteToken.accepted_at == None)
|
||||
.filter(OrgInviteToken.deleted_at == None)
|
||||
.all()
|
||||
)
|
||||
|
||||
def invite_to_dict(inv):
|
||||
return {
|
||||
"id": inv.id,
|
||||
"email": inv.email,
|
||||
"role": inv.role,
|
||||
"invited_by_id": inv.invited_by_id,
|
||||
"created_at": inv.created_at.isoformat() + "Z",
|
||||
"expires_at": inv.expires_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
return api_response(
|
||||
data={"invites": [invite_to_dict(i) for i in invites]},
|
||||
message="Invites retrieved",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/invites/<invite_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def cancel_org_invite(org_id, invite_id):
|
||||
"""Cancel (soft-delete) an organization invite.
|
||||
|
||||
Returns:
|
||||
200: Invite cancelled
|
||||
403: Not an admin
|
||||
404: Invite not found
|
||||
"""
|
||||
from gatehouse_app.models import OrgInviteToken, Organization
|
||||
|
||||
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
|
||||
if not org:
|
||||
return api_response(success=False, message="Organization not found", status=404)
|
||||
|
||||
invite = OrgInviteToken.query.filter_by(id=invite_id, organization_id=org_id, deleted_at=None).first()
|
||||
if not invite:
|
||||
return api_response(success=False, message="Invite not found", status=404)
|
||||
|
||||
# Soft delete the invite so it's no longer usable
|
||||
invite.delete(soft=True)
|
||||
|
||||
return api_response(data={}, message="Invite cancelled")
|
||||
|
||||
|
||||
@api_v1_bp.route("/invites/<token>", methods=["GET"])
|
||||
def get_invite(token):
|
||||
"""Get invite details by token.
|
||||
@@ -518,17 +609,20 @@ def get_invite(token):
|
||||
200: Invite details (org name, email)
|
||||
400: Invalid or expired token
|
||||
"""
|
||||
from gatehouse_app.models import OrgInviteToken
|
||||
from gatehouse_app.models import OrgInviteToken, User
|
||||
|
||||
invite = OrgInviteToken.query.filter_by(token=token).first()
|
||||
if not invite or not invite.is_valid:
|
||||
return api_response(success=False, message="This invitation link is invalid or has expired.", status=400, error_type="INVALID_TOKEN")
|
||||
|
||||
user_exists = User.query.filter_by(email=invite.email, deleted_at=None).first() is not None
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"email": invite.email,
|
||||
"organization": {"id": invite.organization_id, "name": invite.organization.name},
|
||||
"role": invite.role,
|
||||
"user_exists": user_exists,
|
||||
},
|
||||
message="Invite found",
|
||||
)
|
||||
@@ -617,12 +711,14 @@ def accept_invite(token):
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/clients", methods=["GET"])
|
||||
@login_required
|
||||
@require_admin
|
||||
@full_access_required
|
||||
def list_org_clients(org_id):
|
||||
"""List OIDC clients for an organization.
|
||||
|
||||
Returns:
|
||||
200: List of OIDC clients
|
||||
403: Not a member
|
||||
403: Not an admin
|
||||
404: Organization not found
|
||||
"""
|
||||
from gatehouse_app.models import OIDCClient, Organization
|
||||
@@ -838,16 +934,18 @@ def get_system_audit_logs():
|
||||
success – "true"/"false"
|
||||
q – free-text search on description
|
||||
"""
|
||||
from gatehouse_app.models.audit_log import AuditLog
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.auth.audit_log import AuditLog
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
|
||||
current_user = g.current_user
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
|
||||
# Check if the user is an owner of any org to grant admin-level access
|
||||
is_admin = OrganizationMember.query.filter_by(
|
||||
user_id=current_user.id, role="OWNER"
|
||||
# Check if the user is an admin or owner of any org to grant admin-level access
|
||||
is_admin = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
|
||||
OrganizationMember.deleted_at == None,
|
||||
).first() is not None
|
||||
|
||||
query = AuditLog.query
|
||||
@@ -905,7 +1003,7 @@ def get_my_audit_logs():
|
||||
per_page – results per page (default 50, max 200)
|
||||
action – filter by AuditAction value
|
||||
"""
|
||||
from gatehouse_app.models.audit_log import AuditLog
|
||||
from gatehouse_app.models.auth.audit_log import AuditLog
|
||||
|
||||
current_user = g.current_user
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
@@ -947,8 +1045,8 @@ def list_organization_roles(org_id):
|
||||
401: Not authenticated
|
||||
404: Organization not found
|
||||
"""
|
||||
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
|
||||
|
||||
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
|
||||
if not org:
|
||||
@@ -996,7 +1094,7 @@ def assign_role_to_member(org_id, role_name):
|
||||
403: Not an admin/owner
|
||||
404: Org or member not found
|
||||
"""
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
try:
|
||||
@@ -1040,7 +1138,7 @@ def remove_role_from_member(org_id, role_name, user_id):
|
||||
403: Not an admin/owner
|
||||
404: Org or member not found
|
||||
"""
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
try:
|
||||
@@ -1074,8 +1172,8 @@ def list_org_cas(org_id):
|
||||
403: Not admin/owner
|
||||
404: Org not found
|
||||
"""
|
||||
from gatehouse_app.models.ca import CA
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.models.ssh_ca.ca import CA
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
|
||||
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
|
||||
if not org:
|
||||
@@ -1104,8 +1202,8 @@ def update_org_ca(org_id, ca_id):
|
||||
403: Not admin/owner
|
||||
404: Org or CA not found
|
||||
"""
|
||||
from gatehouse_app.models.ca import CA
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.models.ssh_ca.ca import CA
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from marshmallow import Schema, fields, validate, ValidationError
|
||||
|
||||
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
|
||||
@@ -1192,8 +1290,8 @@ def create_org_ca(org_id):
|
||||
403: Not admin/owner
|
||||
404: Org not found
|
||||
"""
|
||||
from gatehouse_app.models.ca import CA, KeyType
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.models.ssh_ca.ca import CA, KeyType
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.utils.crypto import compute_ssh_fingerprint
|
||||
from marshmallow import Schema, fields as ma_fields, validate, ValidationError as MaValidationError
|
||||
from sshkey_tools.keys import Ed25519PrivateKey, RsaPrivateKey, EcdsaPrivateKey
|
||||
@@ -1227,7 +1325,7 @@ def create_org_ca(org_id):
|
||||
)
|
||||
|
||||
# Enforce one CA per type per org
|
||||
from gatehouse_app.models.ca import CaType
|
||||
from gatehouse_app.models.ssh_ca.ca import CaType
|
||||
ca_type_val = data["ca_type"]
|
||||
existing_type = CA.query.filter_by(
|
||||
organization_id=org_id, deleted_at=None
|
||||
|
||||
@@ -195,20 +195,46 @@ def get_org_mfa_compliance(org_id):
|
||||
|
||||
limit = min(int(request.args.get("limit", 100)), 100)
|
||||
offset = int(request.args.get("offset", 0))
|
||||
page = int(request.args.get("page", 1))
|
||||
page_size = min(int(request.args.get("page_size", limit)), 100)
|
||||
|
||||
effective_offset = offset if request.args.get("offset") else (page - 1) * page_size
|
||||
|
||||
compliance_list = MfaPolicyService.get_org_compliance_list(
|
||||
organization_id=org_id,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
limit=page_size,
|
||||
offset=effective_offset,
|
||||
)
|
||||
|
||||
def format_member(c):
|
||||
"""Normalize compliance record to UI-expected shape."""
|
||||
if isinstance(c, dict):
|
||||
return {
|
||||
"user_id": c.get("user_id"),
|
||||
"user_email": c.get("email"),
|
||||
"user_name": c.get("full_name"),
|
||||
"status": c.get("status"),
|
||||
"deadline_at": c.get("deadline_at"),
|
||||
"compliant_at": c.get("compliant_at"),
|
||||
"last_notified_at": c.get("notified_at"),
|
||||
}
|
||||
return {
|
||||
"user_id": getattr(c, "user_id", None),
|
||||
"user_email": getattr(c, "email", None),
|
||||
"user_name": getattr(c, "full_name", None),
|
||||
"status": getattr(c, "status", None),
|
||||
"deadline_at": getattr(c, "deadline_at", None),
|
||||
"compliant_at": getattr(c, "compliant_at", None),
|
||||
"last_notified_at": getattr(c, "notified_at", None),
|
||||
}
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"compliance": compliance_list,
|
||||
"members": [format_member(c) for c in compliance_list],
|
||||
"count": len(compliance_list),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
},
|
||||
message="Compliance records retrieved successfully",
|
||||
)
|
||||
@@ -325,12 +351,10 @@ def get_my_mfa_compliance():
|
||||
|
||||
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,
|
||||
}
|
||||
"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",
|
||||
)
|
||||
@@ -458,12 +458,22 @@ def add_principal_member(org_id, principal_id):
|
||||
error_type="CONFLICT",
|
||||
)
|
||||
|
||||
# Add member
|
||||
membership = PrincipalMembership(
|
||||
user_id=user.id,
|
||||
principal_id=principal_id,
|
||||
)
|
||||
db.session.add(membership)
|
||||
soft_deleted = PrincipalMembership.query.filter(
|
||||
PrincipalMembership.user_id == user.id,
|
||||
PrincipalMembership.principal_id == principal_id,
|
||||
PrincipalMembership.deleted_at.isnot(None)
|
||||
).first()
|
||||
|
||||
if soft_deleted:
|
||||
soft_deleted.deleted_at = None
|
||||
membership = soft_deleted
|
||||
else:
|
||||
membership = PrincipalMembership(
|
||||
user_id=user.id,
|
||||
principal_id=principal_id,
|
||||
)
|
||||
db.session.add(membership)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
member_dict = membership.to_dict()
|
||||
@@ -665,7 +675,7 @@ def link_principal_to_department(org_id, principal_id, dept_id):
|
||||
soft_deleted = DepartmentPrincipal.query.filter(
|
||||
DepartmentPrincipal.department_id == dept_id,
|
||||
DepartmentPrincipal.principal_id == principal_id,
|
||||
DepartmentPrincipal.deleted_at != None, # noqa: E711
|
||||
DepartmentPrincipal.deleted_at.isnot(None),
|
||||
).first()
|
||||
|
||||
try:
|
||||
@@ -678,13 +688,8 @@ def link_principal_to_department(org_id, principal_id, dept_id):
|
||||
)
|
||||
db.session.add(link)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
from gatehouse_app.extensions import db as _db
|
||||
try:
|
||||
_db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Failed to link principal to department",
|
||||
@@ -693,6 +698,10 @@ def link_principal_to_department(org_id, principal_id, dept_id):
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"principal": principal.to_dict(),
|
||||
"department": dept.to_dict(),
|
||||
},
|
||||
message="Principal linked to department successfully",
|
||||
status=201,
|
||||
)
|
||||
|
||||
+127
-10
@@ -30,7 +30,7 @@ ssh_ca_service = SSHCASigningService()
|
||||
def _get_org_ca_for_user(user):
|
||||
"""Return the active DB CA for the user's first org, or None."""
|
||||
try:
|
||||
from gatehouse_app.models.ca import CA
|
||||
from gatehouse_app.models.ssh_ca.ca import CA
|
||||
org_ids = [m.organization_id for m in user.organization_memberships]
|
||||
if not org_ids:
|
||||
return None
|
||||
@@ -51,7 +51,7 @@ def _get_or_create_system_ca():
|
||||
The record is created on first use and has no ``organization_id``.
|
||||
"""
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.ca import CA, KeyType
|
||||
from gatehouse_app.models.ssh_ca.ca import CA, KeyType
|
||||
from gatehouse_app.config.ssh_ca_config import get_ssh_ca_config
|
||||
from gatehouse_app.utils.crypto import compute_ssh_fingerprint
|
||||
import os
|
||||
@@ -132,8 +132,8 @@ def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=N
|
||||
|
||||
try:
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.ssh_certificate import SSHCertificate, CertificateStatus
|
||||
from gatehouse_app.models.ca import CertType
|
||||
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate, CertificateStatus
|
||||
from gatehouse_app.models.ssh_ca.ca import CertType
|
||||
|
||||
try:
|
||||
resolved_cert_type = CertType(cert_type_str)
|
||||
@@ -172,6 +172,87 @@ def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=N
|
||||
|
||||
|
||||
|
||||
def _get_merged_dept_cert_policy(user_id):
|
||||
"""Return a merged cert policy view for the given user across all their departments.
|
||||
|
||||
Rules for merging when a user belongs to multiple departments:
|
||||
- ``allow_user_expiry``: True only if ALL departments allow it.
|
||||
- ``default_expiry_hours``: minimum across departments (most restrictive).
|
||||
- ``max_expiry_hours``: minimum across departments (most restrictive).
|
||||
- ``extensions``: intersection — only extensions allowed by ALL departments.
|
||||
|
||||
Returns a plain dict with keys:
|
||||
allow_user_expiry, default_expiry_hours, max_expiry_hours, extensions
|
||||
Or None if the user has no department memberships or no policies are configured.
|
||||
"""
|
||||
from gatehouse_app.models.organization.department import DepartmentMembership
|
||||
from gatehouse_app.models.organization.department_cert_policy import DepartmentCertPolicy, STANDARD_EXTENSIONS
|
||||
|
||||
memberships = DepartmentMembership.query.filter_by(user_id=user_id, deleted_at=None).all()
|
||||
dept_ids = [m.department_id for m in memberships if m.department and m.department.deleted_at is None]
|
||||
if not dept_ids:
|
||||
return None
|
||||
|
||||
policies = DepartmentCertPolicy.query.filter(
|
||||
DepartmentCertPolicy.department_id.in_(dept_ids),
|
||||
DepartmentCertPolicy.deleted_at.is_(None),
|
||||
).all()
|
||||
if not policies:
|
||||
return None
|
||||
|
||||
allow_user_expiry = all(p.allow_user_expiry for p in policies)
|
||||
default_expiry_hours = min(p.default_expiry_hours for p in policies)
|
||||
max_expiry_hours = min(p.max_expiry_hours for p in policies)
|
||||
|
||||
# Intersection of all_extensions() across policies
|
||||
ext_sets = [set(p.all_extensions()) for p in policies]
|
||||
extensions = list(ext_sets[0].intersection(*ext_sets[1:]))
|
||||
|
||||
return {
|
||||
"allow_user_expiry": allow_user_expiry,
|
||||
"default_expiry_hours": default_expiry_hours,
|
||||
"max_expiry_hours": max_expiry_hours,
|
||||
"extensions": extensions,
|
||||
}
|
||||
|
||||
|
||||
@ssh_bp.route('/dept-cert-policy', methods=['GET'])
|
||||
@login_required
|
||||
def get_my_dept_cert_policy():
|
||||
"""Return the merged department certificate policy for the current user.
|
||||
|
||||
Admins always get allow_user_expiry=True so the frontend shows the expiry
|
||||
picker for them regardless of the member-facing toggle setting.
|
||||
"""
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.department_cert_policy import STANDARD_EXTENSIONS
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
user = g.current_user
|
||||
user_id = user.id
|
||||
|
||||
# Check if caller is an org admin/owner
|
||||
is_org_admin = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
|
||||
OrganizationMember.deleted_at == None,
|
||||
).first() is not None
|
||||
|
||||
policy = _get_merged_dept_cert_policy(user_id)
|
||||
if policy is None:
|
||||
policy = {
|
||||
"allow_user_expiry": is_org_admin, # admins default to True even without a dept policy
|
||||
"default_expiry_hours": 1,
|
||||
"max_expiry_hours": 24,
|
||||
"extensions": list(STANDARD_EXTENSIONS),
|
||||
}
|
||||
elif is_org_admin:
|
||||
# Override allow_user_expiry for admins — they can always pick
|
||||
policy = {**policy, "allow_user_expiry": True}
|
||||
|
||||
return api_response(data={"policy": policy}, message="Certificate policy retrieved")
|
||||
|
||||
|
||||
@ssh_bp.route('/keys', methods=['GET'])
|
||||
@login_required
|
||||
def list_ssh_keys():
|
||||
@@ -375,6 +456,16 @@ def sign_certificate():
|
||||
user = g.current_user
|
||||
user_id = user.id
|
||||
|
||||
# ── Check account suspension ──────────────────────────────────────────────
|
||||
from gatehouse_app.utils.constants import UserStatus
|
||||
if user.status == UserStatus.SUSPENDED:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Your account is suspended. Contact an administrator.",
|
||||
status=403,
|
||||
error_type="ACCOUNT_SUSPENDED",
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return api_response(success=False, message="No JSON data provided", status=400, error_type="BAD_REQUEST")
|
||||
@@ -385,9 +476,9 @@ def sign_certificate():
|
||||
expiry_hours = data.get('expiry_hours')
|
||||
|
||||
# ── Resolve which principals the user is allowed to use ──────────────────
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.principal import Principal, PrincipalMembership
|
||||
from gatehouse_app.models.department import DepartmentMembership, DepartmentPrincipal
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
|
||||
from gatehouse_app.models.organization.department import DepartmentMembership, DepartmentPrincipal
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
allowed_principal_names = set()
|
||||
@@ -468,12 +559,38 @@ def sign_certificate():
|
||||
db_ca = _get_org_ca_for_user(user)
|
||||
ca_private_key = db_ca.private_key if db_ca else None
|
||||
|
||||
# Determine if the caller is an org admin/owner (admins can always choose expiry)
|
||||
is_org_admin = any(
|
||||
om.role in (OrganizationRole.ADMIN, OrganizationRole.OWNER)
|
||||
for om in memberships
|
||||
if om.organization and om.organization.deleted_at is None
|
||||
)
|
||||
|
||||
# ── Apply department certificate policy ───────────────────────────────────
|
||||
dept_policy = _get_merged_dept_cert_policy(user_id)
|
||||
if dept_policy:
|
||||
if is_org_admin:
|
||||
# Admins can always choose their own expiry, but still capped at dept max
|
||||
if expiry_hours is not None:
|
||||
expiry_hours = min(int(expiry_hours), dept_policy["max_expiry_hours"])
|
||||
elif not dept_policy["allow_user_expiry"]:
|
||||
# Regular members: ignore user-requested expiry; use dept default
|
||||
expiry_hours = dept_policy["default_expiry_hours"]
|
||||
else:
|
||||
# Regular members allowed to pick, cap at dept maximum
|
||||
if expiry_hours is not None:
|
||||
expiry_hours = min(int(expiry_hours), dept_policy["max_expiry_hours"])
|
||||
policy_extensions = dept_policy["extensions"]
|
||||
else:
|
||||
policy_extensions = None # let signing service use its own defaults
|
||||
|
||||
signing_request = SSHCertificateSigningRequest(
|
||||
ssh_public_key=ssh_key.payload,
|
||||
principals=principals,
|
||||
cert_type=cert_type,
|
||||
key_id=key_id,
|
||||
expiry_hours=int(expiry_hours) if expiry_hours else None,
|
||||
extensions=policy_extensions,
|
||||
)
|
||||
validation_errors = signing_request.validate()
|
||||
if validation_errors:
|
||||
@@ -547,7 +664,7 @@ def list_certificates():
|
||||
user_id = g.current_user.id
|
||||
|
||||
try:
|
||||
from gatehouse_app.models.ssh_certificate import SSHCertificate
|
||||
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate
|
||||
certs = (
|
||||
SSHCertificate.query
|
||||
.filter_by(user_id=user_id, deleted_at=None)
|
||||
@@ -577,7 +694,7 @@ def get_certificate(cert_id):
|
||||
user_id = g.current_user.id
|
||||
|
||||
try:
|
||||
from gatehouse_app.models.ssh_certificate import SSHCertificate
|
||||
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate
|
||||
cert = SSHCertificate.query.filter_by(id=cert_id, deleted_at=None).first()
|
||||
if not cert:
|
||||
return api_response(success=False, message='Certificate not found', status=404, error_type='NOT_FOUND')
|
||||
@@ -600,7 +717,7 @@ def revoke_certificate(cert_id):
|
||||
reason = data.get('reason', 'User requested revocation')
|
||||
|
||||
try:
|
||||
from gatehouse_app.models.ssh_certificate import SSHCertificate
|
||||
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate
|
||||
cert = SSHCertificate.query.filter_by(id=cert_id, deleted_at=None).first()
|
||||
if not cert:
|
||||
return api_response(success=False, message='Certificate not found', status=404, error_type='NOT_FOUND')
|
||||
|
||||
+274
-17
@@ -142,18 +142,33 @@ def change_password():
|
||||
@full_access_required
|
||||
def get_my_organizations():
|
||||
"""
|
||||
Get all organizations current user is a member of.
|
||||
Get all organizations current user is a member of, including the user's role.
|
||||
|
||||
Returns:
|
||||
200: List of organizations
|
||||
200: List of organizations with role
|
||||
401: Not authenticated
|
||||
"""
|
||||
organizations = UserService.get_user_organizations(g.current_user)
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
|
||||
user = g.current_user
|
||||
memberships = OrganizationMember.query.filter_by(
|
||||
user_id=user.id,
|
||||
deleted_at=None,
|
||||
).all()
|
||||
|
||||
orgs = []
|
||||
for membership in memberships:
|
||||
org = membership.organization
|
||||
if not org or org.deleted_at is not None:
|
||||
continue
|
||||
org_dict = org.to_dict()
|
||||
org_dict["role"] = membership.role.value if hasattr(membership.role, "value") else str(membership.role)
|
||||
orgs.append(org_dict)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"organizations": [org.to_dict() for org in organizations],
|
||||
"count": len(organizations),
|
||||
"organizations": orgs,
|
||||
"count": len(orgs),
|
||||
},
|
||||
message="Organizations retrieved successfully",
|
||||
)
|
||||
@@ -179,9 +194,9 @@ def get_my_principals():
|
||||
}]
|
||||
}
|
||||
"""
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.principal import Principal, PrincipalMembership
|
||||
from gatehouse_app.models.department import DepartmentMembership, DepartmentPrincipal
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
|
||||
from gatehouse_app.models.organization.department import DepartmentMembership, DepartmentPrincipal
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
user = g.current_user
|
||||
@@ -202,7 +217,9 @@ def get_my_principals():
|
||||
is_admin = role in (OrganizationRole.ADMIN, OrganizationRole.OWNER)
|
||||
|
||||
# Collect the user's effective principals for this org
|
||||
effective_principal_ids = set()
|
||||
# Track direct vs via-department separately
|
||||
direct_principal_ids = set()
|
||||
via_dept_principal_ids = set()
|
||||
|
||||
# Direct memberships
|
||||
direct = PrincipalMembership.query.filter_by(
|
||||
@@ -211,7 +228,7 @@ def get_my_principals():
|
||||
).all()
|
||||
for pm in direct:
|
||||
if pm.principal and pm.principal.organization_id == org.id and pm.principal.deleted_at is None:
|
||||
effective_principal_ids.add(pm.principal_id)
|
||||
direct_principal_ids.add(pm.principal_id)
|
||||
|
||||
# Via department
|
||||
dept_memberships = DepartmentMembership.query.filter_by(
|
||||
@@ -226,7 +243,9 @@ def get_my_principals():
|
||||
).all()
|
||||
for dp in dept_principals:
|
||||
if dp.principal and dp.principal.deleted_at is None:
|
||||
effective_principal_ids.add(dp.principal_id)
|
||||
via_dept_principal_ids.add(dp.principal_id)
|
||||
|
||||
effective_principal_ids = direct_principal_ids | via_dept_principal_ids
|
||||
|
||||
# Fetch principal objects
|
||||
my_principals = []
|
||||
@@ -235,7 +254,16 @@ def get_my_principals():
|
||||
Principal.id.in_(list(effective_principal_ids)),
|
||||
Principal.deleted_at == None,
|
||||
).all()
|
||||
my_principals = [{"id": p.id, "name": p.name, "description": p.description} for p in my_p]
|
||||
my_principals = [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"description": p.description,
|
||||
# direct=True means removable via API; False=inherited via department
|
||||
"direct": p.id in direct_principal_ids,
|
||||
}
|
||||
for p in my_p
|
||||
]
|
||||
|
||||
# For admins/owners: also return all principals in the org
|
||||
all_principals = []
|
||||
@@ -263,6 +291,7 @@ def get_my_principals():
|
||||
|
||||
@api_v1_bp.route("/admin/users", methods=["GET"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_list_users():
|
||||
"""List all users the caller has admin rights to see.
|
||||
|
||||
@@ -275,8 +304,8 @@ def admin_list_users():
|
||||
page – page number (default 1)
|
||||
per_page – page size (default 50, max 200)
|
||||
"""
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.user import User as _User
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.user.user import User as _User
|
||||
from gatehouse_app.extensions import db as _db
|
||||
from sqlalchemy import or_
|
||||
|
||||
@@ -355,11 +384,12 @@ def admin_list_users():
|
||||
|
||||
@api_v1_bp.route("/admin/users/<user_id>", methods=["GET"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_get_user(user_id):
|
||||
"""Get a single user's profile (admin view with SSH keys)."""
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.user import User as _User
|
||||
from gatehouse_app.models.ssh_key import SSHKey
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.user.user import User as _User
|
||||
from gatehouse_app.models.ssh_ca.ssh_key import SSHKey
|
||||
|
||||
caller = g.current_user
|
||||
|
||||
@@ -388,3 +418,230 @@ def admin_get_user(user_id):
|
||||
},
|
||||
message="User retrieved",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/users/<user_id>/suspend", methods=["POST"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_suspend_user(user_id):
|
||||
"""Suspend a user account (blocks CA issuance and login).
|
||||
|
||||
The caller must be an OWNER or ADMIN of an organization the target user belongs to.
|
||||
"""
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.user.user import User as _User
|
||||
from gatehouse_app.extensions import db as _db
|
||||
from gatehouse_app.utils.constants import UserStatus, AuditAction
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
|
||||
caller = g.current_user
|
||||
target = _User.query.filter_by(id=user_id, deleted_at=None).first()
|
||||
if not target:
|
||||
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
if target.id == caller.id:
|
||||
return api_response(success=False, message="Cannot suspend yourself", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
# Verify caller has admin access to a shared org
|
||||
target_org_ids = {m.organization_id for m in target.organization_memberships if m.deleted_at is None}
|
||||
admin_in_shared_org = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == caller.id,
|
||||
OrganizationMember.organization_id.in_(target_org_ids),
|
||||
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
|
||||
OrganizationMember.deleted_at == None,
|
||||
).first()
|
||||
|
||||
if not admin_in_shared_org:
|
||||
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
if target.status == UserStatus.SUSPENDED:
|
||||
return api_response(success=False, message="User is already suspended", status=409, error_type="CONFLICT")
|
||||
|
||||
target.status = UserStatus.SUSPENDED
|
||||
_db.session.commit()
|
||||
|
||||
AuditService.log_action(
|
||||
action=AuditAction.USER_SUSPEND,
|
||||
user_id=caller.id,
|
||||
organization_id=admin_in_shared_org.organization_id,
|
||||
resource_type="user",
|
||||
resource_id=str(target.id),
|
||||
description=f"Admin suspended user {target.email}",
|
||||
metadata={"target_user_id": str(target.id), "target_email": target.email},
|
||||
)
|
||||
|
||||
return api_response(data={"user": target.to_dict()}, message="User suspended successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/users/<user_id>/unsuspend", methods=["POST"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_unsuspend_user(user_id):
|
||||
"""Restore a suspended user account to active status."""
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.user.user import User as _User
|
||||
from gatehouse_app.extensions import db as _db
|
||||
from gatehouse_app.utils.constants import UserStatus, AuditAction
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
|
||||
caller = g.current_user
|
||||
target = _User.query.filter_by(id=user_id, deleted_at=None).first()
|
||||
if not target:
|
||||
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
# Verify caller has admin access to a shared org
|
||||
target_org_ids = {m.organization_id for m in target.organization_memberships if m.deleted_at is None}
|
||||
admin_in_shared_org = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == caller.id,
|
||||
OrganizationMember.organization_id.in_(target_org_ids),
|
||||
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
|
||||
OrganizationMember.deleted_at == None,
|
||||
).first()
|
||||
|
||||
if not admin_in_shared_org:
|
||||
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
if target.status != UserStatus.SUSPENDED:
|
||||
return api_response(success=False, message="User is not suspended", status=409, error_type="CONFLICT")
|
||||
|
||||
target.status = UserStatus.ACTIVE
|
||||
_db.session.commit()
|
||||
|
||||
AuditService.log_action(
|
||||
action=AuditAction.USER_UNSUSPEND,
|
||||
user_id=caller.id,
|
||||
organization_id=admin_in_shared_org.organization_id,
|
||||
resource_type="user",
|
||||
resource_id=str(target.id),
|
||||
description=f"Admin unsuspended user {target.email}",
|
||||
metadata={"target_user_id": str(target.id), "target_email": target.email},
|
||||
)
|
||||
|
||||
return api_response(data={"user": target.to_dict()}, message="User unsuspended successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me/invites", methods=["GET"])
|
||||
@login_required
|
||||
def get_my_pending_invites():
|
||||
"""Return pending (unaccepted, non-expired) invitations for the current user's email."""
|
||||
from gatehouse_app.models.organization.org_invite_token import OrgInviteToken
|
||||
from datetime import datetime, timezone
|
||||
|
||||
user = g.current_user
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
invites = OrgInviteToken.query.filter(
|
||||
OrgInviteToken.email == user.email,
|
||||
OrgInviteToken.accepted_at.is_(None),
|
||||
OrgInviteToken.expires_at > now,
|
||||
OrgInviteToken.deleted_at.is_(None),
|
||||
).all()
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"invites": [
|
||||
{
|
||||
"token": i.token,
|
||||
"organization": {"id": str(i.organization_id), "name": i.organization.name},
|
||||
"role": i.role,
|
||||
"expires_at": i.expires_at.isoformat(),
|
||||
}
|
||||
for i in invites
|
||||
]
|
||||
},
|
||||
message="Pending invitations retrieved",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me/memberships", methods=["GET"])
|
||||
@login_required
|
||||
def get_my_memberships():
|
||||
"""Return the current user's department and principal memberships across all orgs.
|
||||
|
||||
Returns:
|
||||
200: {
|
||||
orgs: [{
|
||||
org_id, org_name, role,
|
||||
departments: [{id, name, description}],
|
||||
principals: [{id, name, description, via_department: bool}]
|
||||
}]
|
||||
}
|
||||
"""
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.department import DepartmentMembership, DepartmentPrincipal, Department
|
||||
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
|
||||
|
||||
user = g.current_user
|
||||
|
||||
memberships = OrganizationMember.query.filter_by(
|
||||
user_id=user.id,
|
||||
deleted_at=None,
|
||||
).all()
|
||||
|
||||
orgs_result = []
|
||||
for membership in memberships:
|
||||
org = membership.organization
|
||||
if not org or org.deleted_at is not None:
|
||||
continue
|
||||
|
||||
# Departments the user belongs to
|
||||
dept_memberships = DepartmentMembership.query.filter_by(
|
||||
user_id=user.id,
|
||||
deleted_at=None,
|
||||
).all()
|
||||
user_depts = [
|
||||
dm.department for dm in dept_memberships
|
||||
if dm.department
|
||||
and dm.department.organization_id == org.id
|
||||
and dm.department.deleted_at is None
|
||||
]
|
||||
|
||||
# Principals: direct
|
||||
direct_pm = PrincipalMembership.query.filter_by(
|
||||
user_id=user.id,
|
||||
deleted_at=None,
|
||||
).all()
|
||||
direct_principal_ids = {
|
||||
pm.principal_id for pm in direct_pm
|
||||
if pm.principal
|
||||
and pm.principal.organization_id == org.id
|
||||
and pm.principal.deleted_at is None
|
||||
}
|
||||
|
||||
# Principals: via department
|
||||
via_dept_principal_ids = set()
|
||||
for dept in user_depts:
|
||||
for dp in DepartmentPrincipal.query.filter_by(department_id=dept.id, deleted_at=None).all():
|
||||
if dp.principal and dp.principal.deleted_at is None:
|
||||
via_dept_principal_ids.add(dp.principal_id)
|
||||
|
||||
all_principal_ids = direct_principal_ids | via_dept_principal_ids
|
||||
principals_list = []
|
||||
if all_principal_ids:
|
||||
for p in Principal.query.filter(
|
||||
Principal.id.in_(list(all_principal_ids)),
|
||||
Principal.deleted_at == None,
|
||||
).all():
|
||||
principals_list.append({
|
||||
"id": str(p.id),
|
||||
"name": p.name,
|
||||
"description": p.description,
|
||||
"via_department": p.id not in direct_principal_ids,
|
||||
})
|
||||
|
||||
role = membership.role
|
||||
orgs_result.append({
|
||||
"org_id": str(org.id),
|
||||
"org_name": org.name,
|
||||
"role": role.value if hasattr(role, "value") else role,
|
||||
"departments": [
|
||||
{"id": str(d.id), "name": d.name, "description": d.description}
|
||||
for d in user_depts
|
||||
],
|
||||
"principals": principals_list,
|
||||
})
|
||||
|
||||
return api_response(
|
||||
data={"orgs": orgs_result},
|
||||
message="Memberships retrieved",
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ class SSHCAConfig:
|
||||
Example:
|
||||
config = SSHCAConfig()
|
||||
cert_hours = config.get_int('cert_validity_hours')
|
||||
kms_key = config.get_str('aws_kms_key_id')
|
||||
key_path = config.get_str('ca_key_path')
|
||||
"""
|
||||
|
||||
# Configuration file location (relative to project root)
|
||||
@@ -28,32 +28,13 @@ class SSHCAConfig:
|
||||
|
||||
# Default values if config file is missing
|
||||
DEFAULTS = {
|
||||
'cert_validity_hours': '1',
|
||||
'max_cert_validity_hours': '24',
|
||||
'max_certs_per_user': '100',
|
||||
'crl_enabled': 'true',
|
||||
'crl_endpoint': 'https://ca.example.com/crl',
|
||||
'crl_refresh_hours': '24',
|
||||
'default_key_type': 'ed25519',
|
||||
'rsa_key_bits': '4096',
|
||||
'private_key_encryption': 'kms',
|
||||
'aws_kms_key_id': '',
|
||||
'extensions_enabled': 'true',
|
||||
'extensions': 'permit-X11-forwarding,permit-agent-forwarding,permit-pty,permit-port-forwarding,permit-user-rc',
|
||||
'critical_options_enabled': 'false',
|
||||
'cert_validity_hours': '8',
|
||||
'max_cert_validity_hours': '720',
|
||||
'ca_key_path': '',
|
||||
'max_principals_per_cert': '256',
|
||||
'max_key_id_length': '255',
|
||||
'log_level': 'INFO',
|
||||
'audit_enabled': 'true',
|
||||
'require_key_verification': 'true',
|
||||
'verification_challenge_max_age': '24',
|
||||
'rate_limit_certs_per_minute': '5',
|
||||
'request_timeout': '30',
|
||||
'auto_delete_unverified_days': '30',
|
||||
'archive_expired_days': '365',
|
||||
'oauth_token_endpoint': '/api/v1/oauth2/token',
|
||||
'oauth_userinfo_endpoint': '/api/v1/oauth2/userinfo',
|
||||
'ca_key_path': '',
|
||||
}
|
||||
|
||||
def __init__(self, config_file: Optional[str] = None, environment: Optional[str] = None):
|
||||
@@ -189,12 +170,12 @@ class SSHCAConfig:
|
||||
|
||||
def validate_config(self) -> list:
|
||||
"""Validate SSH CA configuration.
|
||||
|
||||
|
||||
Returns:
|
||||
List of validation error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
|
||||
# Check cert validity hours
|
||||
try:
|
||||
validity = self.get_int('cert_validity_hours')
|
||||
@@ -205,34 +186,16 @@ class SSHCAConfig:
|
||||
)
|
||||
except ValueError as e:
|
||||
errors.append(f"Invalid cert validity hours: {e}")
|
||||
|
||||
# Check key type
|
||||
valid_key_types = ['ed25519', 'rsa', 'ecdsa']
|
||||
key_type = self.get_str('default_key_type', 'ed25519')
|
||||
if key_type not in valid_key_types:
|
||||
errors.append(f"Invalid key type: {key_type}. Must be one of {valid_key_types}")
|
||||
|
||||
# Check encryption method
|
||||
valid_methods = ['kms', 'local']
|
||||
encryption = self.get_str('private_key_encryption', 'kms')
|
||||
if encryption not in valid_methods:
|
||||
errors.append(f"Invalid private_key_encryption: {encryption}. Must be one of {valid_methods}")
|
||||
|
||||
# Warn if using local encryption in production
|
||||
if encryption == 'local' and self.environment == 'production':
|
||||
errors.append("WARNING: Using local key encryption in production! Use KMS instead.")
|
||||
|
||||
# Check KMS key ID if using KMS
|
||||
if encryption == 'kms':
|
||||
kms_key = self.get_str('aws_kms_key_id', '').strip()
|
||||
if not kms_key:
|
||||
errors.append("aws_kms_key_id not set but private_key_encryption=kms")
|
||||
|
||||
|
||||
# Check principals limit
|
||||
max_principals = self.get_int('max_principals_per_cert')
|
||||
if max_principals > 256:
|
||||
errors.append(f"max_principals_per_cert ({max_principals}) exceeds SSH limit of 256")
|
||||
|
||||
|
||||
# Check ca_key_path is set
|
||||
if not self.get_str('ca_key_path', '').strip():
|
||||
errors.append("ca_key_path is not set")
|
||||
|
||||
return errors
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Security headers middleware."""
|
||||
from flask import request
|
||||
import os
|
||||
from flask import current_app, request
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
@@ -34,13 +35,22 @@ class SecurityHeadersMiddleware:
|
||||
)
|
||||
|
||||
# Content Security Policy
|
||||
try:
|
||||
flask_env = current_app.config.get("ENV") or os.environ.get("FLASK_ENV", "production")
|
||||
if flask_env == "development":
|
||||
connect_src = "connect-src 'self' http://localhost:5000 http://127.0.0.1:5000"
|
||||
else:
|
||||
connect_src = "connect-src 'self'"
|
||||
except RuntimeError:
|
||||
connect_src = "connect-src 'self'"
|
||||
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: https:; "
|
||||
"font-src 'self' data:; "
|
||||
"connect-src 'self'"
|
||||
+ connect_src
|
||||
)
|
||||
|
||||
# Referrer Policy
|
||||
|
||||
@@ -25,7 +25,7 @@ class LoginSchema(Schema):
|
||||
|
||||
email = fields.Email(required=True)
|
||||
password = fields.Str(required=True, validate=validate.Length(min=1))
|
||||
remember_me = fields.Bool(missing=False)
|
||||
remember_me = fields.Bool(load_default=False)
|
||||
|
||||
|
||||
class RefreshTokenSchema(Schema):
|
||||
@@ -78,7 +78,7 @@ class TOTPVerifySchema(Schema):
|
||||
"""Schema for TOTP code verification during login."""
|
||||
|
||||
code = fields.Str(required=True)
|
||||
is_backup_code = fields.Bool(missing=False)
|
||||
is_backup_code = fields.Bool(load_default=False)
|
||||
client_timestamp = fields.Int(
|
||||
required=False,
|
||||
allow_none=True,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,6 +61,8 @@ class AuditAction(str, Enum):
|
||||
USER_REGISTER = "user.register"
|
||||
USER_UPDATE = "user.update"
|
||||
USER_DELETE = "user.delete"
|
||||
USER_SUSPEND = "user.suspend"
|
||||
USER_UNSUSPEND = "user.unsuspend"
|
||||
PASSWORD_CHANGE = "user.password_change"
|
||||
PASSWORD_RESET = "user.password_reset"
|
||||
|
||||
|
||||
@@ -64,11 +64,47 @@ def login_required(f):
|
||||
session.last_activity_at = datetime.now(timezone.utc)
|
||||
from gatehouse_app import db
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# Set context variables
|
||||
g.current_user = session.user
|
||||
g.current_session = session
|
||||
|
||||
|
||||
user = session.user
|
||||
token_groups: list = []
|
||||
try:
|
||||
if session.device_info:
|
||||
# device_info may carry OIDC claims stored at login time
|
||||
claims = session.device_info
|
||||
# Normalise: Gatehouse stores roles as [{"organization_id":…,"role":…}]
|
||||
roles_claim = claims.get("roles", [])
|
||||
if isinstance(roles_claim, list):
|
||||
for entry in roles_claim:
|
||||
if isinstance(entry, dict):
|
||||
role_val = entry.get("role")
|
||||
if role_val:
|
||||
token_groups.append(str(role_val))
|
||||
elif isinstance(entry, str):
|
||||
token_groups.append(entry)
|
||||
# Standard OIDC groups claim
|
||||
groups_claim = claims.get("groups", [])
|
||||
if isinstance(groups_claim, list):
|
||||
token_groups.extend(str(g_) for g_ in groups_claim if g_)
|
||||
except Exception:
|
||||
pass # Never block auth over token_groups enrichment failure
|
||||
user.token_groups = token_groups
|
||||
|
||||
# Activation check: if the user has an `activated` attribute and it is
|
||||
# explicitly False, block access. New accounts without the attribute are
|
||||
# treated as active to avoid breaking existing sessions.
|
||||
activated = getattr(user, "activated", None)
|
||||
if activated is False:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Account not yet activated. Please check your email for an activation link.",
|
||||
status=403,
|
||||
error_type="ACCOUNT_NOT_ACTIVATED",
|
||||
)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
@@ -97,11 +133,12 @@ def require_role(*allowed_roles):
|
||||
raise ForbiddenError("Organization context required")
|
||||
|
||||
# Check user's role in the organization
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
|
||||
membership = OrganizationMember.query.filter_by(
|
||||
user_id=g.current_user.id,
|
||||
organization_id=org_id,
|
||||
deleted_at=None,
|
||||
).first()
|
||||
|
||||
if not membership:
|
||||
|
||||
Reference in New Issue
Block a user