Feat(Chore, Fix): Refractor, Half Baked Deletion + Admin Privilege
Refractor Codes into sub file/folders Admin can remove users'/members mfa/2fa, unlink account from oauth provider Admin can add/reset password Different Email (OIDC + Manual)-Same Account; (Block Linking and authorize if available)
This commit is contained in:
@@ -12,7 +12,7 @@ from flask import Blueprint, request, redirect, jsonify, session, g, current_app
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.services.oidc_service import (
|
||||
from gatehouse_app.services.oidc import (
|
||||
OIDCService, InvalidClientError, InvalidGrantError, InvalidRequestError
|
||||
)
|
||||
from gatehouse_app.services.auth_service import AuthService
|
||||
@@ -756,7 +756,8 @@ def _show_login_page(client_id, redirect_uri, scope, state, nonce, response_type
|
||||
if error:
|
||||
params["error"] = error
|
||||
|
||||
return redirect(f"{ui_base_url}/login?{urlencode(params)}")
|
||||
# /oidc-login is the dedicated OIDC bridge UI (not the main /login page)
|
||||
return redirect(f"{ui_base_url}/oidc-login?{urlencode(params)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
"""Auth blueprint subpackage."""
|
||||
from gatehouse_app.api.v1.auth import core, totp, webauthn, password
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Core auth endpoints: register, login, logout, sessions."""
|
||||
import logging
|
||||
from flask import request, session, g, current_app
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.extensions import limiter
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.schemas.auth_schema import RegisterSchema, LoginSchema
|
||||
from gatehouse_app.services.auth_service import AuthService
|
||||
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
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/register", methods=["POST"])
|
||||
@limiter.limit(lambda: current_app.config["RATELIMIT_AUTH_REGISTER"])
|
||||
def register():
|
||||
try:
|
||||
schema = RegisterSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
user = AuthService.register_user(
|
||||
email=data["email"],
|
||||
password=data["password"],
|
||||
full_name=data.get("full_name"),
|
||||
)
|
||||
|
||||
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}")
|
||||
|
||||
user_session = AuthService.create_session(user)
|
||||
|
||||
from gatehouse_app.models.organization.org_invite_token import OrgInviteToken
|
||||
from gatehouse_app.models.user.user import User as _User
|
||||
from datetime import datetime, timezone as _tz
|
||||
|
||||
now = datetime.now(_tz.utc)
|
||||
pending_invites = OrgInviteToken.query.filter(
|
||||
OrgInviteToken.email == user.email,
|
||||
OrgInviteToken.accepted_at.is_(None),
|
||||
OrgInviteToken.expires_at > now,
|
||||
OrgInviteToken.deleted_at.is_(None),
|
||||
).all()
|
||||
|
||||
total_users = _User.query.filter(_User.deleted_at.is_(None)).count()
|
||||
is_first_user = total_users == 1
|
||||
|
||||
expires_str = user_session.expires_at.isoformat()
|
||||
if expires_str[-1] != "Z":
|
||||
expires_str += "Z"
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"user": user.to_dict(),
|
||||
"token": user_session.token,
|
||||
"expires_at": expires_str,
|
||||
"is_first_user": is_first_user,
|
||||
"pending_invites": [
|
||||
{
|
||||
"token": inv.token,
|
||||
"organization": {"id": str(inv.organization_id), "name": inv.organization.name},
|
||||
"role": inv.role,
|
||||
"expires_at": inv.expires_at.isoformat(),
|
||||
}
|
||||
for inv in pending_invites
|
||||
],
|
||||
},
|
||||
message="Registration successful",
|
||||
status=201,
|
||||
)
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/login", methods=["POST"])
|
||||
@limiter.limit(lambda: current_app.config["RATELIMIT_AUTH_LOGIN"])
|
||||
def login():
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
schema = LoginSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
user = AuthService.authenticate(email=data["email"], password=data["password"])
|
||||
|
||||
has_totp = user.has_totp_enabled()
|
||||
has_webauthn = user.has_webauthn_enabled()
|
||||
logger.info(f"Login attempt for user {user.email} - TOTP enabled: {has_totp}, WebAuthn enabled: {has_webauthn}")
|
||||
|
||||
if has_webauthn:
|
||||
session["webauthn_pending_user_id"] = user.id
|
||||
return api_response(data={"requires_webauthn": True}, message="Passkey verification required. Please use your passkey to complete login.")
|
||||
|
||||
if has_totp:
|
||||
session["totp_pending_user_id"] = user.id
|
||||
return api_response(data={"requires_totp": True}, message="TOTP code required. Please enter your 6-digit code from your authenticator app.")
|
||||
|
||||
remember_me = data.get("remember_me", False)
|
||||
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me)
|
||||
duration = 2592000 if remember_me else 86400
|
||||
is_compliance_only = policy_result.create_compliance_only_session
|
||||
|
||||
user_session = AuthService.create_session(user, duration_seconds=duration, is_compliance_only=is_compliance_only)
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
if policy_result.compliance_summary:
|
||||
response_data["mfa_compliance"] = {
|
||||
"overall_status": policy_result.compliance_summary.overall_status,
|
||||
"missing_methods": policy_result.compliance_summary.missing_methods,
|
||||
"deadline_at": policy_result.compliance_summary.deadline_at,
|
||||
"orgs": [
|
||||
{
|
||||
"organization_id": org.organization_id,
|
||||
"organization_name": org.organization_name,
|
||||
"status": org.status,
|
||||
"effective_mode": org.effective_mode,
|
||||
"deadline_at": org.deadline_at,
|
||||
"applied_at": org.applied_at,
|
||||
}
|
||||
for org in policy_result.compliance_summary.orgs
|
||||
],
|
||||
}
|
||||
|
||||
if is_compliance_only:
|
||||
response_data["requires_mfa_enrollment"] = True
|
||||
|
||||
user_orgs = user.get_organizations()
|
||||
if not user_orgs:
|
||||
from gatehouse_app.models.organization.org_invite_token import OrgInviteToken
|
||||
from datetime import datetime, timezone as _tz
|
||||
_now = datetime.now(_tz.utc)
|
||||
pending_invites = OrgInviteToken.query.filter(
|
||||
OrgInviteToken.email == user.email,
|
||||
OrgInviteToken.accepted_at.is_(None),
|
||||
OrgInviteToken.expires_at > _now,
|
||||
OrgInviteToken.deleted_at.is_(None),
|
||||
).all()
|
||||
response_data["pending_invites"] = [
|
||||
{
|
||||
"token": inv.token,
|
||||
"organization": {"id": str(inv.organization_id), "name": inv.organization.name},
|
||||
"role": inv.role,
|
||||
"expires_at": inv.expires_at.isoformat(),
|
||||
}
|
||||
for inv in pending_invites
|
||||
]
|
||||
response_data["requires_org_setup"] = True
|
||||
|
||||
return api_response(data=response_data, message="Login successful")
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/logout", methods=["POST"])
|
||||
@login_required
|
||||
def logout():
|
||||
if g.current_session:
|
||||
AuthService.revoke_session(g.current_session.id, reason="User logout")
|
||||
return api_response(message="Logout successful")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/me", methods=["GET"])
|
||||
@login_required
|
||||
def get_current_user():
|
||||
user = g.current_user
|
||||
return api_response(
|
||||
data={
|
||||
"user": user.to_dict(),
|
||||
"organizations": [
|
||||
{
|
||||
"id": membership.organization.id,
|
||||
"name": membership.organization.name,
|
||||
"slug": membership.organization.slug,
|
||||
"role": membership.role.value if hasattr(membership.role, "value") else str(membership.role),
|
||||
}
|
||||
for membership in user.organization_memberships
|
||||
if membership.deleted_at is None and membership.organization and not membership.organization.deleted_at
|
||||
],
|
||||
},
|
||||
message="User retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/sessions", methods=["GET"])
|
||||
@login_required
|
||||
def get_user_sessions():
|
||||
from gatehouse_app.services.session_service import SessionService
|
||||
|
||||
sessions = SessionService.get_user_sessions(g.current_user.id, active_only=True)
|
||||
return api_response(data={"sessions": [s.to_dict() for s in sessions], "count": len(sessions)}, message="Sessions retrieved successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/sessions/<session_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
def revoke_session(session_id):
|
||||
from gatehouse_app.models.user.session import Session
|
||||
|
||||
user_session = Session.query.filter_by(id=session_id, user_id=g.current_user.id, deleted_at=None).first()
|
||||
if not user_session:
|
||||
return api_response(success=False, message="Session not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
AuthService.revoke_session(session_id, reason="Revoked by user")
|
||||
return api_response(message="Session revoked successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/token", methods=["GET"])
|
||||
@login_required
|
||||
def get_token():
|
||||
from flask import redirect as flask_redirect
|
||||
from urllib.parse import urlparse
|
||||
|
||||
token = g.current_session.token
|
||||
redirect_url = request.args.get("redirect", "").strip()
|
||||
|
||||
if redirect_url:
|
||||
allowed_origins = set(current_app.config.get("CORS_ORIGINS", []))
|
||||
frontend_url = current_app.config.get("FRONTEND_URL", "")
|
||||
if frontend_url:
|
||||
parsed = urlparse(frontend_url)
|
||||
allowed_origins.add(f"{parsed.scheme}://{parsed.netloc}")
|
||||
|
||||
parsed_redirect = urlparse(redirect_url)
|
||||
redirect_origin = f"{parsed_redirect.scheme}://{parsed_redirect.netloc}"
|
||||
|
||||
if redirect_origin not in allowed_origins:
|
||||
return api_response(success=False, message="Redirect URL is not allowed.", status=400, error_type="INVALID_REDIRECT")
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Password reset, email verification, and account activation endpoints."""
|
||||
import logging
|
||||
from flask import request, current_app
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.extensions import limiter
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.services.auth_service import AuthService
|
||||
from gatehouse_app.services.notification_service import NotificationService
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/forgot-password", methods=["POST"])
|
||||
@limiter.limit(lambda: current_app.config["RATELIMIT_AUTH_FORGOT_PASSWORD"])
|
||||
def forgot_password():
|
||||
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")
|
||||
|
||||
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}"
|
||||
NotificationService._send_email(
|
||||
to_address=user.email,
|
||||
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"
|
||||
),
|
||||
)
|
||||
_logger.info(f"Password reset token generated for user {user.id}")
|
||||
except Exception as exc:
|
||||
_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"])
|
||||
@limiter.limit(lambda: current_app.config["RATELIMIT_AUTH_RESET_PASSWORD"])
|
||||
def reset_password():
|
||||
from gatehouse_app.extensions import bcrypt
|
||||
from gatehouse_app.models import PasswordResetToken, AuthenticationMethod
|
||||
from gatehouse_app.utils.constants import AuthMethodType
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
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
|
||||
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")
|
||||
db.session.add(auth_method)
|
||||
reset_token.consume()
|
||||
_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:
|
||||
_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():
|
||||
from gatehouse_app.models import EmailVerificationToken
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
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
|
||||
db.session.add(user)
|
||||
verify_token.consume()
|
||||
_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:
|
||||
_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():
|
||||
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}"
|
||||
NotificationService._send_email(
|
||||
to_address=user.email,
|
||||
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"
|
||||
),
|
||||
)
|
||||
_logger.info(f"Verification email sent for user {user.id}")
|
||||
except Exception as exc:
|
||||
_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.")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/activate", methods=["POST"])
|
||||
def activate_account():
|
||||
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
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
user_session = AuthService.create_session(user)
|
||||
_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():
|
||||
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}"
|
||||
NotificationService._send_email(
|
||||
to_address=user.email,
|
||||
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"
|
||||
),
|
||||
)
|
||||
_logger.info(f"Activation email re-sent to {user.id}")
|
||||
except Exception as exc:
|
||||
_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.")
|
||||
@@ -0,0 +1,183 @@
|
||||
"""TOTP authentication endpoints."""
|
||||
from flask import request, session, g, current_app
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.extensions import limiter
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.schemas.auth_schema import (
|
||||
TOTPVerifyEnrollmentSchema,
|
||||
TOTPVerifySchema,
|
||||
TOTPDisableSchema,
|
||||
TOTPRegenerateBackupCodesSchema,
|
||||
)
|
||||
from gatehouse_app.services.auth_service import AuthService
|
||||
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
|
||||
from gatehouse_app.utils.decorators import login_required
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||
from gatehouse_app.exceptions.validation_exceptions import ConflictError
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/totp/enroll", methods=["POST"])
|
||||
@login_required
|
||||
def enroll_totp():
|
||||
try:
|
||||
result = AuthService.enroll_totp(g.current_user)
|
||||
return api_response(
|
||||
data={
|
||||
"secret": result["secret"],
|
||||
"provisioning_uri": result["provisioning_uri"],
|
||||
"qr_code": result["qr_code"],
|
||||
"backup_codes": result["backup_codes"],
|
||||
},
|
||||
message="TOTP enrollment initiated. Please verify with your authenticator app.",
|
||||
status=201,
|
||||
)
|
||||
except ConflictError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/totp/verify-enrollment", methods=["POST"])
|
||||
@login_required
|
||||
def verify_totp_enrollment():
|
||||
try:
|
||||
schema = TOTPVerifyEnrollmentSchema()
|
||||
data = schema.load(request.json)
|
||||
AuthService.verify_totp_enrollment(g.current_user, data["code"], client_utc_timestamp=data.get("client_timestamp"))
|
||||
return api_response(message="TOTP enrollment completed successfully")
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
except InvalidCredentialsError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/totp/verify", methods=["POST"])
|
||||
@limiter.limit(lambda: current_app.config["RATELIMIT_AUTH_TOTP_VERIFY"])
|
||||
def verify_totp():
|
||||
try:
|
||||
schema = TOTPVerifySchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
user_id = session.get("totp_pending_user_id") or session.get("webauthn_pending_user_id")
|
||||
if not user_id:
|
||||
return api_response(success=False, message="No pending TOTP verification. Please login first.", status=401, error_type="AUTHENTICATION_ERROR")
|
||||
|
||||
from gatehouse_app.models.user.user import User
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return api_response(success=False, message="User not found", status=401, error_type="AUTHENTICATION_ERROR")
|
||||
|
||||
from gatehouse_app.utils.constants import UserStatus
|
||||
if user.status in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_SUSPENDED):
|
||||
session.pop("totp_pending_user_id", None)
|
||||
session.pop("webauthn_pending_user_id", None)
|
||||
return api_response(success=False, message="Account is suspended. Contact an administrator.", status=403, error_type="ACCOUNT_SUSPENDED")
|
||||
|
||||
AuthService.authenticate_with_totp(user, data["code"], data.get("is_backup_code", False), client_utc_timestamp=data.get("client_timestamp"))
|
||||
|
||||
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me=False)
|
||||
is_compliance_only = policy_result.create_compliance_only_session
|
||||
user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only)
|
||||
|
||||
session.pop("totp_pending_user_id", None)
|
||||
session.pop("webauthn_pending_user_id", None)
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
if policy_result.compliance_summary:
|
||||
response_data["mfa_compliance"] = {
|
||||
"overall_status": policy_result.compliance_summary.overall_status,
|
||||
"missing_methods": policy_result.compliance_summary.missing_methods,
|
||||
"deadline_at": policy_result.compliance_summary.deadline_at,
|
||||
"orgs": [
|
||||
{
|
||||
"organization_id": org.organization_id,
|
||||
"organization_name": org.organization_name,
|
||||
"status": org.status,
|
||||
"effective_mode": org.effective_mode,
|
||||
"deadline_at": org.deadline_at,
|
||||
"applied_at": org.applied_at,
|
||||
}
|
||||
for org in policy_result.compliance_summary.orgs
|
||||
],
|
||||
}
|
||||
|
||||
if is_compliance_only:
|
||||
response_data["requires_mfa_enrollment"] = True
|
||||
|
||||
return api_response(data=response_data, message="TOTP verification successful")
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
except InvalidCredentialsError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/totp/disable", methods=["DELETE"])
|
||||
@login_required
|
||||
def disable_totp():
|
||||
try:
|
||||
schema = TOTPDisableSchema()
|
||||
data = schema.load(request.json)
|
||||
AuthService.disable_totp(g.current_user, data["password"])
|
||||
return api_response(message="TOTP disabled successfully")
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
except InvalidCredentialsError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/totp/status", methods=["GET"])
|
||||
@login_required
|
||||
def get_totp_status():
|
||||
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.utils.constants import AuthMethodType
|
||||
from gatehouse_app.extensions import db as _db
|
||||
from datetime import datetime, timezone
|
||||
|
||||
user = g.current_user
|
||||
|
||||
stale = AuthenticationMethod.query.filter_by(user_id=user.id, method_type=AuthMethodType.TOTP, verified=False, deleted_at=None).all()
|
||||
for s in stale:
|
||||
secret = (s.provider_data or {}).get("secret") if s.provider_data else None
|
||||
if not secret:
|
||||
s.deleted_at = datetime.now(timezone.utc)
|
||||
_db.session.add(s)
|
||||
if stale:
|
||||
_db.session.commit()
|
||||
|
||||
totp_enabled = user.has_totp_enabled()
|
||||
backup_codes_remaining = 0
|
||||
verified_at = None
|
||||
|
||||
if totp_enabled:
|
||||
totp_method = AuthenticationMethod.query.filter_by(
|
||||
user_id=user.id, method_type=AuthMethodType.TOTP, verified=True, deleted_at=None
|
||||
).order_by(AuthenticationMethod.created_at.desc()).first()
|
||||
|
||||
if totp_method and totp_method.provider_data:
|
||||
backup_codes_remaining = len(totp_method.provider_data.get("backup_codes", []))
|
||||
if totp_method and totp_method.totp_verified_at:
|
||||
ts = totp_method.totp_verified_at.isoformat()
|
||||
verified_at = ts if ts.endswith("Z") else ts + "Z"
|
||||
|
||||
return api_response(
|
||||
data={"totp_enabled": totp_enabled, "verified_at": verified_at, "backup_codes_remaining": backup_codes_remaining},
|
||||
message="TOTP status retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/totp/regenerate-backup-codes", methods=["POST"])
|
||||
@login_required
|
||||
def regenerate_totp_backup_codes():
|
||||
try:
|
||||
schema = TOTPRegenerateBackupCodesSchema()
|
||||
data = schema.load(request.json)
|
||||
backup_codes = AuthService.regenerate_totp_backup_codes(g.current_user, data["password"])
|
||||
return api_response(data={"backup_codes": backup_codes}, message="Backup codes regenerated successfully")
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
except InvalidCredentialsError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""WebAuthn passkey authentication endpoints."""
|
||||
import json
|
||||
import base64
|
||||
import logging
|
||||
from flask import request, session, g, jsonify
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.schemas.webauthn_schema import (
|
||||
WebAuthnRegistrationBeginSchema,
|
||||
WebAuthnRegistrationCompleteSchema,
|
||||
WebAuthnLoginBeginSchema,
|
||||
WebAuthnLoginCompleteSchema,
|
||||
WebAuthnCredentialRenameSchema,
|
||||
)
|
||||
from gatehouse_app.services.auth_service import AuthService
|
||||
from gatehouse_app.services.webauthn_service import WebAuthnService
|
||||
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
|
||||
from gatehouse_app.utils.decorators import login_required
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/webauthn/register/begin", methods=["POST"])
|
||||
@login_required
|
||||
def begin_webauthn_registration():
|
||||
options = WebAuthnService.generate_registration_challenge(g.current_user)
|
||||
return jsonify(options), 200
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/webauthn/register/complete", methods=["POST"])
|
||||
@login_required
|
||||
def complete_webauthn_registration():
|
||||
user_email = g.current_user.email
|
||||
logger.info(f"WebAuthn registration completion started for user: {user_email}")
|
||||
|
||||
try:
|
||||
schema = WebAuthnRegistrationCompleteSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
client_data_json_b64 = data.get("response", {}).get("clientDataJSON", "")
|
||||
if not client_data_json_b64:
|
||||
return api_response(success=False, message="Missing clientDataJSON in response", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
try:
|
||||
padding = 4 - (len(client_data_json_b64) % 4)
|
||||
padded = client_data_json_b64 + ("=" * padding if padding != 4 else "")
|
||||
client_data_dict = json.loads(base64.urlsafe_b64decode(padded))
|
||||
except Exception as e:
|
||||
return api_response(success=False, message=f"Failed to decode client data JSON: {str(e)}", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
challenge = client_data_dict.get("challenge")
|
||||
if not challenge:
|
||||
return api_response(success=False, message="Invalid challenge in client data", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
auth_method = WebAuthnService.verify_registration_response(g.current_user, data, challenge)
|
||||
logger.info(f"WebAuthn registration completed successfully for user: {user_email}")
|
||||
return api_response(data={"credential": auth_method.to_webauthn_dict()}, message="Passkey registered successfully", status=201)
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
except InvalidCredentialsError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
except Exception as e:
|
||||
logger.exception(f"WebAuthn registration unexpected error for user {user_email}: {e}")
|
||||
return api_response(success=False, message="An unexpected error occurred during registration", status=500, error_type="INTERNAL_ERROR")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/webauthn/login/begin", methods=["POST"])
|
||||
def begin_webauthn_login():
|
||||
try:
|
||||
schema = WebAuthnLoginBeginSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
from gatehouse_app.models.user.user import User
|
||||
user = User.query.filter_by(email=data["email"].lower(), deleted_at=None).first()
|
||||
if not user:
|
||||
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
from gatehouse_app.utils.constants import UserStatus
|
||||
if user.status in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_SUSPENDED):
|
||||
return api_response(success=False, message="Account is suspended. Contact an administrator.", status=403, error_type="ACCOUNT_SUSPENDED")
|
||||
|
||||
if not user.has_webauthn_enabled():
|
||||
return api_response(success=False, message="No passkeys found for this account", status=404, error_type="NOT_FOUND")
|
||||
|
||||
options = WebAuthnService.generate_authentication_challenge(user)
|
||||
session["webauthn_pending_user_id"] = user.id
|
||||
return jsonify(options), 200
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
except Exception as e:
|
||||
logger.exception(f"WebAuthn login begin unexpected error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/webauthn/login/complete", methods=["POST"])
|
||||
def complete_webauthn_login():
|
||||
try:
|
||||
user_id = session.get("webauthn_pending_user_id")
|
||||
if not user_id:
|
||||
return api_response(success=False, message="No pending WebAuthn verification. Please initiate login first.", status=401, error_type="AUTHENTICATION_ERROR")
|
||||
|
||||
schema = WebAuthnLoginCompleteSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
from gatehouse_app.models.user.user import User
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return api_response(success=False, message="User not found", status=401, error_type="AUTHENTICATION_ERROR")
|
||||
|
||||
from gatehouse_app.utils.constants import UserStatus
|
||||
if user.status in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_SUSPENDED):
|
||||
session.pop("webauthn_pending_user_id", None)
|
||||
return api_response(success=False, message="Account is suspended. Contact an administrator.", status=403, error_type="ACCOUNT_SUSPENDED")
|
||||
|
||||
client_data = data.get("response", {}).get("clientDataJSON", "")
|
||||
client_data_dict = json.loads(base64.urlsafe_b64decode(client_data + "=="))
|
||||
challenge = client_data_dict.get("challenge")
|
||||
|
||||
if not challenge:
|
||||
return api_response(success=False, message="Invalid challenge in client data", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
WebAuthnService.verify_authentication_response(user, data, challenge)
|
||||
|
||||
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me=False)
|
||||
is_compliance_only = policy_result.create_compliance_only_session
|
||||
user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only)
|
||||
session.pop("webauthn_pending_user_id", None)
|
||||
|
||||
logger.info(f"WebAuthn login completed successfully for user: {user.email}")
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
if policy_result.compliance_summary:
|
||||
response_data["mfa_compliance"] = {
|
||||
"overall_status": policy_result.compliance_summary.overall_status,
|
||||
"missing_methods": policy_result.compliance_summary.missing_methods,
|
||||
"deadline_at": policy_result.compliance_summary.deadline_at,
|
||||
"orgs": [
|
||||
{
|
||||
"organization_id": org.organization_id,
|
||||
"organization_name": org.organization_name,
|
||||
"status": org.status,
|
||||
"effective_mode": org.effective_mode,
|
||||
"deadline_at": org.deadline_at,
|
||||
"applied_at": org.applied_at,
|
||||
}
|
||||
for org in policy_result.compliance_summary.orgs
|
||||
],
|
||||
}
|
||||
|
||||
if is_compliance_only:
|
||||
response_data["requires_mfa_enrollment"] = True
|
||||
|
||||
return api_response(data=response_data, message="Login successful")
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
except InvalidCredentialsError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
except Exception as e:
|
||||
logger.exception(f"WebAuthn login complete unexpected error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/webauthn/credentials", methods=["GET"])
|
||||
@login_required
|
||||
def list_webauthn_credentials():
|
||||
credentials = WebAuthnService.get_user_credentials(g.current_user)
|
||||
return api_response(data={"credentials": [c.to_webauthn_dict() for c in credentials], "count": len(credentials)}, message="Credentials retrieved successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/webauthn/credentials/<credential_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
def delete_webauthn_credential(credential_id):
|
||||
user = g.current_user
|
||||
|
||||
if not WebAuthnService.credential_belongs_to_user(credential_id, user):
|
||||
return api_response(success=False, message="Credential not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
if user.get_webauthn_credential_count() <= 1:
|
||||
return api_response(success=False, message="Cannot delete the last passkey. Add another passkey first.", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
if not WebAuthnService.delete_credential(credential_id, user):
|
||||
return api_response(success=False, message="Credential not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
return api_response(message="Passkey deleted successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/webauthn/credentials/<credential_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
def rename_webauthn_credential(credential_id):
|
||||
try:
|
||||
schema = WebAuthnCredentialRenameSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
if not WebAuthnService.rename_credential(credential_id, g.current_user, data["name"]):
|
||||
return api_response(success=False, message="Credential not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
credential = WebAuthnService.get_credential_by_id(credential_id, g.current_user)
|
||||
return api_response(data={"credential": credential.to_webauthn_dict() if credential else None}, message="Passkey renamed successfully")
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/webauthn/status", methods=["GET"])
|
||||
@login_required
|
||||
def get_webauthn_status():
|
||||
user = g.current_user
|
||||
return api_response(
|
||||
data={"webauthn_enabled": user.has_webauthn_enabled(), "credential_count": user.get_webauthn_credential_count()},
|
||||
message="WebAuthn status retrieved successfully",
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
"""External auth blueprint subpackage."""
|
||||
from gatehouse_app.api.v1.external_auth import cli, providers, oauth, admin
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Shared helpers for external_auth subpackage."""
|
||||
import logging
|
||||
from gatehouse_app.utils.constants import AuthMethodType
|
||||
from gatehouse_app.services.external_auth.models import ExternalAuthError
|
||||
|
||||
_OAUTH_BRIDGE_TTL = 600 # 10 minutes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROVIDER_TYPE_MAP = {
|
||||
"google": AuthMethodType.GOOGLE,
|
||||
"github": AuthMethodType.GITHUB,
|
||||
"microsoft": AuthMethodType.MICROSOFT,
|
||||
}
|
||||
|
||||
|
||||
def get_provider_type(provider: str) -> AuthMethodType:
|
||||
provider_lower = provider.lower()
|
||||
if provider_lower not in PROVIDER_TYPE_MAP:
|
||||
raise ExternalAuthError(f"Unsupported provider: {provider}", "UNSUPPORTED_PROVIDER", 400)
|
||||
return PROVIDER_TYPE_MAP[provider_lower]
|
||||
|
||||
|
||||
def _get_provider_endpoints(provider_type: AuthMethodType):
|
||||
if provider_type == AuthMethodType.GOOGLE:
|
||||
return (
|
||||
"https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"https://oauth2.googleapis.com/token",
|
||||
"https://www.googleapis.com/oauth2/v3/userinfo",
|
||||
)
|
||||
elif provider_type == AuthMethodType.GITHUB:
|
||||
return (
|
||||
"https://github.com/login/oauth/authorize",
|
||||
"https://github.com/login/oauth/access_token",
|
||||
"https://api.github.com/user",
|
||||
)
|
||||
elif provider_type == AuthMethodType.MICROSOFT:
|
||||
return (
|
||||
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||
"https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
"https://graph.microsoft.com/oidc/userinfo",
|
||||
)
|
||||
else:
|
||||
raise ExternalAuthError(f"Unsupported provider: {provider_type}", "UNSUPPORTED_PROVIDER", 400)
|
||||
|
||||
|
||||
def _store_oidc_bridge(oauth_state: str, oidc_session_id: str) -> None:
|
||||
try:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
rc.setex(f"oauth_oidc_bridge:{oauth_state}", _OAUTH_BRIDGE_TTL, oidc_session_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _pop_oidc_bridge(oauth_state: str) -> str | None:
|
||||
try:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
key = f"oauth_oidc_bridge:{oauth_state}"
|
||||
val = rc.get(key)
|
||||
if val:
|
||||
rc.delete(key)
|
||||
return val.decode() if isinstance(val, bytes) else val
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _store_cli_redirect(oauth_state: str, redirect_url: str) -> None:
|
||||
try:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
rc.setex(f"oauth_cli_redirect:{oauth_state}", _OAUTH_BRIDGE_TTL, redirect_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _pop_cli_redirect(oauth_state: str) -> str | None:
|
||||
try:
|
||||
import gatehouse_app.extensions as _ext
|
||||
rc = _ext.redis_client
|
||||
if rc is not None:
|
||||
key = f"oauth_cli_redirect:{oauth_state}"
|
||||
val = rc.get(key)
|
||||
if val:
|
||||
rc.delete(key)
|
||||
return val.decode() if isinstance(val, bytes) else val
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Admin application-level OAuth provider management."""
|
||||
from flask import g, request
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/oauth/providers", methods=["GET"])
|
||||
@login_required
|
||||
def admin_list_app_providers():
|
||||
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
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):
|
||||
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")
|
||||
|
||||
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):
|
||||
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
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,68 @@
|
||||
"""CLI token acquisition endpoints."""
|
||||
import secrets
|
||||
import logging
|
||||
from urllib.parse import quote
|
||||
from flask import request, current_app, redirect as flask_redirect
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.api.v1.external_auth._helpers import _OAUTH_BRIDGE_TTL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_v1_bp.route("/token_please", methods=["GET"])
|
||||
def token_please():
|
||||
redirect_url = request.args.get("redirect_url", "").strip()
|
||||
|
||||
if not redirect_url:
|
||||
return api_response(success=False, message="redirect_url query parameter is required", status=400, error_type="MISSING_REDIRECT_URL")
|
||||
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
parsed = _urlparse(redirect_url)
|
||||
if parsed.hostname not in ("localhost", "127.0.0.1"):
|
||||
return api_response(success=False, message="redirect_url must point to localhost", status=400, error_type="INVALID_REDIRECT_URL")
|
||||
|
||||
cli_token = secrets.token_urlsafe(32)
|
||||
try:
|
||||
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:
|
||||
login_url = f"{frontend_url}/login?cli_token={cli_token}"
|
||||
else:
|
||||
login_url = f"{frontend_url}/login?cli_redirect={quote(redirect_url, safe='')}"
|
||||
|
||||
logger.info("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():
|
||||
cli_token = request.args.get("token", "").strip()
|
||||
if not cli_token:
|
||||
return api_response(success=False, message="token query parameter is required", status=400, error_type="MISSING_TOKEN")
|
||||
|
||||
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")
|
||||
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")
|
||||
|
||||
return api_response(success=False, message="Redis not available", status=503, error_type="SERVICE_UNAVAILABLE")
|
||||
@@ -0,0 +1,244 @@
|
||||
"""OAuth authorization and callback endpoints."""
|
||||
import json
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
from flask import request, current_app, redirect as flask_redirect
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.services.external_auth.models import ExternalAuthError
|
||||
from gatehouse_app.services.oauth_flow import OAuthFlowService, OAuthFlowError
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
from gatehouse_app.api.v1.external_auth._helpers import (
|
||||
get_provider_type, _store_oidc_bridge, _pop_oidc_bridge, _pop_cli_redirect,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/<provider>/authorize", methods=["GET"])
|
||||
def initiate_oauth_authorize(provider: str):
|
||||
flow = request.args.get("flow", "login")
|
||||
redirect_uri = request.args.get("redirect_uri")
|
||||
organization_id = request.args.get("organization_id")
|
||||
oidc_session_id = request.args.get("oidc_session_id")
|
||||
|
||||
if flow not in ["login", "register"]:
|
||||
return api_response(success=False, message="Invalid flow type. Must be 'login' or 'register'", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
try:
|
||||
provider_type = get_provider_type(provider)
|
||||
if flow == "login":
|
||||
auth_url, state = OAuthFlowService.initiate_login_flow(
|
||||
provider_type=provider_type, organization_id=organization_id, redirect_uri=redirect_uri,
|
||||
)
|
||||
else:
|
||||
auth_url, state = OAuthFlowService.initiate_register_flow(
|
||||
provider_type=provider_type, organization_id=organization_id, redirect_uri=redirect_uri,
|
||||
)
|
||||
|
||||
if oidc_session_id:
|
||||
_store_oidc_bridge(state, oidc_session_id)
|
||||
|
||||
return api_response(data={"authorization_url": auth_url, "state": state}, message=f"OAuth {flow} flow initiated")
|
||||
|
||||
except OAuthFlowError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
except ExternalAuthError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/<provider>/callback", methods=["GET"])
|
||||
def handle_oauth_callback(provider: str):
|
||||
provider_type = get_provider_type(provider)
|
||||
|
||||
state = request.args.get("state")
|
||||
authorization_code = request.args.get("code")
|
||||
error = request.args.get("error")
|
||||
error_description = request.args.get("error_description")
|
||||
|
||||
frontend_url = current_app.config.get("FRONTEND_URL", "http://localhost:8080")
|
||||
frontend_callback = f"{frontend_url}/oauth/callback"
|
||||
|
||||
cli_redirect_url = _pop_cli_redirect(state) if state else None
|
||||
|
||||
def redirect_error(message: str, error_type: str = "OAUTH_ERROR"):
|
||||
if cli_redirect_url:
|
||||
from flask import make_response
|
||||
return make_response(
|
||||
f"<html><body><h2>Authentication Error</h2><p>{message}</p>"
|
||||
f"<p>You may close this window.</p></body></html>", 400,
|
||||
)
|
||||
params = {"error": message, "error_type": error_type}
|
||||
if state:
|
||||
params["state"] = state
|
||||
return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302)
|
||||
|
||||
if error:
|
||||
msg = error_description or f"Authorization failed: {error}"
|
||||
return redirect_error(msg, error.upper())
|
||||
|
||||
if not authorization_code or not state:
|
||||
return redirect_error("Missing authorization code or state parameter.")
|
||||
|
||||
try:
|
||||
result = OAuthFlowService.handle_callback(
|
||||
provider_type=provider_type,
|
||||
authorization_code=authorization_code,
|
||||
state=state,
|
||||
redirect_uri=None,
|
||||
error=None,
|
||||
error_description=None,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
return redirect_error("Authentication failed.", "AUTH_FAILED")
|
||||
|
||||
flow_type = result.get("flow_type", "login")
|
||||
|
||||
if flow_type == "link":
|
||||
params = {"flow": "link", "provider": provider, "linked": "1"}
|
||||
return flask_redirect(f"{frontend_url}/linked-accounts?{urlencode(params)}", code=302)
|
||||
|
||||
oidc_session_id = _pop_oidc_bridge(state)
|
||||
|
||||
if result.get("requires_org_selection") and not cli_redirect_url:
|
||||
orgs = json.dumps(result.get("available_organizations", []))
|
||||
params = {"requires_org_selection": "1", "state": result["state"], "provider": provider, "flow": flow_type, "orgs": orgs}
|
||||
if oidc_session_id:
|
||||
params["oidc_session_id"] = oidc_session_id
|
||||
return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302)
|
||||
|
||||
if result.get("requires_org_creation") and not cli_redirect_url:
|
||||
import json as _json
|
||||
session_data = result.get("session", {})
|
||||
token = session_data.get("token", "")
|
||||
expires_in = session_data.get("expires_in", 86400)
|
||||
pending_invites = result.get("pending_invites", [])
|
||||
params = {
|
||||
"requires_org_creation": "1", "state": result["state"], "provider": provider,
|
||||
"flow": flow_type, "token": token, "expires_in": str(expires_in),
|
||||
"pending_invites": _json.dumps(pending_invites),
|
||||
}
|
||||
if oidc_session_id:
|
||||
params["oidc_session_id"] = oidc_session_id
|
||||
return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302)
|
||||
|
||||
session_data = result.get("session", {})
|
||||
token = session_data.get("token")
|
||||
expires_in = session_data.get("expires_in", 86400)
|
||||
|
||||
if not token:
|
||||
return redirect_error("No session token returned by server.", "NO_TOKEN")
|
||||
|
||||
params = {"token": token, "expires_in": str(expires_in), "flow": flow_type, "provider": provider, "state": state}
|
||||
user_info = result.get("user", {})
|
||||
if user_info.get("email"):
|
||||
params["email"] = user_info["email"]
|
||||
|
||||
if cli_redirect_url:
|
||||
cli_final_url = cli_redirect_url + token
|
||||
logger.info(f"CLI token_please success: provider={provider}, user={user_info.get('email')}, redirecting to CLI callback")
|
||||
return flask_redirect(cli_final_url, code=302)
|
||||
|
||||
if oidc_session_id:
|
||||
params["oidc_session_id"] = oidc_session_id
|
||||
|
||||
logger.info(f"OAuth callback success: provider={provider}, flow={flow_type}, user={user_info.get('email')}, redirecting to frontend")
|
||||
return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302)
|
||||
|
||||
except OAuthFlowError as e:
|
||||
logger.warning(f"OAuth callback OAuthFlowError: {e.message}")
|
||||
return redirect_error(e.message, e.error_type)
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth callback unexpected error: {str(e)}", exc_info=True)
|
||||
return redirect_error("An unexpected error occurred. Please try again.", "INTERNAL_ERROR")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/select-organization", methods=["POST"])
|
||||
def select_organization():
|
||||
from gatehouse_app.utils.constants import AuthMethodType as _AuthMethodType
|
||||
from gatehouse_app.models import User, AuthenticationMethod, Organization, OrganizationMember
|
||||
|
||||
data = request.json or {}
|
||||
state_token = data.get("state")
|
||||
organization_id = data.get("organization_id")
|
||||
|
||||
if not state_token:
|
||||
return api_response(success=False, message="state is required", status=400, error_type="VALIDATION_ERROR")
|
||||
if not organization_id:
|
||||
return api_response(success=False, message="organization_id is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
try:
|
||||
state_record = OAuthFlowService.validate_state(state_token)
|
||||
if not state_record or state_record.used:
|
||||
return api_response(success=False, message="Invalid or expired state token", status=400, error_type="INVALID_STATE")
|
||||
|
||||
auth_method = AuthenticationMethod.query.filter_by(
|
||||
method_type=state_record.provider_type,
|
||||
).order_by(AuthenticationMethod.created_at.desc()).first()
|
||||
|
||||
if not auth_method:
|
||||
return api_response(success=False, message="Authentication session not found", status=400, error_type="SESSION_NOT_FOUND")
|
||||
|
||||
user = auth_method.user
|
||||
|
||||
org = Organization.query.get(organization_id)
|
||||
if not org:
|
||||
return api_response(success=False, message="Organization not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
member = OrganizationMember.query.filter_by(user_id=user.id, organization_id=organization_id).first()
|
||||
if not member:
|
||||
return api_response(success=False, message="You are not a member of this organization", status=403, error_type="FORBIDDEN")
|
||||
|
||||
from gatehouse_app.services.session_service import SessionService
|
||||
session = SessionService.create_session(user=user, organization_id=organization_id)
|
||||
state_record.mark_used()
|
||||
|
||||
provider_type_val = state_record.provider_type.value if isinstance(state_record.provider_type, _AuthMethodType) else state_record.provider_type
|
||||
AuditService.log_external_auth_login(
|
||||
user_id=user.id, organization_id=organization_id, provider_type=provider_type_val,
|
||||
provider_user_id=auth_method.provider_user_id,
|
||||
auth_method_id=auth_method.id, session_id=session.id,
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"token": session.token, "expires_in": session.lifetime_seconds, "token_type": "Bearer",
|
||||
"user": {"id": user.id, "email": user.email, "full_name": user.full_name, "organization_id": organization_id},
|
||||
},
|
||||
message="Organization selected and session created successfully",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in select_organization: {str(e)}", exc_info=True)
|
||||
return api_response(success=False, message="An error occurred while selecting organization", status=500, error_type="INTERNAL_ERROR")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/token", methods=["POST"])
|
||||
def exchange_authorization_code():
|
||||
if request.is_json:
|
||||
data = request.json or {}
|
||||
else:
|
||||
data = request.form or {}
|
||||
|
||||
grant_type = data.get("grant_type")
|
||||
code = data.get("code")
|
||||
redirect_uri = data.get("redirect_uri")
|
||||
client_id = data.get("client_id", "external-app")
|
||||
|
||||
if grant_type and grant_type != "authorization_code":
|
||||
return api_response(success=False, message="Invalid grant_type. Must be 'authorization_code'", status=400, error_type="INVALID_GRANT_TYPE")
|
||||
if not code:
|
||||
return api_response(success=False, message="code is required", status=400, error_type="VALIDATION_ERROR")
|
||||
if not redirect_uri:
|
||||
return api_response(success=False, message="redirect_uri is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
try:
|
||||
result = OAuthFlowService.exchange_authorization_code(
|
||||
code=code, client_id=client_id, redirect_uri=redirect_uri, ip_address=request.remote_addr,
|
||||
)
|
||||
return api_response(
|
||||
data={"token": result["token"], "expires_in": result["expires_in"], "token_type": result["token_type"], "user": result["user"]},
|
||||
message="Token exchanged successfully",
|
||||
)
|
||||
except OAuthFlowError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""External auth provider config endpoints (admin and user)."""
|
||||
from flask import g, request
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required
|
||||
from gatehouse_app.services.external_auth import ExternalAuthService
|
||||
from gatehouse_app.services.external_auth.models import ExternalAuthError, ExternalProviderConfig
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
from gatehouse_app.api.v1.external_auth._helpers import get_provider_type, _get_provider_endpoints
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/providers", methods=["GET"])
|
||||
@login_required
|
||||
def list_providers():
|
||||
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
|
||||
|
||||
app_configs = {c.provider_type.lower(): c for c in ApplicationProviderConfig.query.filter_by(is_enabled=True).all()}
|
||||
|
||||
user_orgs = g.current_user.get_organizations()
|
||||
org_configs = {}
|
||||
if user_orgs:
|
||||
organization_id = user_orgs[0].id
|
||||
org_level = ExternalProviderConfig.query.filter_by(organization_id=organization_id).all()
|
||||
org_configs = {c.provider_type.lower(): c for c in org_level}
|
||||
|
||||
def provider_info(provider_id, name):
|
||||
app_cfg = app_configs.get(provider_id)
|
||||
org_cfg = org_configs.get(provider_id)
|
||||
is_configured = app_cfg is not None or org_cfg is not None
|
||||
is_active = bool(app_cfg.is_enabled) if app_cfg else False
|
||||
if org_cfg and hasattr(org_cfg, "is_active"):
|
||||
is_active = bool(org_cfg.is_active)
|
||||
return {"id": provider_id, "name": name, "type": provider_id, "is_configured": is_configured, "is_active": is_active,
|
||||
"settings": {"requires_domain": False, "supports_refresh_tokens": True}}
|
||||
|
||||
providers = [provider_info("google", "Google"), provider_info("github", "GitHub"), provider_info("microsoft", "Microsoft")]
|
||||
return api_response(data={"providers": providers}, message="Providers retrieved successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/providers/<provider>/config", methods=["GET"])
|
||||
@login_required
|
||||
def get_provider_config(provider: str):
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
provider_type = get_provider_type(provider)
|
||||
|
||||
user_orgs = g.current_user.get_organizations()
|
||||
if not user_orgs:
|
||||
return api_response(success=False, message="No organizations found for user", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
organization_id = user_orgs[0].id
|
||||
member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id).first()
|
||||
if not member or member.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
|
||||
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
|
||||
|
||||
config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value).first()
|
||||
if not config:
|
||||
return api_response(success=False, message=f"{provider.title()} OAuth is not configured", status=404, error_type="NOT_FOUND")
|
||||
|
||||
return api_response(data=config.to_dict(include_secrets=False), message="Provider configuration retrieved successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/providers/<provider>/config", methods=["POST"])
|
||||
@login_required
|
||||
def create_or_update_provider_config(provider: str):
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
provider_type = get_provider_type(provider)
|
||||
|
||||
user_orgs = g.current_user.get_organizations()
|
||||
if not user_orgs:
|
||||
return api_response(success=False, message="No organizations found for user", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
organization_id = user_orgs[0].id
|
||||
member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id).first()
|
||||
if not member or member.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
|
||||
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
|
||||
|
||||
data = request.json or {}
|
||||
client_id = data.get("client_id")
|
||||
client_secret = data.get("client_secret")
|
||||
|
||||
if not client_id:
|
||||
return api_response(success=False, message="client_id is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value).first()
|
||||
is_new = config is None
|
||||
|
||||
if config:
|
||||
config.client_id = client_id
|
||||
if client_secret:
|
||||
config.set_client_secret(client_secret)
|
||||
config.scopes = data.get("scopes", ["openid", "profile", "email"])
|
||||
config.redirect_uris = data.get("redirect_uris", [])
|
||||
config.settings = data.get("settings", {})
|
||||
config.is_active = data.get("is_active", True)
|
||||
config.save()
|
||||
AuditService.log_external_auth_config_update(
|
||||
user_id=g.current_user.id, organization_id=organization_id, provider_type=provider_type.value,
|
||||
config_id=config.id,
|
||||
changes={"client_id": "updated", "client_secret": "updated" if client_secret else None,
|
||||
"scopes": data.get("scopes"), "redirect_uris": data.get("redirect_uris"), "is_active": config.is_active},
|
||||
)
|
||||
else:
|
||||
auth_url, token_url, userinfo_url = _get_provider_endpoints(provider_type)
|
||||
config = ExternalProviderConfig(
|
||||
organization_id=organization_id, provider_type=provider_type.value,
|
||||
client_id=client_id, client_secret_encrypted=None,
|
||||
auth_url=auth_url, token_url=token_url, userinfo_url=userinfo_url,
|
||||
scopes=data.get("scopes", ["openid", "profile", "email"]),
|
||||
redirect_uris=data.get("redirect_uris", []), settings=data.get("settings", {}),
|
||||
is_active=data.get("is_active", True),
|
||||
)
|
||||
if client_secret:
|
||||
config.set_client_secret(client_secret)
|
||||
config.save()
|
||||
AuditService.log_external_auth_config_create(
|
||||
user_id=g.current_user.id, organization_id=organization_id,
|
||||
provider_type=provider_type.value, config_id=config.id,
|
||||
)
|
||||
|
||||
return api_response(data=config.to_dict(include_secrets=False), message="Provider configuration saved successfully", status=201 if is_new else 200)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/providers/<provider>/config", methods=["DELETE"])
|
||||
@login_required
|
||||
def delete_provider_config(provider: str):
|
||||
from gatehouse_app.models import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
provider_type = get_provider_type(provider)
|
||||
|
||||
user_orgs = g.current_user.get_organizations()
|
||||
if not user_orgs:
|
||||
return api_response(success=False, message="No organizations found for user", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
organization_id = user_orgs[0].id
|
||||
member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id).first()
|
||||
if not member or member.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
|
||||
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
|
||||
|
||||
config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value).first()
|
||||
if not config:
|
||||
return api_response(success=False, message=f"{provider.title()} OAuth is not configured", status=404, error_type="NOT_FOUND")
|
||||
|
||||
config_id = config.id
|
||||
config.delete()
|
||||
AuditService.log_external_auth_config_delete(
|
||||
user_id=g.current_user.id, organization_id=organization_id,
|
||||
provider_type=provider_type.value, config_id=config_id,
|
||||
)
|
||||
return api_response(message=f"{provider.title()} provider configuration deleted successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/linked-accounts", methods=["GET"])
|
||||
@login_required
|
||||
def list_linked_accounts():
|
||||
from gatehouse_app.models import AuthenticationMethod
|
||||
|
||||
linked_accounts = ExternalAuthService.get_linked_accounts(g.current_user.id)
|
||||
other_methods = AuthenticationMethod.query.filter_by(user_id=g.current_user.id, deleted_at=None).count()
|
||||
return api_response(data={"linked_accounts": linked_accounts, "unlink_available": other_methods > 1}, message="Linked accounts retrieved successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/<provider>/link", methods=["POST"])
|
||||
@login_required
|
||||
def initiate_link_account(provider: str):
|
||||
provider_type = get_provider_type(provider)
|
||||
|
||||
user_orgs = g.current_user.get_organizations()
|
||||
organization_id = user_orgs[0].id if user_orgs else None
|
||||
data = request.json or {}
|
||||
redirect_uri = data.get("redirect_uri")
|
||||
|
||||
try:
|
||||
auth_url, state = ExternalAuthService.initiate_link_flow(
|
||||
user_id=g.current_user.id, provider_type=provider_type,
|
||||
organization_id=organization_id, redirect_uri=redirect_uri,
|
||||
)
|
||||
return api_response(data={"authorization_url": auth_url, "state": state}, message="Link flow initiated. Redirect to authorization URL.")
|
||||
except ExternalAuthError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/external/<provider>/unlink", methods=["DELETE"])
|
||||
@login_required
|
||||
def unlink_account(provider: str):
|
||||
provider_type = get_provider_type(provider)
|
||||
|
||||
user_orgs = g.current_user.get_organizations()
|
||||
organization_id = user_orgs[0].id if user_orgs else None
|
||||
|
||||
try:
|
||||
ExternalAuthService.unlink_provider(
|
||||
user_id=g.current_user.id, provider_type=provider_type, organization_id=organization_id,
|
||||
)
|
||||
return api_response(message=f"{provider.title()} account unlinked successfully")
|
||||
except ExternalAuthError as e:
|
||||
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
"""Organization routes package."""
|
||||
from gatehouse_app.api.v1.organizations import core, members, invites, clients, cas, audit, roles
|
||||
|
||||
__all__ = ["core", "members", "invites", "clients", "cas", "audit", "roles"]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Shared helpers for organization endpoints."""
|
||||
import os
|
||||
|
||||
|
||||
def _get_system_ca_dict():
|
||||
try:
|
||||
from gatehouse_app.config.ssh_ca_config import get_ssh_ca_config
|
||||
from gatehouse_app.utils.crypto import compute_ssh_fingerprint
|
||||
|
||||
priv_key = os.environ.get("SSH_CA_PRIVATE_KEY", "").strip()
|
||||
pub_key = ""
|
||||
|
||||
if not priv_key:
|
||||
cfg = get_ssh_ca_config()
|
||||
key_path = cfg.get_str("ca_key_path", "").strip()
|
||||
if not key_path:
|
||||
return None
|
||||
pub_path = key_path + ".pub"
|
||||
if not os.path.exists(pub_path):
|
||||
return None
|
||||
with open(pub_path) as f:
|
||||
pub_key = f.read().strip()
|
||||
else:
|
||||
from sshkey_tools.keys import PrivateKey
|
||||
pk = PrivateKey.from_string(priv_key)
|
||||
pub_key = pk.public_key.to_string()
|
||||
|
||||
fingerprint = compute_ssh_fingerprint(pub_key)
|
||||
return {
|
||||
"id": f"system-ca-{fingerprint[:16]}",
|
||||
"organization_id": None,
|
||||
"name": "System CA (config file)",
|
||||
"description": (
|
||||
"Read-only — this CA is loaded from the server's SSH_CA_PRIVATE_KEY "
|
||||
"environment variable or etc/ssh_ca.conf. Manage it on the server."
|
||||
),
|
||||
"ca_type": "user",
|
||||
"key_type": "unknown",
|
||||
"public_key": pub_key,
|
||||
"fingerprint": fingerprint,
|
||||
"is_active": True,
|
||||
"is_system": True,
|
||||
"default_cert_validity_hours": 0,
|
||||
"max_cert_validity_hours": 0,
|
||||
"total_certs": 0,
|
||||
"active_certs": 0,
|
||||
"revoked_certs": 0,
|
||||
"created_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Organization audit log endpoints."""
|
||||
from flask import g, request
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
|
||||
from gatehouse_app.services.organization_service import OrganizationService
|
||||
|
||||
|
||||
def _audit_log_to_dict(log):
|
||||
action = log.action
|
||||
return {
|
||||
"id": log.id,
|
||||
"action": action.value if hasattr(action, "value") else action,
|
||||
"user_id": log.user_id,
|
||||
"user": (
|
||||
{"id": log.user.id, "email": log.user.email, "full_name": log.user.full_name}
|
||||
if log.user else None
|
||||
),
|
||||
"organization_id": log.organization_id,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
"ip_address": log.ip_address,
|
||||
"user_agent": log.user_agent,
|
||||
"request_id": log.request_id,
|
||||
"description": log.description,
|
||||
"success": log.success,
|
||||
"error_message": log.error_message,
|
||||
"metadata": log.extra_data,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
"updated_at": log.updated_at.isoformat() if log.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@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):
|
||||
from gatehouse_app.models.auth.audit_log import AuditLog
|
||||
|
||||
OrganizationService.get_organization_by_id(org_id)
|
||||
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
action_filter = request.args.get("action")
|
||||
|
||||
query = AuditLog.query.filter_by(organization_id=org_id)
|
||||
if action_filter:
|
||||
query = query.filter_by(action=action_filter)
|
||||
|
||||
query = query.order_by(AuditLog.created_at.desc())
|
||||
total = query.count()
|
||||
logs = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
def log_to_dict(log):
|
||||
action = log.action
|
||||
return {
|
||||
"id": log.id,
|
||||
"action": action.value if hasattr(action, "value") else action,
|
||||
"user_id": log.user_id,
|
||||
"user_email": log.user.email if log.user else None,
|
||||
"user": {"id": log.user.id, "email": log.user.email, "full_name": log.user.full_name} if log.user else None,
|
||||
"organization_id": log.organization_id,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
"ip_address": log.ip_address,
|
||||
"user_agent": log.user_agent,
|
||||
"request_id": log.request_id,
|
||||
"description": log.description,
|
||||
"success": log.success,
|
||||
"error_message": log.error_message,
|
||||
"metadata": log.extra_data,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
"updated_at": log.updated_at.isoformat() if log.updated_at else None,
|
||||
}
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"audit_logs": [log_to_dict(log) for log in logs],
|
||||
"count": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
},
|
||||
message="Audit logs retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/audit-logs", methods=["GET"])
|
||||
@login_required
|
||||
def get_system_audit_logs():
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
if not is_admin:
|
||||
query = query.filter(AuditLog.user_id == current_user.id)
|
||||
|
||||
action_filter = request.args.get("action")
|
||||
if action_filter:
|
||||
query = query.filter(AuditLog.action == action_filter)
|
||||
|
||||
user_id_filter = request.args.get("user_id")
|
||||
if user_id_filter:
|
||||
query = query.filter(AuditLog.user_id == user_id_filter)
|
||||
|
||||
resource_type_filter = request.args.get("resource_type")
|
||||
if resource_type_filter:
|
||||
query = query.filter(AuditLog.resource_type == resource_type_filter)
|
||||
|
||||
success_filter = request.args.get("success")
|
||||
if success_filter is not None:
|
||||
query = query.filter(AuditLog.success == (success_filter.lower() == "true"))
|
||||
|
||||
q = request.args.get("q", "").strip()
|
||||
if q:
|
||||
query = query.filter(AuditLog.description.ilike(f"%{q}%"))
|
||||
|
||||
query = query.order_by(AuditLog.created_at.desc())
|
||||
total = query.count()
|
||||
logs = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"audit_logs": [_audit_log_to_dict(log) for log in logs],
|
||||
"count": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
"is_admin_view": is_admin,
|
||||
},
|
||||
message="Audit logs retrieved",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/audit-logs", methods=["GET"])
|
||||
@login_required
|
||||
def get_my_audit_logs():
|
||||
from gatehouse_app.models.auth.audit_log import AuditLog
|
||||
|
||||
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)
|
||||
|
||||
query = AuditLog.query.filter(AuditLog.user_id == current_user.id)
|
||||
|
||||
action_filter = request.args.get("action")
|
||||
if action_filter:
|
||||
query = query.filter(AuditLog.action == action_filter)
|
||||
|
||||
query = query.order_by(AuditLog.created_at.desc())
|
||||
total = query.count()
|
||||
logs = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"audit_logs": [_audit_log_to_dict(log) for log in logs],
|
||||
"count": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
},
|
||||
message="Activity retrieved",
|
||||
)
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Organization Certificate Authority endpoints."""
|
||||
from flask import g, request, current_app
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required, require_admin
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.api.v1.organizations._helpers import _get_system_ca_dict
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/cas", methods=["GET"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def list_org_cas(org_id):
|
||||
from gatehouse_app.models.ssh_ca.ca import CA, CaType
|
||||
from gatehouse_app.models.organization.organization import 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, error_type="NOT_FOUND")
|
||||
|
||||
cas = CA.query.filter_by(organization_id=org_id, deleted_at=None).all()
|
||||
ca_list = [ca.to_dict() for ca in cas]
|
||||
covered_types = {ca.ca_type for ca in cas}
|
||||
|
||||
system_ca_dict = _get_system_ca_dict()
|
||||
if system_ca_dict and CaType.USER not in covered_types:
|
||||
ca_list.append({**system_ca_dict, "ca_type": "user"})
|
||||
|
||||
return api_response(data={"cas": ca_list, "count": len(ca_list)}, message="CAs retrieved")
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/cas/<ca_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def update_org_ca(org_id, ca_id):
|
||||
from gatehouse_app.models.ssh_ca.ca import CA
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from marshmallow import Schema, fields, validate
|
||||
|
||||
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, error_type="NOT_FOUND")
|
||||
|
||||
ca = CA.query.filter_by(id=ca_id, organization_id=org_id, deleted_at=None).first()
|
||||
if not ca:
|
||||
return api_response(success=False, message="CA not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
try:
|
||||
class CAUpdateSchema(Schema):
|
||||
default_cert_validity_hours = fields.Int(validate=validate.Range(min=1), required=False)
|
||||
max_cert_validity_hours = fields.Int(validate=validate.Range(min=1), required=False)
|
||||
|
||||
schema = CAUpdateSchema()
|
||||
data = schema.load(request.json or {})
|
||||
|
||||
default_hours = data.get("default_cert_validity_hours", ca.default_cert_validity_hours)
|
||||
max_hours = data.get("max_cert_validity_hours", ca.max_cert_validity_hours)
|
||||
|
||||
if default_hours > max_hours:
|
||||
return api_response(success=False, message="Default validity must be less than or equal to maximum validity", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
if "default_cert_validity_hours" in data:
|
||||
ca.default_cert_validity_hours = data["default_cert_validity_hours"]
|
||||
if "max_cert_validity_hours" in data:
|
||||
ca.max_cert_validity_hours = data["max_cert_validity_hours"]
|
||||
|
||||
db.session.commit()
|
||||
return api_response(data={"ca": ca.to_dict()}, message="CA updated successfully")
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
return api_response(success=False, message="Failed to update CA", status=500, error_type="SERVER_ERROR")
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/cas", methods=["POST"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def create_org_ca(org_id):
|
||||
from gatehouse_app.models.ssh_ca.ca import CA, KeyType, CaType
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.utils.crypto import compute_ssh_fingerprint
|
||||
from gatehouse_app.utils.ca_key_encryption import encrypt_ca_key
|
||||
from marshmallow import Schema, fields as ma_fields, validate, ValidationError as MaValidationError
|
||||
from sshkey_tools.keys import Ed25519PrivateKey, RsaPrivateKey, EcdsaPrivateKey
|
||||
|
||||
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, error_type="NOT_FOUND")
|
||||
|
||||
class CreateCASchema(Schema):
|
||||
name = ma_fields.Str(required=True, validate=validate.Length(min=1, max=255))
|
||||
description = ma_fields.Str(load_default=None, allow_none=True)
|
||||
ca_type = ma_fields.Str(load_default="user", validate=validate.OneOf(["user", "host"]))
|
||||
key_type = ma_fields.Str(load_default="ed25519", validate=validate.OneOf(["ed25519", "rsa", "ecdsa"]))
|
||||
default_cert_validity_hours = ma_fields.Int(load_default=8, validate=validate.Range(min=1))
|
||||
max_cert_validity_hours = ma_fields.Int(load_default=720, validate=validate.Range(min=1))
|
||||
|
||||
try:
|
||||
schema = CreateCASchema()
|
||||
data = schema.load(request.get_json() or {})
|
||||
|
||||
existing = CA.query.filter_by(organization_id=org_id, name=data["name"], deleted_at=None).first()
|
||||
if existing:
|
||||
return api_response(success=False, message="A CA with that name already exists in this organization", status=400, error_type="DUPLICATE_NAME")
|
||||
|
||||
ca_type_val = data["ca_type"]
|
||||
existing_type = CA.query.filter_by(organization_id=org_id, deleted_at=None).filter(CA.ca_type == CaType(ca_type_val)).first()
|
||||
if existing_type:
|
||||
type_label = "User" if ca_type_val == "user" else "Host"
|
||||
return api_response(success=False, message=f"A {type_label} CA already exists for this organization. You can only have one {type_label} CA per organization.", status=400, error_type="DUPLICATE_CA_TYPE")
|
||||
|
||||
if data["default_cert_validity_hours"] > data["max_cert_validity_hours"]:
|
||||
return api_response(success=False, message="Default validity must be less than or equal to maximum validity", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
key_type = data["key_type"]
|
||||
if key_type == "ed25519":
|
||||
private_key_obj = Ed25519PrivateKey.generate()
|
||||
elif key_type == "rsa":
|
||||
private_key_obj = RsaPrivateKey.generate(4096)
|
||||
else:
|
||||
private_key_obj = EcdsaPrivateKey.generate()
|
||||
|
||||
private_key_pem = private_key_obj.to_string()
|
||||
public_key_str = private_key_obj.public_key.to_string()
|
||||
fingerprint = compute_ssh_fingerprint(public_key_str)
|
||||
encrypted_private_key = encrypt_ca_key(private_key_pem)
|
||||
|
||||
ca = CA(
|
||||
organization_id=org_id,
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
ca_type=CaType(ca_type_val),
|
||||
key_type=KeyType(key_type),
|
||||
private_key=encrypted_private_key,
|
||||
public_key=public_key_str,
|
||||
fingerprint=fingerprint,
|
||||
default_cert_validity_hours=data["default_cert_validity_hours"],
|
||||
max_cert_validity_hours=data["max_cert_validity_hours"],
|
||||
is_active=True,
|
||||
)
|
||||
db.session.add(ca)
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as commit_exc:
|
||||
db.session.rollback()
|
||||
exc_str = str(commit_exc).lower()
|
||||
if "uix_org_ca_name" in exc_str or "unique" in exc_str:
|
||||
return api_response(success=False, message="A CA with that name already exists in this organization (it may have been recently deleted — choose a different name).", status=400, error_type="DUPLICATE_NAME")
|
||||
raise
|
||||
|
||||
return api_response(data={"ca": ca.to_dict()}, message="CA created successfully", status=201)
|
||||
except MaValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
current_app.logger.exception("Failed to create CA")
|
||||
return api_response(success=False, message="Failed to create CA", status=500, error_type="SERVER_ERROR")
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/cas/<ca_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def delete_org_ca(org_id, ca_id):
|
||||
from gatehouse_app.models.ssh_ca.ca import CA
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.utils.constants import AuditAction
|
||||
from gatehouse_app.models import AuditLog
|
||||
|
||||
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, error_type="NOT_FOUND")
|
||||
|
||||
ca = CA.query.filter_by(id=ca_id, organization_id=org_id, deleted_at=None).first()
|
||||
if not ca:
|
||||
return api_response(success=False, message="CA not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
try:
|
||||
ca_name = ca.name
|
||||
ca_type = ca.ca_type.value if hasattr(ca.ca_type, "value") else str(ca.ca_type)
|
||||
ca.is_active = False
|
||||
ca.delete(soft=True)
|
||||
|
||||
AuditLog.log(
|
||||
action=AuditAction.CA_DELETED,
|
||||
user_id=g.current_user.id,
|
||||
resource_type="CA",
|
||||
resource_id=ca_id,
|
||||
organization_id=org_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"CA '{ca_name}' ({ca_type}) deleted",
|
||||
)
|
||||
return api_response(data={"ca_id": ca_id}, message="CA deleted successfully")
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
current_app.logger.exception("Failed to delete CA")
|
||||
return api_response(success=False, message="Failed to delete CA", status=500, error_type="SERVER_ERROR")
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/cas/<ca_id>/rotate", methods=["POST"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def rotate_org_ca(org_id, ca_id):
|
||||
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 gatehouse_app.utils.ca_key_encryption import encrypt_ca_key
|
||||
from gatehouse_app.utils.constants import AuditAction
|
||||
from gatehouse_app.models import AuditLog
|
||||
from sshkey_tools.keys import Ed25519PrivateKey, RsaPrivateKey, EcdsaPrivateKey
|
||||
|
||||
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, error_type="NOT_FOUND")
|
||||
|
||||
ca = CA.query.filter_by(id=ca_id, organization_id=org_id, deleted_at=None).first()
|
||||
if not ca:
|
||||
return api_response(success=False, message="CA not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
data = request.get_json() or {}
|
||||
new_key_type = data.get("key_type") or (ca.key_type.value if hasattr(ca.key_type, "value") else str(ca.key_type))
|
||||
reason = data.get("reason", "Admin-initiated key rotation")
|
||||
|
||||
if new_key_type not in ("ed25519", "rsa", "ecdsa"):
|
||||
return api_response(success=False, message="Invalid key_type. Must be one of: ed25519, rsa, ecdsa", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
try:
|
||||
old_fingerprint = ca.fingerprint
|
||||
|
||||
if new_key_type == "ed25519":
|
||||
private_key_obj = Ed25519PrivateKey.generate()
|
||||
elif new_key_type == "rsa":
|
||||
private_key_obj = RsaPrivateKey.generate(4096)
|
||||
else:
|
||||
private_key_obj = EcdsaPrivateKey.generate()
|
||||
|
||||
new_private_key = private_key_obj.to_string()
|
||||
new_public_key = private_key_obj.public_key.to_string()
|
||||
new_fingerprint = compute_ssh_fingerprint(new_public_key)
|
||||
encrypted_new_private_key = encrypt_ca_key(new_private_key)
|
||||
|
||||
ca.rotate_key(new_private_key=encrypted_new_private_key, new_public_key=new_public_key, new_fingerprint=new_fingerprint, reason=reason)
|
||||
ca.key_type = KeyType(new_key_type)
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log(
|
||||
action=AuditAction.CA_KEY_ROTATED,
|
||||
user_id=g.current_user.id,
|
||||
resource_type="CA",
|
||||
resource_id=ca_id,
|
||||
organization_id=org_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=(f"CA '{ca.name}' key rotated. Old fingerprint: {old_fingerprint}, New fingerprint: {new_fingerprint}. Reason: {reason}"),
|
||||
)
|
||||
|
||||
return api_response(data={"ca": ca.to_dict(), "old_fingerprint": old_fingerprint}, message="CA key rotated successfully. Update TrustedUserCAKeys / known_hosts on your servers.")
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
current_app.logger.exception("Failed to rotate CA key")
|
||||
return api_response(success=False, message="Failed to rotate CA key", status=500, error_type="SERVER_ERROR")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Organization OIDC client endpoints."""
|
||||
import secrets as _secrets
|
||||
from flask import g, request
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
|
||||
from gatehouse_app.extensions import db, bcrypt
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/clients", methods=["GET"])
|
||||
@login_required
|
||||
@require_admin
|
||||
@full_access_required
|
||||
def list_org_clients(org_id):
|
||||
from gatehouse_app.models import OIDCClient, 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)
|
||||
|
||||
clients = OIDCClient.query.filter_by(organization_id=org_id, is_active=True).all()
|
||||
|
||||
def client_to_dict(c):
|
||||
return {
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"client_id": c.client_id,
|
||||
"redirect_uris": c.redirect_uris,
|
||||
"scopes": c.scopes,
|
||||
"grant_types": c.grant_types,
|
||||
"is_active": c.is_active,
|
||||
"created_at": c.created_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
return api_response(data={"clients": [client_to_dict(c) for c in clients], "count": len(clients)}, message="Clients retrieved successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/clients", methods=["POST"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def create_org_client(org_id):
|
||||
from gatehouse_app.models import OIDCClient, 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)
|
||||
|
||||
data = request.get_json() or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
redirect_uris_raw = data.get("redirect_uris") or []
|
||||
|
||||
if not name:
|
||||
return api_response(success=False, message="Client name is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
if isinstance(redirect_uris_raw, str):
|
||||
redirect_uris = [u.strip() for u in redirect_uris_raw.replace(",", "\n").splitlines() if u.strip()]
|
||||
else:
|
||||
redirect_uris = [u.strip() for u in redirect_uris_raw if isinstance(u, str) and u.strip()]
|
||||
|
||||
if not redirect_uris:
|
||||
return api_response(success=False, message="At least one redirect URI is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
client_id = _secrets.token_hex(16)
|
||||
client_secret = _secrets.token_urlsafe(32)
|
||||
|
||||
client = OIDCClient(
|
||||
organization_id=org_id,
|
||||
name=name,
|
||||
client_id=client_id,
|
||||
client_secret_hash=bcrypt.generate_password_hash(client_secret).decode("utf-8"),
|
||||
redirect_uris=redirect_uris,
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
response_types=["code"],
|
||||
scopes=["openid", "profile", "email"],
|
||||
is_active=True,
|
||||
is_confidential=True,
|
||||
)
|
||||
db.session.add(client)
|
||||
db.session.commit()
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"client": {
|
||||
"id": client.id,
|
||||
"name": client.name,
|
||||
"client_id": client.client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uris": client.redirect_uris,
|
||||
"scopes": client.scopes,
|
||||
"created_at": client.created_at.isoformat() + "Z",
|
||||
}
|
||||
},
|
||||
message="OIDC client created successfully",
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/clients/<client_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def delete_org_client(org_id, client_id):
|
||||
from gatehouse_app.models import OIDCClient
|
||||
|
||||
client = OIDCClient.query.filter_by(id=client_id, organization_id=org_id).first()
|
||||
if not client:
|
||||
return api_response(success=False, message="Client not found", status=404)
|
||||
|
||||
client.is_active = False
|
||||
db.session.commit()
|
||||
return api_response(data={}, message="Client deactivated successfully")
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Organization core CRUD endpoints."""
|
||||
from flask import g, request
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
|
||||
from gatehouse_app.schemas.organization_schema import OrganizationCreateSchema, OrganizationUpdateSchema
|
||||
from gatehouse_app.services.organization_service import OrganizationService
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations", methods=["POST"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def create_organization():
|
||||
try:
|
||||
schema = OrganizationCreateSchema()
|
||||
data = schema.load(request.json)
|
||||
org = OrganizationService.create_organization(
|
||||
name=data["name"],
|
||||
slug=data["slug"],
|
||||
owner_user_id=g.current_user.id,
|
||||
description=data.get("description"),
|
||||
logo_url=data.get("logo_url"),
|
||||
)
|
||||
return api_response(data={"organization": org.to_dict()}, message="Organization created successfully", status=201)
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>", methods=["GET"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def get_organization(org_id):
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
if not org.is_member(g.current_user.id):
|
||||
return api_response(success=False, message="You are not a member of this organization", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
return api_response(
|
||||
data={"organization": org.to_dict(), "member_count": org.get_member_count()},
|
||||
message="Organization retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
@require_admin
|
||||
@full_access_required
|
||||
def update_organization(org_id):
|
||||
try:
|
||||
schema = OrganizationUpdateSchema()
|
||||
data = schema.load(request.json)
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
org = OrganizationService.update_organization(org=org, user_id=g.current_user.id, **data)
|
||||
return api_response(data={"organization": org.to_dict()}, message="Organization updated successfully")
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def delete_organization(org_id):
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember as _OrgMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole as _OrgRole
|
||||
|
||||
caller = g.current_user
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
|
||||
caller_membership = _OrgMember.query.filter_by(user_id=caller.id, organization_id=org.id, deleted_at=None).first()
|
||||
if not caller_membership or caller_membership.role != _OrgRole.OWNER:
|
||||
return api_response(success=False, message="Only the organization owner can delete the organization.", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
active_member_count = org.get_member_count()
|
||||
if active_member_count > 1:
|
||||
data = request.get_json(silent=True) or {}
|
||||
if not data.get("confirm"):
|
||||
return api_response(
|
||||
success=False,
|
||||
message=(f"This organization has {active_member_count} active members. Deleting it will remove all members and their data. Send {{\"confirm\": true}} to confirm."),
|
||||
status=400,
|
||||
error_type="CONFIRMATION_REQUIRED",
|
||||
error_details={"member_count": active_member_count},
|
||||
)
|
||||
|
||||
OrganizationService.force_delete_organization(org=org, user_id=caller.id)
|
||||
return api_response(message="Organization deleted successfully")
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Organization invite token endpoints."""
|
||||
import logging
|
||||
from flask import g, request, current_app
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required, require_admin
|
||||
from gatehouse_app.services.notification_service import NotificationService
|
||||
from gatehouse_app.services.auth_service import AuthService
|
||||
from gatehouse_app.services.organization_service import OrganizationService
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/invites", methods=["POST"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def create_org_invite(org_id):
|
||||
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)
|
||||
|
||||
data = request.get_json() or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
role = (data.get("role") or "member").strip()
|
||||
|
||||
if not email:
|
||||
return api_response(success=False, message="Email is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
invite = OrgInviteToken.generate(
|
||||
organization_id=org_id,
|
||||
email=email,
|
||||
role=role,
|
||||
invited_by_id=g.current_user.id,
|
||||
)
|
||||
|
||||
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
||||
invite_link = f"{app_url}/invite?token={invite.token}"
|
||||
|
||||
email_sent = NotificationService._send_email(
|
||||
to_address=email,
|
||||
subject=f"You're invited to join {org.name} on Gatehouse",
|
||||
body=(
|
||||
f"You've been invited to join {org.name} on Gatehouse.\n\n"
|
||||
f"Click the link below to accept the invitation (valid for 7 days):\n"
|
||||
f"{invite_link}\n\n"
|
||||
f"Gatehouse Security Team"
|
||||
),
|
||||
)
|
||||
|
||||
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=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):
|
||||
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,
|
||||
"expires_at": inv.expires_at.isoformat() + "Z",
|
||||
"token": inv.token,
|
||||
}
|
||||
|
||||
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):
|
||||
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)
|
||||
|
||||
invite.delete(soft=True)
|
||||
return api_response(data={}, message="Invite cancelled")
|
||||
|
||||
|
||||
@api_v1_bp.route("/invites/<token>", methods=["GET"])
|
||||
def get_invite(token):
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/invites/<token>/accept", methods=["POST"])
|
||||
def accept_invite(token):
|
||||
"""Accept an organization invite.
|
||||
|
||||
"""
|
||||
from gatehouse_app.models import OrgInviteToken, User
|
||||
from gatehouse_app.services.session_service import SessionService
|
||||
|
||||
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")
|
||||
|
||||
# --- Resolve the user -----------------------------------------------
|
||||
# If the request carries a valid session token the user is already
|
||||
# authenticated (e.g. via Google OAuth). Use that identity and skip
|
||||
# any password / registration logic entirely.
|
||||
user = None
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
bearer_token = auth_header.split(None, 1)[1].strip()
|
||||
session = SessionService.get_active_session_by_token(bearer_token)
|
||||
if session and session.is_active():
|
||||
session_user = session.user
|
||||
# Verify the authenticated user's email matches the invite
|
||||
if session_user.email.lower() != invite.email.lower():
|
||||
return api_response(
|
||||
success=False,
|
||||
message="This invite was sent to a different email address.",
|
||||
status=403,
|
||||
error_type="EMAIL_MISMATCH",
|
||||
)
|
||||
user = session_user
|
||||
|
||||
data = request.get_json() or {}
|
||||
full_name = data.get("full_name") or ""
|
||||
password = data.get("password") or ""
|
||||
password_confirm = data.get("password_confirm") or ""
|
||||
|
||||
if user is None:
|
||||
# Fall back to email lookup (existing account created by any method)
|
||||
user = User.query.filter(
|
||||
User.email.ilike(invite.email),
|
||||
User.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
if not user:
|
||||
# Brand-new account — password registration required
|
||||
if not password:
|
||||
return api_response(success=False, message="Password is required for new accounts.", status=400, error_type="VALIDATION_ERROR")
|
||||
if password != password_confirm:
|
||||
return api_response(success=False, message="Passwords do not match.", status=400, error_type="VALIDATION_ERROR")
|
||||
if len(password) < 8:
|
||||
return api_response(success=False, message="Password must be at least 8 characters.", status=400, error_type="VALIDATION_ERROR")
|
||||
try:
|
||||
user = AuthService.register_user(email=invite.email, password=password, full_name=full_name or None)
|
||||
except Exception as exc:
|
||||
return api_response(success=False, message=str(exc), status=400, error_type="REGISTRATION_ERROR")
|
||||
|
||||
# Add to org
|
||||
try:
|
||||
org_role = OrganizationRole(invite.role)
|
||||
except ValueError:
|
||||
org_role = OrganizationRole.MEMBER
|
||||
|
||||
try:
|
||||
OrganizationService.add_member(
|
||||
org=invite.organization,
|
||||
user_id=user.id,
|
||||
role=org_role,
|
||||
inviter_id=invite.invited_by_id,
|
||||
)
|
||||
except Exception:
|
||||
from gatehouse_app.extensions import db
|
||||
db.session.rollback()
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Failed to add you to the organization. You may already be a member.",
|
||||
status=409,
|
||||
error_type="CONFLICT",
|
||||
)
|
||||
|
||||
invite.accept()
|
||||
|
||||
has_webauthn = user.has_webauthn_enabled()
|
||||
has_totp = user.has_totp_enabled()
|
||||
|
||||
if has_webauthn:
|
||||
from flask import session as flask_session
|
||||
flask_session["webauthn_pending_user_id"] = user.id
|
||||
return api_response(data={"requires_webauthn": True}, message="Passkey verification required. Please use your passkey to complete sign-in.")
|
||||
|
||||
if has_totp:
|
||||
from flask import session as flask_session
|
||||
flask_session["totp_pending_user_id"] = user.id
|
||||
return api_response(data={"requires_totp": True}, message="TOTP code required. Please enter your 6-digit code from your authenticator app.")
|
||||
|
||||
user_session = AuthService.create_session(user)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"user": user.to_dict(),
|
||||
"token": user_session.token,
|
||||
"expires_at": user_session.expires_at.isoformat() + "Z",
|
||||
},
|
||||
message="Invitation accepted. Welcome!",
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Organization member management endpoints."""
|
||||
from flask import g, request
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
|
||||
from gatehouse_app.schemas.organization_schema import InviteMemberSchema, UpdateMemberRoleSchema
|
||||
from gatehouse_app.services.organization_service import OrganizationService
|
||||
from gatehouse_app.services.user_service import UserService
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/members", methods=["GET"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def get_organization_members(org_id):
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
if not org.is_member(g.current_user.id):
|
||||
return api_response(success=False, message="You are not a member of this organization", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
members_data = []
|
||||
for member in org.members:
|
||||
if member.deleted_at is None:
|
||||
member_dict = member.to_dict()
|
||||
member_dict["user"] = member.user.to_dict()
|
||||
members_data.append(member_dict)
|
||||
|
||||
return api_response(data={"members": members_data, "count": len(members_data)}, message="Members retrieved successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/members", methods=["POST"])
|
||||
@login_required
|
||||
@require_admin
|
||||
@full_access_required
|
||||
def add_organization_member(org_id):
|
||||
try:
|
||||
schema = InviteMemberSchema()
|
||||
data = schema.load(request.json)
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
user = UserService.get_user_by_email(data["email"])
|
||||
if not user:
|
||||
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
role = OrganizationRole(data["role"])
|
||||
member = OrganizationService.add_member(org=org, user_id=user.id, role=role, inviter_id=g.current_user.id)
|
||||
member_dict = member.to_dict()
|
||||
member_dict["user"] = user.to_dict()
|
||||
return api_response(data={"member": member_dict}, message="Member added successfully", status=201)
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/members/<user_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@require_admin
|
||||
@full_access_required
|
||||
def remove_organization_member(org_id, user_id):
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
OrganizationService.remove_member(org=org, user_id=user_id, remover_id=g.current_user.id)
|
||||
return api_response(message="Member removed successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/members/<user_id>/role", methods=["PATCH"])
|
||||
@login_required
|
||||
@require_admin
|
||||
@full_access_required
|
||||
def update_member_role(org_id, user_id):
|
||||
try:
|
||||
schema = UpdateMemberRoleSchema()
|
||||
data = schema.load(request.json)
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
new_role = OrganizationRole(data["role"])
|
||||
member = OrganizationService.update_member_role(org=org, user_id=user_id, new_role=new_role, updater_id=g.current_user.id)
|
||||
member_dict = member.to_dict()
|
||||
member_dict["user"] = member.user.to_dict()
|
||||
return api_response(data={"member": member_dict}, message="Member role updated successfully")
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/transfer-ownership", methods=["POST"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def transfer_organization_ownership(org_id):
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.utils.constants import AuditAction
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
|
||||
caller = g.current_user
|
||||
data = request.get_json() or {}
|
||||
new_owner_user_id = data.get("new_owner_user_id")
|
||||
|
||||
if not new_owner_user_id:
|
||||
return api_response(success=False, message="new_owner_user_id is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
if str(new_owner_user_id) == str(caller.id):
|
||||
return api_response(success=False, message="You are already the owner of this organization.", status=409, error_type="CONFLICT")
|
||||
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
|
||||
caller_membership = OrganizationMember.query.filter_by(organization_id=org.id, user_id=caller.id, deleted_at=None).first()
|
||||
if not caller_membership or caller_membership.role != OrganizationRole.OWNER:
|
||||
return api_response(success=False, message="Only the organization owner can transfer ownership.", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
target_membership = OrganizationMember.query.filter_by(organization_id=org.id, user_id=new_owner_user_id, deleted_at=None).first()
|
||||
if not target_membership:
|
||||
return api_response(success=False, message="Target user is not a member of this organization.", status=404, error_type="NOT_FOUND")
|
||||
|
||||
if target_membership.role == OrganizationRole.OWNER:
|
||||
return api_response(success=False, message="Target user is already the owner.", status=409, error_type="CONFLICT")
|
||||
|
||||
try:
|
||||
demoted = OrganizationService.update_member_role(org=org, user_id=str(caller.id), new_role=OrganizationRole.ADMIN, updater_id=str(caller.id))
|
||||
promoted = OrganizationService.update_member_role(org=org, user_id=str(new_owner_user_id), new_role=OrganizationRole.OWNER, updater_id=str(caller.id))
|
||||
except Exception as exc:
|
||||
from gatehouse_app.extensions import db as _db
|
||||
_db.session.rollback()
|
||||
return api_response(success=False, message=f"Failed to transfer ownership: {exc}", status=500, error_type="SERVER_ERROR")
|
||||
|
||||
AuditService.log_action(
|
||||
action=AuditAction.ORG_OWNERSHIP_TRANSFERRED,
|
||||
user_id=caller.id,
|
||||
organization_id=org.id,
|
||||
resource_type="organization",
|
||||
resource_id=str(org.id),
|
||||
description=(f"Ownership of '{org.name}' transferred from {caller.email} to {target_membership.user.email if target_membership.user else new_owner_user_id}"),
|
||||
metadata={
|
||||
"previous_owner_id": str(caller.id),
|
||||
"previous_owner_email": caller.email,
|
||||
"new_owner_id": str(new_owner_user_id),
|
||||
"new_owner_email": target_membership.user.email if target_membership.user else None,
|
||||
},
|
||||
)
|
||||
|
||||
def _member_dict(m):
|
||||
d = m.to_dict()
|
||||
if m.user:
|
||||
d["user"] = m.user.to_dict()
|
||||
return d
|
||||
|
||||
return api_response(
|
||||
data={"previous_owner": _member_dict(demoted), "new_owner": _member_dict(promoted)},
|
||||
message=(f"Ownership of '{org.name}' successfully transferred to {target_membership.user.email if target_membership.user else new_owner_user_id}."),
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/members/<user_id>/send-mfa-reminder", methods=["POST"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def send_mfa_reminder(org_id, user_id):
|
||||
from gatehouse_app.models import User, MfaPolicyCompliance, OrganizationSecurityPolicy
|
||||
from gatehouse_app.services.notification_service import NotificationService
|
||||
|
||||
user = User.query.filter_by(id=user_id, deleted_at=None).first()
|
||||
if not user:
|
||||
return api_response(success=False, message="User not found", status=404)
|
||||
|
||||
compliance = MfaPolicyCompliance.query.filter_by(user_id=user_id, organization_id=org_id).first()
|
||||
policy = OrganizationSecurityPolicy.query.filter_by(organization_id=org_id).first()
|
||||
|
||||
if compliance and policy and compliance.deadline_at:
|
||||
NotificationService.send_mfa_deadline_reminder(user, compliance, policy)
|
||||
else:
|
||||
NotificationService._send_email(
|
||||
to_address=user.email,
|
||||
subject="Reminder: Set up multi-factor authentication",
|
||||
body=(
|
||||
f"Hi {user.full_name or user.email},\n\n"
|
||||
"Your organization administrator has asked you to set up "
|
||||
"multi-factor authentication (MFA) on your Gatehouse account.\n\n"
|
||||
"Please log in and configure MFA as soon as possible.\n\n"
|
||||
"Gatehouse Security Team"
|
||||
),
|
||||
)
|
||||
|
||||
return api_response(data={}, message="Reminder sent successfully")
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Organization role management endpoints."""
|
||||
from flask import g, request
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/roles", methods=["GET"])
|
||||
@login_required
|
||||
def list_organization_roles(org_id):
|
||||
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:
|
||||
return api_response(success=False, message="Organization not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
members = OrganizationMember.query.filter_by(organization_id=org_id, deleted_at=None).all()
|
||||
by_role: dict = {r.value: [] for r in OrganizationRole}
|
||||
for m in members:
|
||||
role_key = m.role.value if hasattr(m.role, "value") else str(m.role)
|
||||
if role_key in by_role:
|
||||
by_role[role_key].append({
|
||||
"user_id": m.user_id,
|
||||
"email": m.user.email if m.user else None,
|
||||
"full_name": m.user.full_name if m.user else None,
|
||||
"joined_at": m.created_at.isoformat() if m.created_at else None,
|
||||
})
|
||||
|
||||
roles = [
|
||||
{"role": r.value, "member_count": len(by_role[r.value]), "members": by_role[r.value]}
|
||||
for r in OrganizationRole
|
||||
]
|
||||
return api_response(data={"roles": roles, "organization_id": org_id}, message="Roles retrieved")
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/roles/<role_name>/members", methods=["POST"])
|
||||
@login_required
|
||||
@require_admin
|
||||
def assign_role_to_member(org_id, role_name):
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
|
||||
try:
|
||||
new_role = OrganizationRole(role_name.lower())
|
||||
except ValueError:
|
||||
valid = [r.value for r in OrganizationRole]
|
||||
return api_response(success=False, message=f"Invalid role. Must be one of: {valid}", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
data = request.get_json() or {}
|
||||
target_user_id = data.get("user_id")
|
||||
if not target_user_id:
|
||||
return api_response(success=False, message="user_id is required", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
membership = OrganizationMember.query.filter_by(organization_id=org_id, user_id=target_user_id, deleted_at=None).first()
|
||||
if not membership:
|
||||
return api_response(success=False, message="Member not found in this organization", status=404, error_type="NOT_FOUND")
|
||||
|
||||
membership.role = new_role
|
||||
db.session.commit()
|
||||
return api_response(data={"user_id": target_user_id, "role": new_role.value}, message=f"Role updated to {new_role.value}")
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/roles/<role_name>/members/<user_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@require_admin
|
||||
@full_access_required
|
||||
def remove_role_from_member(org_id, role_name, user_id):
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.services.organization_service import OrganizationService
|
||||
|
||||
try:
|
||||
OrganizationRole(role_name.lower())
|
||||
except ValueError:
|
||||
valid = [r.value for r in OrganizationRole]
|
||||
return api_response(success=False, message=f"Invalid role. Must be one of: {valid}", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
membership = OrganizationMember.query.filter_by(organization_id=org_id, user_id=user_id, deleted_at=None).first()
|
||||
if not membership:
|
||||
return api_response(success=False, message="Member not found in this organization", status=404, error_type="NOT_FOUND")
|
||||
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
OrganizationService.remove_member(org=org, user_id=user_id, remover_id=g.current_user.id)
|
||||
return api_response(data={"user_id": user_id}, message="Member removed from organization")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
"""SSH blueprint subpackage. Exports ssh_bp for registration."""
|
||||
from gatehouse_app.api.v1.ssh._helpers import ssh_bp
|
||||
from gatehouse_app.api.v1.ssh import keys, certs, admin
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Shared helpers for the SSH subpackage."""
|
||||
import logging
|
||||
from flask import Blueprint, request, g
|
||||
from gatehouse_app.services.ssh_key_service import SSHKeyService
|
||||
from gatehouse_app.services.ssh_ca_signing_service import SSHCASigningService
|
||||
|
||||
ssh_bp = Blueprint('ssh', __name__, url_prefix='/ssh')
|
||||
ssh_key_service = SSHKeyService()
|
||||
ssh_ca_service = SSHCASigningService()
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_org_ca_for_user(user, ca_type: str = "user"):
|
||||
try:
|
||||
from gatehouse_app.models.ssh_ca.ca import CA, CaType
|
||||
org_ids = [m.organization_id for m in user.organization_memberships]
|
||||
if not org_ids:
|
||||
return None
|
||||
return CA.query.filter(
|
||||
CA.organization_id.in_(org_ids),
|
||||
CA.ca_type == CaType(ca_type),
|
||||
CA.is_active == True, # noqa: E712
|
||||
).first()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_or_create_system_ca():
|
||||
from gatehouse_app.extensions import db
|
||||
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
|
||||
|
||||
try:
|
||||
existing = CA.query.filter_by(name="system-config-ca").first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
cfg = get_ssh_ca_config()
|
||||
key_path = cfg.get_str("ca_key_path", "").strip()
|
||||
pub_key_path = key_path + ".pub"
|
||||
|
||||
if not os.path.exists(pub_key_path):
|
||||
return None
|
||||
|
||||
with open(pub_key_path) as f:
|
||||
pub_key = f.read().strip()
|
||||
|
||||
priv_key = ""
|
||||
if os.path.exists(key_path):
|
||||
with open(key_path) as f:
|
||||
raw_priv_key = f.read()
|
||||
try:
|
||||
from gatehouse_app.utils.ca_key_encryption import encrypt_ca_key
|
||||
priv_key = encrypt_ca_key(raw_priv_key)
|
||||
except Exception:
|
||||
priv_key = raw_priv_key
|
||||
|
||||
fingerprint = compute_ssh_fingerprint(pub_key)
|
||||
|
||||
existing_by_fp = CA.query.filter_by(fingerprint=fingerprint).first()
|
||||
if existing_by_fp:
|
||||
return existing_by_fp
|
||||
|
||||
system_ca = CA(
|
||||
name="system-config-ca",
|
||||
description="Global CA loaded from etc/ssh_ca.conf (ca_key_path)",
|
||||
key_type=KeyType.ED25519,
|
||||
private_key=priv_key,
|
||||
public_key=pub_key,
|
||||
fingerprint=fingerprint,
|
||||
is_active=True,
|
||||
default_cert_validity_hours=24,
|
||||
max_cert_validity_hours=720,
|
||||
)
|
||||
db.session.add(system_ca)
|
||||
db.session.commit()
|
||||
return system_ca
|
||||
except Exception as exc:
|
||||
_logger.warning(f"Could not upsert system-config-ca: {exc}")
|
||||
try:
|
||||
db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=None, cert_type_str='user', cert_identity=None):
|
||||
if ca is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
from gatehouse_app.extensions import db
|
||||
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)
|
||||
except ValueError:
|
||||
resolved_cert_type = CertType.USER
|
||||
|
||||
cert_record = SSHCertificate(
|
||||
ca_id=ca.id,
|
||||
user_id=user_id,
|
||||
ssh_key_id=ssh_key_id,
|
||||
certificate=signing_response.certificate,
|
||||
serial=signing_response.serial,
|
||||
key_id=cert_identity or (str(ssh_key_id) if ssh_key_id else "host-cert"),
|
||||
cert_type=resolved_cert_type,
|
||||
principals=signing_response.principals,
|
||||
valid_after=signing_response.valid_after,
|
||||
valid_before=signing_response.valid_before,
|
||||
revoked=False,
|
||||
status=CertificateStatus.ISSUED,
|
||||
request_ip=request_ip,
|
||||
)
|
||||
db.session.add(cert_record)
|
||||
db.session.commit()
|
||||
return cert_record
|
||||
except Exception as exc:
|
||||
_logger.warning(f"Failed to persist certificate to DB: {exc}")
|
||||
try:
|
||||
from gatehouse_app.extensions import db as _db
|
||||
_db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_merged_dept_cert_policy(user_id):
|
||||
from gatehouse_app.models.organization.department import DepartmentMembership
|
||||
from gatehouse_app.models.organization.department_cert_policy import DepartmentCertPolicy
|
||||
|
||||
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)
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
def _classify_ssh_key_material(raw: str) -> str:
|
||||
import re
|
||||
line = raw.strip().split()[0] if raw.strip() else ""
|
||||
if re.search(r"-cert-v01@openssh\.com$", line):
|
||||
return "certificate"
|
||||
if re.match(
|
||||
r"^(ssh-ed25519|ssh-rsa|ssh-dss|ecdsa-sha2-nistp\d+|sk-ssh-ed25519@openssh\.com)$",
|
||||
line,
|
||||
):
|
||||
return "public_key"
|
||||
if "BEGIN OPENSSH PRIVATE KEY" in raw or "BEGIN RSA PRIVATE KEY" in raw:
|
||||
return "private_key"
|
||||
return "unknown"
|
||||
@@ -0,0 +1,111 @@
|
||||
"""SSH CA permissions admin endpoints."""
|
||||
from flask import request, g
|
||||
from gatehouse_app.api.v1.ssh._helpers import ssh_bp
|
||||
from gatehouse_app.utils.constants import AuditAction, OrganizationRole
|
||||
from gatehouse_app.models import AuditLog
|
||||
from gatehouse_app.utils.decorators import login_required
|
||||
from gatehouse_app.utils.response import api_response
|
||||
|
||||
|
||||
@ssh_bp.route('/ca/<ca_id>/permissions', methods=['GET'])
|
||||
@login_required
|
||||
def list_ca_permissions(ca_id):
|
||||
from gatehouse_app.models.ssh_ca.ca import CA, CAPermission
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
|
||||
user = g.current_user
|
||||
ca = CA.query.filter_by(id=ca_id, deleted_at=None).first()
|
||||
if not ca:
|
||||
return api_response(success=False, message="CA not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
if ca.organization_id:
|
||||
membership = OrganizationMember.query.filter_by(organization_id=ca.organization_id, user_id=user.id, deleted_at=None).first()
|
||||
if not membership or membership.role not in (OrganizationRole.ADMIN, OrganizationRole.OWNER):
|
||||
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
|
||||
|
||||
perms = CAPermission.query.filter_by(ca_id=ca_id, deleted_at=None).all()
|
||||
perm_list = []
|
||||
for p in perms:
|
||||
d = p.to_dict()
|
||||
d["user_email"] = p.user.email if p.user else None
|
||||
perm_list.append(d)
|
||||
|
||||
return api_response(data={"ca_id": ca_id, "permissions": perm_list, "open_to_all": len(perms) == 0}, message="CA permissions retrieved")
|
||||
|
||||
|
||||
@ssh_bp.route('/ca/<ca_id>/permissions', methods=['POST'])
|
||||
@login_required
|
||||
def add_ca_permission(ca_id):
|
||||
from gatehouse_app.models.ssh_ca.ca import CA, CAPermission
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
user = g.current_user
|
||||
ca = CA.query.filter_by(id=ca_id, deleted_at=None).first()
|
||||
if not ca:
|
||||
return api_response(success=False, message="CA not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
if ca.organization_id:
|
||||
membership = OrganizationMember.query.filter_by(organization_id=ca.organization_id, user_id=user.id, deleted_at=None).first()
|
||||
if not membership or membership.role not in (OrganizationRole.ADMIN, OrganizationRole.OWNER):
|
||||
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
|
||||
|
||||
data = request.get_json() or {}
|
||||
target_user_id = (data.get("user_id") or "").strip()
|
||||
permission = data.get("permission", "sign")
|
||||
|
||||
if not target_user_id:
|
||||
return api_response(success=False, message="user_id is required", status=400, error_type="VALIDATION_ERROR")
|
||||
if permission not in ("sign", "admin"):
|
||||
return api_response(success=False, message="permission must be 'sign' or 'admin'", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
target_user = User.query.filter_by(id=target_user_id, deleted_at=None).first()
|
||||
if not target_user:
|
||||
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
existing = CAPermission.query.filter_by(ca_id=ca_id, user_id=target_user_id, deleted_at=None).first()
|
||||
if existing:
|
||||
if existing.permission != permission:
|
||||
existing.permission = permission
|
||||
db.session.commit()
|
||||
d = existing.to_dict()
|
||||
d["user_email"] = target_user.email
|
||||
return api_response(data={"message": "Permission updated", "permission": d}, message="Permission updated")
|
||||
return api_response(success=False, message="User already has this permission on the CA", status=409, error_type="DUPLICATE")
|
||||
|
||||
perm = CAPermission(ca_id=ca_id, user_id=target_user_id, permission=permission)
|
||||
db.session.add(perm)
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log(action=AuditAction.CA_UPDATED, user_id=user.id, resource_type="CAPermission", resource_id=perm.id, ip_address=request.remote_addr, description=f"Granted '{permission}' on CA '{ca.name}' to user {target_user.email}")
|
||||
|
||||
d = perm.to_dict()
|
||||
d["user_email"] = target_user.email
|
||||
return api_response(data={"message": "Permission granted", "permission": d}, message="Permission granted", status=201)
|
||||
|
||||
|
||||
@ssh_bp.route('/ca/<ca_id>/permissions/<target_user_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def remove_ca_permission(ca_id, target_user_id):
|
||||
from gatehouse_app.models.ssh_ca.ca import CA, CAPermission
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
user = g.current_user
|
||||
ca = CA.query.filter_by(id=ca_id, deleted_at=None).first()
|
||||
if not ca:
|
||||
return api_response(success=False, message="CA not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
if ca.organization_id:
|
||||
membership = OrganizationMember.query.filter_by(organization_id=ca.organization_id, user_id=user.id, deleted_at=None).first()
|
||||
if not membership or membership.role not in (OrganizationRole.ADMIN, OrganizationRole.OWNER):
|
||||
return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN")
|
||||
|
||||
perm = CAPermission.query.filter_by(ca_id=ca_id, user_id=target_user_id, deleted_at=None).first()
|
||||
if not perm:
|
||||
return api_response(success=False, message="Permission not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
perm.delete(soft=True)
|
||||
AuditLog.log(action=AuditAction.CA_UPDATED, user_id=user.id, resource_type="CAPermission", resource_id=perm.id, ip_address=request.remote_addr, description=f"Revoked permission on CA '{ca.name}' from user {target_user_id}")
|
||||
return api_response(data={}, message="Permission revoked")
|
||||
@@ -0,0 +1,391 @@
|
||||
"""SSH certificate signing and listing endpoints."""
|
||||
from flask import request, g
|
||||
from gatehouse_app.api.v1.ssh._helpers import (
|
||||
ssh_bp, ssh_key_service, ssh_ca_service,
|
||||
_get_org_ca_for_user, _persist_certificate,
|
||||
_get_merged_dept_cert_policy, _classify_ssh_key_material,
|
||||
)
|
||||
from gatehouse_app.services.ssh_ca_signing_service import SSHCertificateSigningRequest
|
||||
from gatehouse_app.exceptions import SSHKeyNotFoundError, SSHCertificateError
|
||||
from gatehouse_app.utils.constants import AuditAction, OrganizationRole
|
||||
from gatehouse_app.models import AuditLog
|
||||
from gatehouse_app.models.ssh_ca.certificate_audit_log import CertificateAuditLog
|
||||
from gatehouse_app.utils.decorators import login_required
|
||||
from gatehouse_app.utils.response import api_response
|
||||
|
||||
|
||||
@ssh_bp.route('/dept-cert-policy', methods=['GET'])
|
||||
@login_required
|
||||
def get_my_dept_cert_policy():
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.department_cert_policy import STANDARD_EXTENSIONS
|
||||
|
||||
user = g.current_user
|
||||
user_id = user.id
|
||||
|
||||
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, "default_expiry_hours": 1, "max_expiry_hours": 24, "extensions": list(STANDARD_EXTENSIONS)}
|
||||
elif is_org_admin:
|
||||
policy = {**policy, "allow_user_expiry": True}
|
||||
|
||||
return api_response(data={"policy": policy}, message="Certificate policy retrieved")
|
||||
|
||||
|
||||
@ssh_bp.route('/sign', methods=['POST'])
|
||||
@login_required
|
||||
def sign_certificate():
|
||||
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 UserStatus
|
||||
|
||||
user = g.current_user
|
||||
user_id = user.id
|
||||
|
||||
if user.status in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_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")
|
||||
|
||||
requested_principals = data.get('principals') or []
|
||||
cert_type = data.get('cert_type', 'user')
|
||||
key_id = data.get('key_id') or data.get('cert_id')
|
||||
expiry_hours = data.get('expiry_hours')
|
||||
|
||||
AuditLog.log(
|
||||
action=AuditAction.SSH_CERT_REQUESTED,
|
||||
user_id=user_id, resource_type='SSHCertificate', ip_address=request.remote_addr,
|
||||
description=(f'{user.email} requested a certificate' + (f' for principals: {", ".join(requested_principals)}' if requested_principals else '')),
|
||||
)
|
||||
|
||||
allowed_principal_names = set()
|
||||
memberships = OrganizationMember.query.filter_by(user_id=user_id).all()
|
||||
for om in memberships:
|
||||
org = om.organization
|
||||
if not org or org.deleted_at is not None:
|
||||
continue
|
||||
role = om.role
|
||||
if role in (OrganizationRole.ADMIN, OrganizationRole.OWNER):
|
||||
for p in Principal.query.filter_by(organization_id=org.id, deleted_at=None).all():
|
||||
allowed_principal_names.add(p.name)
|
||||
else:
|
||||
for pm in PrincipalMembership.query.filter_by(user_id=user_id, deleted_at=None).all():
|
||||
if pm.principal and pm.principal.organization_id == org.id and pm.principal.deleted_at is None:
|
||||
allowed_principal_names.add(pm.principal.name)
|
||||
for dm in DepartmentMembership.query.filter_by(user_id=user_id, deleted_at=None).all():
|
||||
if dm.department and dm.department.organization_id == org.id and dm.department.deleted_at is None:
|
||||
for dp in DepartmentPrincipal.query.filter_by(department_id=dm.department_id, deleted_at=None).all():
|
||||
if dp.principal and dp.principal.deleted_at is None:
|
||||
allowed_principal_names.add(dp.principal.name)
|
||||
|
||||
if not requested_principals:
|
||||
principals = list(allowed_principal_names)
|
||||
if not principals:
|
||||
return api_response(success=False, message="You have no principals assigned. Ask an admin to add you to a principal.", status=400, error_type="NO_PRINCIPALS")
|
||||
else:
|
||||
invalid = [p for p in requested_principals if p not in allowed_principal_names]
|
||||
if invalid:
|
||||
return api_response(success=False, message=f"You are not authorised to request principals: {', '.join(invalid)}", status=403, error_type="UNAUTHORIZED_PRINCIPALS")
|
||||
principals = requested_principals
|
||||
|
||||
if not key_id:
|
||||
verified_keys = ssh_key_service.get_user_verified_ssh_keys(user_id)
|
||||
if not verified_keys:
|
||||
return api_response(success=False, message="No verified SSH keys found. Verify a key before requesting a certificate.", status=400, error_type="NO_VERIFIED_KEYS")
|
||||
key_id = verified_keys[0].id
|
||||
|
||||
try:
|
||||
ssh_key = ssh_key_service.get_ssh_key(key_id)
|
||||
except SSHKeyNotFoundError:
|
||||
return api_response(success=False, message="SSH key not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
if ssh_key.user_id != user_id:
|
||||
return api_response(success=False, message="Forbidden", status=403, error_type="FORBIDDEN")
|
||||
|
||||
if not ssh_key.verified:
|
||||
return api_response(success=False, message="SSH key is not verified. Verify it before requesting a certificate.", status=400, error_type="KEY_NOT_VERIFIED")
|
||||
|
||||
db_ca = _get_org_ca_for_user(user, ca_type=cert_type)
|
||||
if db_ca is None:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="No active Certificate Authority is configured for your organization. An admin must generate a CA on the Certificate Authorities page before certificates can be issued.",
|
||||
status=503, error_type="CA_NOT_CONFIGURED",
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
dept_policy = _get_merged_dept_cert_policy(user_id)
|
||||
if dept_policy:
|
||||
if is_org_admin:
|
||||
if expiry_hours is not None:
|
||||
expiry_hours = min(int(expiry_hours), dept_policy["max_expiry_hours"])
|
||||
elif not dept_policy["allow_user_expiry"]:
|
||||
expiry_hours = dept_policy["default_expiry_hours"]
|
||||
else:
|
||||
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
|
||||
|
||||
org_slugs = sorted({
|
||||
om.organization.slug for om in memberships
|
||||
if om.organization and om.organization.deleted_at is None and getattr(om.organization, 'slug', None)
|
||||
})
|
||||
org_slug = org_slugs[0] if org_slugs else "unknown"
|
||||
full_name = getattr(user, 'full_name', None) or getattr(user, 'name', None) or "unknown"
|
||||
cert_identity = f"{user.email} ({full_name}) [org:{org_slug}]"
|
||||
|
||||
signing_request = SSHCertificateSigningRequest(
|
||||
ssh_public_key=ssh_key.payload, principals=principals, cert_type=cert_type,
|
||||
key_id=cert_identity, expiry_hours=int(expiry_hours) if expiry_hours else None,
|
||||
extensions=policy_extensions,
|
||||
)
|
||||
validation_errors = signing_request.validate()
|
||||
if validation_errors:
|
||||
return api_response(success=False, message="Invalid signing request", status=400, error_type="VALIDATION_ERROR", error_details={"errors": validation_errors})
|
||||
|
||||
try:
|
||||
from gatehouse_app.utils.ca_key_encryption import decrypt_ca_key
|
||||
ca_private_key_pem = decrypt_ca_key(db_ca.private_key)
|
||||
response = ssh_ca_service.sign_certificate(signing_request, ca_private_key=ca_private_key_pem, ca_obj=db_ca)
|
||||
except SSHCertificateError as e:
|
||||
AuditLog.log(action=AuditAction.SSH_CERT_FAILED, user_id=user_id, resource_type='SSHCertificate', ip_address=request.remote_addr, success=False, error_message=str(e))
|
||||
return api_response(success=False, message=str(e), status=400, error_type="SIGNING_FAILED")
|
||||
except Exception as e:
|
||||
AuditLog.log(action=AuditAction.SSH_CERT_FAILED, user_id=user_id, resource_type='SSHCertificate', ip_address=request.remote_addr, success=False, error_message=str(e))
|
||||
return api_response(success=False, message="Certificate signing failed", status=500, error_type="SERVER_ERROR")
|
||||
|
||||
cert_record = _persist_certificate(
|
||||
user_id=user_id, ssh_key_id=key_id, ca=db_ca,
|
||||
signing_response=response, request_ip=request.remote_addr,
|
||||
cert_type_str=cert_type, cert_identity=cert_identity,
|
||||
)
|
||||
|
||||
AuditLog.log(
|
||||
action=AuditAction.SSH_CERT_ISSUED, user_id=user_id,
|
||||
resource_type='SSHCertificate', resource_id=cert_record.id if cert_record else key_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f'Certificate serial={response.serial} issued for {user.email}; principals: {", ".join(principals)}',
|
||||
extra_data={'serial': response.serial, 'key_id': cert_identity, 'principals': principals, 'ca_id': str(db_ca.id), 'ssh_key_id': str(key_id)},
|
||||
)
|
||||
|
||||
if cert_record:
|
||||
CertificateAuditLog.log(
|
||||
certificate_id=cert_record.id, action='issued', user_id=user_id,
|
||||
ip_address=request.remote_addr, user_agent=request.headers.get('User-Agent'),
|
||||
message=f'Certificate serial={response.serial} issued for {user.email}; principals: {", ".join(principals)}',
|
||||
extra_data={
|
||||
'serial': response.serial, 'key_id': cert_identity, 'principals': principals,
|
||||
'ca_id': str(db_ca.id), 'ssh_key_id': str(key_id),
|
||||
'valid_after': response.valid_after.isoformat() if response.valid_after else None,
|
||||
'valid_before': response.valid_before.isoformat() if response.valid_before else None,
|
||||
},
|
||||
success=True,
|
||||
)
|
||||
|
||||
result = {
|
||||
'certificate': response.certificate, 'serial': response.serial,
|
||||
'principals': response.principals,
|
||||
'valid_after': response.valid_after.isoformat() if response.valid_after else None,
|
||||
'valid_before': response.valid_before.isoformat() if response.valid_before else None,
|
||||
}
|
||||
if cert_record:
|
||||
result['cert_id'] = str(cert_record.id)
|
||||
|
||||
return api_response(data=result, message="Certificate signed successfully", status=201)
|
||||
|
||||
|
||||
@ssh_bp.route('/sign/host', methods=['POST'])
|
||||
@login_required
|
||||
def sign_host_certificate():
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.ssh_ca.ca import CA, CaType
|
||||
from gatehouse_app.utils.ca_key_encryption import decrypt_ca_key
|
||||
|
||||
user = g.current_user
|
||||
user_id = user.id
|
||||
|
||||
is_admin = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.role.in_([OrganizationRole.ADMIN, OrganizationRole.OWNER]),
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).first() is not None
|
||||
|
||||
if not is_admin:
|
||||
return api_response(success=False, message="Issuing host certificates requires org admin or owner role.", status=403, error_type="FORBIDDEN")
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return api_response(success=False, message="No JSON data provided", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
host_public_key = (data.get("host_public_key") or "").strip()
|
||||
principals = data.get("principals") or []
|
||||
validity_hours = data.get("validity_hours", 720)
|
||||
ca_id = (data.get("ca_id") or "").strip()
|
||||
|
||||
if not host_public_key:
|
||||
return api_response(success=False, message="host_public_key is required.", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
key_kind = _classify_ssh_key_material(host_public_key)
|
||||
if key_kind == "certificate":
|
||||
return api_response(success=False, message="You submitted a certificate (ssh-…-cert-v01@openssh.com), not a host public key. Retrieve the server's host public key with: cat /etc/ssh/ssh_host_ed25519_key.pub", status=400, error_type="WRONG_KEY_MATERIAL")
|
||||
if key_kind == "private_key":
|
||||
return api_response(success=False, message="Private keys must never be submitted here. Use the .pub file.", status=400, error_type="WRONG_KEY_MATERIAL")
|
||||
if key_kind == "unknown":
|
||||
return api_response(success=False, message="Unrecognised key format. Expected an OpenSSH public key starting with ssh-ed25519, ssh-rsa, or ecdsa-sha2-*.", status=400, error_type="WRONG_KEY_MATERIAL")
|
||||
|
||||
if not principals or not isinstance(principals, list):
|
||||
return api_response(success=False, message="principals must be a non-empty list of hostnames.", status=422, error_type="VALIDATION_ERROR")
|
||||
principals = [str(p).strip() for p in principals if str(p).strip()]
|
||||
if not principals:
|
||||
return api_response(success=False, message="At least one principal (hostname/FQDN) is required.", status=422, error_type="VALIDATION_ERROR")
|
||||
|
||||
try:
|
||||
validity_hours = int(validity_hours)
|
||||
if validity_hours < 1:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
return api_response(success=False, message="validity_hours must be a positive integer.", status=422, error_type="VALIDATION_ERROR")
|
||||
|
||||
if not ca_id:
|
||||
return api_response(success=False, message="ca_id is required.", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
org_ids = [m.organization_id for m in OrganizationMember.query.filter_by(user_id=user_id, deleted_at=None).all()]
|
||||
|
||||
any_ca = CA.query.filter(CA.id == ca_id, CA.is_active.is_(True), CA.organization_id.in_(org_ids), CA.deleted_at.is_(None)).first()
|
||||
|
||||
if any_ca and any_ca.ca_type != CaType.HOST:
|
||||
return api_response(success=False, message=f"The CA '{any_ca.name}' is a {any_ca.ca_type.value} CA. Host certificates must be signed by a ca_type='host' CA.", status=400, error_type="WRONG_CA_TYPE")
|
||||
|
||||
host_ca = any_ca
|
||||
if not host_ca:
|
||||
return api_response(success=False, message="Host CA not found, inactive, or you do not have permission to use it. Ensure the CA exists and ca_type is 'host'.", status=404, error_type="CA_NOT_FOUND")
|
||||
|
||||
primary_principal = principals[0]
|
||||
cert_identity = f"host:{primary_principal} [signed-by:{user.email}]"
|
||||
|
||||
signing_request = SSHCertificateSigningRequest(
|
||||
ssh_public_key=host_public_key, principals=principals, cert_type="host",
|
||||
key_id=cert_identity, expiry_hours=validity_hours, extensions=[], critical_options={},
|
||||
)
|
||||
validation_errors = signing_request.validate()
|
||||
if validation_errors:
|
||||
return api_response(success=False, message="Invalid signing request: " + "; ".join(validation_errors), status=422, error_type="VALIDATION_ERROR")
|
||||
|
||||
try:
|
||||
ca_private_key_pem = decrypt_ca_key(host_ca.private_key)
|
||||
response = ssh_ca_service.sign_certificate(signing_request, ca_private_key=ca_private_key_pem, ca_obj=host_ca)
|
||||
except Exception as exc:
|
||||
AuditLog.log(action=AuditAction.SSH_CERT_FAILED, user_id=user_id, resource_type="SSHCertificate", ip_address=request.remote_addr, success=False, error_message=str(exc))
|
||||
return api_response(success=False, message=f"Host certificate signing failed: {exc}", status=500, error_type="SIGNING_FAILED")
|
||||
|
||||
cert_record = _persist_certificate(
|
||||
user_id=user_id, ssh_key_id=None, ca=host_ca,
|
||||
signing_response=response, request_ip=request.remote_addr,
|
||||
cert_type_str="host", cert_identity=cert_identity,
|
||||
)
|
||||
|
||||
AuditLog.log(
|
||||
action=AuditAction.SSH_CERT_ISSUED, user_id=user_id,
|
||||
resource_type="SSHCertificate", resource_id=cert_record.id if cert_record else None,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"Host certificate serial={response.serial} issued for {primary_principal} by {user.email}",
|
||||
extra_data={"serial": response.serial, "principals": principals, "ca_id": str(host_ca.id), "cert_type": "host"},
|
||||
)
|
||||
|
||||
result = {
|
||||
"certificate": response.certificate, "serial": response.serial, "principals": response.principals,
|
||||
"valid_after": response.valid_after.isoformat() if response.valid_after else None,
|
||||
"valid_before": response.valid_before.isoformat() if response.valid_before else None,
|
||||
}
|
||||
if cert_record:
|
||||
result["cert_id"] = str(cert_record.id)
|
||||
|
||||
return api_response(data=result, message="Host certificate issued successfully", status=201)
|
||||
|
||||
|
||||
@ssh_bp.route('/certificates', methods=['GET'])
|
||||
@login_required
|
||||
def list_certificates():
|
||||
user_id = g.current_user.id
|
||||
try:
|
||||
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate
|
||||
certs = SSHCertificate.query.filter_by(user_id=user_id, deleted_at=None).order_by(SSHCertificate.created_at.desc()).all()
|
||||
return api_response(data={'certificates': [c.to_dict() for c in certs], 'count': len(certs)}, message="Certificates retrieved successfully")
|
||||
except Exception as e:
|
||||
return api_response(success=False, message=str(e), status=500, error_type='INTERNAL_ERROR')
|
||||
|
||||
|
||||
@ssh_bp.route('/certificates/<cert_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_certificate(cert_id):
|
||||
user_id = g.current_user.id
|
||||
try:
|
||||
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')
|
||||
if cert.user_id != user_id:
|
||||
return api_response(success=False, message='Forbidden', status=403, error_type='FORBIDDEN')
|
||||
data = cert.to_dict()
|
||||
data['certificate'] = cert.certificate
|
||||
return api_response(success=True, message='Certificate retrieved', data=data, status=200)
|
||||
except Exception as e:
|
||||
return api_response(success=False, message=str(e), status=500, error_type='INTERNAL_ERROR')
|
||||
|
||||
|
||||
@ssh_bp.route('/certificates/<cert_id>/revoke', methods=['POST'])
|
||||
@login_required
|
||||
def revoke_certificate(cert_id):
|
||||
user_id = g.current_user.id
|
||||
data = request.get_json() or {}
|
||||
reason = data.get('reason', 'User requested revocation')
|
||||
try:
|
||||
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')
|
||||
if cert.user_id != user_id:
|
||||
return api_response(success=False, message='Forbidden', status=403, error_type='FORBIDDEN')
|
||||
if cert.revoked:
|
||||
return api_response(success=False, message='Certificate is already revoked', status=409, error_type='ALREADY_REVOKED')
|
||||
|
||||
cert.revoke(reason=reason)
|
||||
AuditLog.log(action=AuditAction.SSH_CERT_REVOKED, user_id=user_id, resource_type='SSHCertificate', resource_id=cert_id, ip_address=request.remote_addr, description=f'Revoked: {reason}')
|
||||
CertificateAuditLog.log(certificate_id=cert_id, action='revoked', user_id=user_id, ip_address=request.remote_addr, user_agent=request.headers.get('User-Agent'), message=f'Certificate revoked: {reason}', success=True)
|
||||
|
||||
return api_response(success=True, message='Certificate revoked successfully', data={'status': 'revoked', 'cert_id': cert_id, 'reason': reason}, status=200)
|
||||
except Exception as e:
|
||||
return api_response(success=False, message=str(e), status=500, error_type='INTERNAL_ERROR')
|
||||
|
||||
|
||||
@ssh_bp.route('/ca/public-key', methods=['GET'])
|
||||
@login_required
|
||||
def get_ca_public_key():
|
||||
user = g.current_user
|
||||
ca_type = request.args.get("ca_type", "user")
|
||||
if ca_type not in ("user", "host"):
|
||||
return api_response(success=False, message="ca_type must be 'user' or 'host'", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
db_ca = _get_org_ca_for_user(user, ca_type=ca_type)
|
||||
if db_ca:
|
||||
return api_response(
|
||||
data={'public_key': db_ca.public_key, 'fingerprint': db_ca.fingerprint, 'ca_name': db_ca.name, 'ca_type': ca_type, 'source': 'db'},
|
||||
message="CA public key retrieved successfully",
|
||||
)
|
||||
return api_response(success=False, message=f"No {ca_type} CA is configured for your organization. An admin must generate one on the Certificate Authorities page.", status=404, error_type="CA_NOT_CONFIGURED")
|
||||
@@ -0,0 +1,125 @@
|
||||
"""SSH key management endpoints."""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from flask import request, g
|
||||
from gatehouse_app.api.v1.ssh._helpers import ssh_bp, ssh_key_service
|
||||
from gatehouse_app.exceptions import SSHKeyError, SSHKeyNotFoundError, ValidationError, SSHKeyAlreadyExistsError
|
||||
from gatehouse_app.utils.constants import AuditAction
|
||||
from gatehouse_app.models import AuditLog
|
||||
from gatehouse_app.utils.decorators import login_required
|
||||
from gatehouse_app.utils.response import api_response
|
||||
|
||||
|
||||
@ssh_bp.route('/keys', methods=['GET'])
|
||||
@login_required
|
||||
def list_ssh_keys():
|
||||
user_id = g.current_user.id
|
||||
keys = ssh_key_service.get_user_ssh_keys(user_id)
|
||||
return api_response(data={'keys': [k.to_dict() for k in keys], 'count': len(keys)}, message="SSH keys retrieved successfully")
|
||||
|
||||
|
||||
@ssh_bp.route('/keys', methods=['POST'])
|
||||
@login_required
|
||||
def add_ssh_key():
|
||||
user_id = g.current_user.id
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return api_response(success=False, message='No JSON data provided', status=400, error_type='BAD_REQUEST')
|
||||
|
||||
public_key = data.get('public_key') or data.get('key')
|
||||
description = data.get('description')
|
||||
|
||||
if not public_key:
|
||||
return api_response(success=False, message='public_key is required', status=400, error_type='BAD_REQUEST')
|
||||
|
||||
try:
|
||||
ssh_key = ssh_key_service.add_ssh_key(user_id=user_id, public_key=public_key, description=description)
|
||||
AuditLog.log(action=AuditAction.SSH_KEY_ADDED, user_id=user_id, resource_type='SSHKey', resource_id=ssh_key.id, ip_address=request.remote_addr)
|
||||
return api_response(success=True, message='SSH key added', data=ssh_key.to_dict(), status=201)
|
||||
except SSHKeyAlreadyExistsError as e:
|
||||
return api_response(success=False, message=e.message, status=409, error_type='SSH_KEY_ALREADY_EXISTS')
|
||||
except IntegrityError:
|
||||
return api_response(success=False, message='SSH key already exists', status=409, error_type='SSH_KEY_ALREADY_EXISTS')
|
||||
except SSHKeyError as e:
|
||||
return api_response(success=False, message=str(e), status=400, error_type='SSH_KEY_ERROR')
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message=str(e), status=400, error_type='VALIDATION_ERROR')
|
||||
|
||||
|
||||
@ssh_bp.route('/keys/<key_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_ssh_key(key_id):
|
||||
user_id = g.current_user.id
|
||||
try:
|
||||
ssh_key = ssh_key_service.get_ssh_key(key_id)
|
||||
if ssh_key.user_id != user_id:
|
||||
return api_response(success=False, message='Forbidden', status=403, error_type='FORBIDDEN')
|
||||
return api_response(success=True, message='SSH key retrieved', data=ssh_key.to_dict(), status=200)
|
||||
except SSHKeyNotFoundError:
|
||||
return api_response(success=False, message='SSH key not found', status=404, error_type='NOT_FOUND')
|
||||
|
||||
|
||||
@ssh_bp.route('/keys/<key_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_ssh_key(key_id):
|
||||
user_id = g.current_user.id
|
||||
try:
|
||||
ssh_key = ssh_key_service.get_ssh_key(key_id)
|
||||
if ssh_key.user_id != user_id:
|
||||
return api_response(success=False, message='Forbidden', status=403, error_type='FORBIDDEN')
|
||||
ssh_key_service.delete_ssh_key(key_id)
|
||||
AuditLog.log(action=AuditAction.SSH_KEY_DELETED, user_id=user_id, resource_type='SSHKey', resource_id=key_id, ip_address=request.remote_addr)
|
||||
return api_response(success=True, message='SSH key deleted', data={'status': 'deleted'}, status=200)
|
||||
except SSHKeyNotFoundError:
|
||||
return api_response(success=False, message='SSH key not found', status=404, error_type='NOT_FOUND')
|
||||
|
||||
|
||||
@ssh_bp.route('/keys/<key_id>/verify', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def verify_ssh_key(key_id):
|
||||
user_id = g.current_user.id
|
||||
try:
|
||||
ssh_key = ssh_key_service.get_ssh_key(key_id)
|
||||
if ssh_key.user_id != user_id:
|
||||
return api_response(success=False, message='Forbidden', status=403, error_type='FORBIDDEN')
|
||||
|
||||
if request.method == 'GET':
|
||||
challenge = ssh_key_service.generate_verification_challenge(key_id)
|
||||
return api_response(success=True, message='Challenge generated', data={'challenge_text': challenge, 'validationText': challenge, 'key_id': key_id}, status=200)
|
||||
|
||||
data = request.get_json() or {}
|
||||
action = data.get('action', 'verify_signature')
|
||||
|
||||
if action == 'verify_signature':
|
||||
signature = data.get('signature')
|
||||
if not signature:
|
||||
return api_response(success=False, message='signature is required', status=400, error_type='BAD_REQUEST')
|
||||
try:
|
||||
verified = ssh_key_service.verify_ssh_key_ownership(key_id, signature)
|
||||
AuditLog.log(action=AuditAction.SSH_KEY_VERIFIED, user_id=user_id, resource_type='SSHKey', resource_id=key_id, ip_address=request.remote_addr, success=verified)
|
||||
return api_response(success=True, message='Verification complete', data={'verified': verified}, status=200)
|
||||
except Exception as e:
|
||||
AuditLog.log(action=AuditAction.SSH_KEY_VALIDATION_FAILED, user_id=user_id, resource_type='SSHKey', resource_id=key_id, ip_address=request.remote_addr, success=False, error_message=str(e))
|
||||
return api_response(success=False, message=str(e), status=400, error_type='VERIFICATION_FAILED')
|
||||
else:
|
||||
challenge = ssh_key_service.generate_verification_challenge(key_id)
|
||||
return api_response(success=True, message='Challenge generated', data={'challenge_text': challenge, 'challenge': challenge}, status=200)
|
||||
|
||||
except SSHKeyNotFoundError:
|
||||
return api_response(success=False, message='SSH key not found', status=404, error_type='NOT_FOUND')
|
||||
|
||||
|
||||
@ssh_bp.route('/keys/<key_id>/update-description', methods=['PATCH'])
|
||||
@login_required
|
||||
def update_ssh_key_description(key_id):
|
||||
user_id = g.current_user.id
|
||||
data = request.get_json()
|
||||
if not data or 'description' not in data:
|
||||
return api_response(success=False, message='description is required', status=400, error_type='BAD_REQUEST')
|
||||
try:
|
||||
ssh_key = ssh_key_service.get_ssh_key(key_id)
|
||||
if ssh_key.user_id != user_id:
|
||||
return api_response(success=False, message='Forbidden', status=403, error_type='FORBIDDEN')
|
||||
updated_key = ssh_key_service.update_ssh_key_description(key_id, data['description'])
|
||||
return api_response(success=True, message='Description updated', data=updated_key.to_dict(), status=200)
|
||||
except SSHKeyNotFoundError:
|
||||
return api_response(success=False, message='SSH key not found', status=404, error_type='NOT_FOUND')
|
||||
@@ -1,879 +0,0 @@
|
||||
"""User endpoints."""
|
||||
from flask import g, request
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required, full_access_required
|
||||
from gatehouse_app.schemas.user_schema import UserUpdateSchema, ChangePasswordSchema
|
||||
from gatehouse_app.services.user_service import UserService
|
||||
from gatehouse_app.services.auth_service import AuthService
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me", methods=["GET"])
|
||||
@login_required
|
||||
def get_me():
|
||||
"""
|
||||
Get current user profile.
|
||||
|
||||
Returns:
|
||||
200: User profile data
|
||||
401: Not authenticated
|
||||
"""
|
||||
user = g.current_user
|
||||
|
||||
return api_response(
|
||||
data={"user": user.to_dict()},
|
||||
message="User profile retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me", methods=["PATCH"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def update_me():
|
||||
"""
|
||||
Update current user profile.
|
||||
|
||||
Request body:
|
||||
full_name: Optional full name
|
||||
avatar_url: Optional avatar URL
|
||||
|
||||
Returns:
|
||||
200: User updated successfully
|
||||
400: Validation error
|
||||
401: Not authenticated
|
||||
"""
|
||||
try:
|
||||
# Validate request data
|
||||
schema = UserUpdateSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Update user
|
||||
user = UserService.update_user(g.current_user, **data)
|
||||
|
||||
return api_response(
|
||||
data={"user": user.to_dict()},
|
||||
message="Profile updated successfully",
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Validation failed",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details=e.messages,
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me", methods=["DELETE"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def delete_me():
|
||||
"""
|
||||
Delete current user account (soft delete).
|
||||
|
||||
Behaviour for owned organizations:
|
||||
- If the org has other active members → blocked; user must transfer ownership first.
|
||||
- If they are the sole member → org is automatically cascade-deleted (no orphan risk).
|
||||
|
||||
Returns:
|
||||
200: Account deleted successfully (sole-member orgs auto-deleted)
|
||||
401: Not authenticated
|
||||
409: USER_IS_SOLE_OWNER — user owns orgs that still have other members
|
||||
"""
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
from gatehouse_app.services.organization_service import OrganizationService
|
||||
|
||||
user = g.current_user
|
||||
|
||||
# Find all orgs where this user is the owner.
|
||||
owned_memberships = OrganizationMember.query.filter_by(
|
||||
user_id=user.id,
|
||||
role=OrganizationRole.OWNER,
|
||||
deleted_at=None,
|
||||
).all()
|
||||
|
||||
# Separate into two buckets depending on whether other members exist.
|
||||
transfer_needed = [] # org has other members → must transfer ownership first
|
||||
auto_delete = [] # user is sole member → safe to cascade-delete automatically
|
||||
|
||||
for membership in owned_memberships:
|
||||
org = membership.organization
|
||||
if org.deleted_at is not None:
|
||||
continue
|
||||
member_count = org.get_member_count()
|
||||
if member_count > 1:
|
||||
transfer_needed.append(org.name)
|
||||
else:
|
||||
auto_delete.append(org)
|
||||
|
||||
# Hard block: user owns orgs with other members — must transfer first.
|
||||
if transfer_needed:
|
||||
names = ", ".join(f'"{n}"' for n in transfer_needed)
|
||||
return api_response(
|
||||
success=False,
|
||||
message=(
|
||||
f"You are the owner of {len(transfer_needed)} organization"
|
||||
f"{'s' if len(transfer_needed) > 1 else ''} that still "
|
||||
f"{'have' if len(transfer_needed) > 1 else 'has'} other members "
|
||||
f"({names}). Transfer ownership to another member first."
|
||||
),
|
||||
status=409,
|
||||
error_type="USER_IS_SOLE_OWNER",
|
||||
error_details={"transfer_ownership": transfer_needed},
|
||||
)
|
||||
|
||||
# Auto-delete any sole-member orgs so no orphaned org rows can ever be left behind.
|
||||
for org in auto_delete:
|
||||
OrganizationService.force_delete_organization(org, user_id=user.id)
|
||||
|
||||
UserService.delete_user(user, soft=True)
|
||||
|
||||
return api_response(
|
||||
message="Account deleted successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me/password", methods=["POST"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def change_password():
|
||||
"""
|
||||
Change current user password.
|
||||
|
||||
Request body:
|
||||
current_password: Current password
|
||||
new_password: New password
|
||||
new_password_confirm: New password confirmation
|
||||
|
||||
Returns:
|
||||
200: Password changed successfully
|
||||
400: Validation error
|
||||
401: Not authenticated or invalid current password
|
||||
"""
|
||||
try:
|
||||
# Validate request data
|
||||
schema = ChangePasswordSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Verify passwords match
|
||||
if data["new_password"] != data["new_password_confirm"]:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="New passwords do not match",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details={"new_password_confirm": ["Passwords do not match"]},
|
||||
)
|
||||
|
||||
# Change password
|
||||
AuthService.change_password(
|
||||
user=g.current_user,
|
||||
current_password=data["current_password"],
|
||||
new_password=data["new_password"],
|
||||
)
|
||||
|
||||
return api_response(
|
||||
message="Password changed successfully",
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Validation failed",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details=e.messages,
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me/organizations", methods=["GET"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def get_my_organizations():
|
||||
"""
|
||||
Get all organizations current user is a member of, including the user's role.
|
||||
|
||||
Returns:
|
||||
200: List of organizations with role
|
||||
401: Not authenticated
|
||||
"""
|
||||
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": orgs,
|
||||
"count": len(orgs),
|
||||
},
|
||||
message="Organizations retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me/principals", methods=["GET"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def get_my_principals():
|
||||
"""Return all principals the current user can sign certificates for.
|
||||
|
||||
For each organization the user belongs to, returns:
|
||||
- Their effective principals (direct membership + via department)
|
||||
- Their role in that org (so the frontend can offer admin-mode selection)
|
||||
- All principals in the org (admin/owner only — so they can pick any)
|
||||
|
||||
Returns:
|
||||
200: {
|
||||
orgs: [{
|
||||
org_id, org_name, role,
|
||||
my_principals: [{id, name, description}],
|
||||
all_principals: [{id, name, description}] # populated for admin/owner only
|
||||
}]
|
||||
}
|
||||
"""
|
||||
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
|
||||
user_id = user.id
|
||||
|
||||
# Get all org memberships
|
||||
memberships = OrganizationMember.query.filter_by(
|
||||
user_id=user_id,
|
||||
).all()
|
||||
|
||||
orgs_result = []
|
||||
for membership in memberships:
|
||||
org = membership.organization
|
||||
if not org or org.deleted_at is not None:
|
||||
continue
|
||||
|
||||
role = membership.role
|
||||
is_admin = role in (OrganizationRole.ADMIN, OrganizationRole.OWNER)
|
||||
|
||||
# Collect the user's effective principals for this org
|
||||
# Track direct vs via-department separately
|
||||
direct_principal_ids = set()
|
||||
via_dept_principal_ids = set()
|
||||
|
||||
# Direct memberships
|
||||
direct = PrincipalMembership.query.filter_by(
|
||||
user_id=user_id,
|
||||
deleted_at=None,
|
||||
).all()
|
||||
for pm in direct:
|
||||
if pm.principal and pm.principal.organization_id == org.id and pm.principal.deleted_at is None:
|
||||
direct_principal_ids.add(pm.principal_id)
|
||||
|
||||
# Via department
|
||||
dept_memberships = DepartmentMembership.query.filter_by(
|
||||
user_id=user_id,
|
||||
deleted_at=None,
|
||||
).all()
|
||||
for dm in dept_memberships:
|
||||
if dm.department and dm.department.organization_id == org.id and dm.department.deleted_at is None:
|
||||
dept_principals = DepartmentPrincipal.query.filter_by(
|
||||
department_id=dm.department_id,
|
||||
deleted_at=None,
|
||||
).all()
|
||||
for dp in dept_principals:
|
||||
if dp.principal and dp.principal.deleted_at is None:
|
||||
via_dept_principal_ids.add(dp.principal_id)
|
||||
|
||||
effective_principal_ids = direct_principal_ids | via_dept_principal_ids
|
||||
|
||||
# Fetch principal objects
|
||||
my_principals = []
|
||||
if effective_principal_ids:
|
||||
my_p = Principal.query.filter(
|
||||
Principal.id.in_(list(effective_principal_ids)),
|
||||
Principal.deleted_at == None,
|
||||
).all()
|
||||
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 = []
|
||||
if is_admin:
|
||||
all_p = Principal.query.filter_by(
|
||||
organization_id=org.id,
|
||||
deleted_at=None,
|
||||
).all()
|
||||
all_principals = [{"id": p.id, "name": p.name, "description": p.description} for p in all_p]
|
||||
|
||||
orgs_result.append({
|
||||
"org_id": org.id,
|
||||
"org_name": org.name,
|
||||
"role": role.value if hasattr(role, "value") else role,
|
||||
"is_admin": is_admin,
|
||||
"my_principals": my_principals,
|
||||
"all_principals": all_principals,
|
||||
})
|
||||
|
||||
return api_response(
|
||||
data={"orgs": orgs_result},
|
||||
message="Principals retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
The caller must be an OWNER or ADMIN of at least one organization.
|
||||
Returns users that share an organization with the caller and where the
|
||||
caller holds admin/owner role in that organization.
|
||||
|
||||
Query params:
|
||||
q – optional search string (matched against name/email)
|
||||
page – page number (default 1)
|
||||
per_page – page size (default 50, max 200)
|
||||
"""
|
||||
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_
|
||||
|
||||
caller = g.current_user
|
||||
|
||||
# Find orgs where caller is admin/owner
|
||||
admin_memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == caller.id,
|
||||
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
|
||||
OrganizationMember.deleted_at == None,
|
||||
).all()
|
||||
|
||||
if not admin_memberships:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Admin or owner role required",
|
||||
status=403,
|
||||
error_type="AUTHORIZATION_ERROR",
|
||||
)
|
||||
|
||||
admin_org_ids = [m.organization_id for m in admin_memberships]
|
||||
|
||||
# Collect user IDs in those orgs
|
||||
member_rows = OrganizationMember.query.filter(
|
||||
OrganizationMember.organization_id.in_(admin_org_ids),
|
||||
OrganizationMember.deleted_at == None,
|
||||
).all()
|
||||
visible_user_ids = list({row.user_id for row in member_rows})
|
||||
|
||||
# Optional search
|
||||
q = request.args.get("q", "").strip()
|
||||
try:
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(200, max(1, int(request.args.get("per_page", 50))))
|
||||
except ValueError:
|
||||
page, per_page = 1, 50
|
||||
|
||||
query = _User.query.filter(
|
||||
_User.id.in_(visible_user_ids),
|
||||
_User.deleted_at == None,
|
||||
)
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
query = query.filter(or_(_User.email.ilike(like), _User.full_name.ilike(like)))
|
||||
|
||||
total = query.count()
|
||||
users = query.order_by(_User.email).offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
member_lookup: dict = {}
|
||||
for row in member_rows:
|
||||
if row.user_id not in member_lookup:
|
||||
member_lookup[row.user_id] = {
|
||||
"organization_id": row.organization_id,
|
||||
"role": row.role.value if hasattr(row.role, "value") else row.role,
|
||||
}
|
||||
|
||||
users_data = []
|
||||
for u in users:
|
||||
d = u.to_dict()
|
||||
m = member_lookup.get(u.id, {})
|
||||
d["org_role"] = m.get("role", "member")
|
||||
d["org_id"] = m.get("organization_id")
|
||||
users_data.append(d)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"users": users_data,
|
||||
"count": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
},
|
||||
message="Users retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@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.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
|
||||
|
||||
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}
|
||||
has_access = 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() is not None
|
||||
|
||||
if not has_access:
|
||||
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
ssh_keys = SSHKey.query.filter_by(user_id=user_id, deleted_at=None).all()
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"user": target.to_dict(),
|
||||
"ssh_keys": [k.to_dict() for k in ssh_keys],
|
||||
},
|
||||
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")
|
||||
|
||||
# ── Owner protection ──────────────────────────────────────────────────────
|
||||
# An org owner cannot be suspended until they transfer ownership.
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
owner_memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == target.id,
|
||||
OrganizationMember.role == OrganizationRole.OWNER,
|
||||
OrganizationMember.deleted_at == None,
|
||||
).all()
|
||||
if owner_memberships:
|
||||
org_names = [
|
||||
m.organization.name
|
||||
for m in owner_memberships
|
||||
if m.organization and not m.organization.deleted_at
|
||||
]
|
||||
return api_response(
|
||||
success=False,
|
||||
message=(
|
||||
f"Cannot suspend an organization owner. "
|
||||
f"{target.email} is the owner of: {', '.join(org_names)}. "
|
||||
"Transfer ownership to another member first."
|
||||
),
|
||||
status=403,
|
||||
error_type="OWNER_PROTECTION",
|
||||
)
|
||||
|
||||
if target.status in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_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 not in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_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",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/users/<user_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_hard_delete_user(user_id):
|
||||
"""Permanently delete a user and ALL associated data (hard delete, irreversible).
|
||||
|
||||
Required body: {"confirm": true}
|
||||
|
||||
Pre-conditions:
|
||||
- Caller is OWNER or ADMIN of a shared org with the target.
|
||||
- Cannot delete yourself.
|
||||
- Target must not be the OWNER of any active organization (transfer first).
|
||||
|
||||
Side-effects:
|
||||
- All active SSH certificates are revoked before deletion.
|
||||
- The user row and all cascaded rows are hard-deleted from the database.
|
||||
- An audit log entry is written by the *caller* (so it is not lost with the 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 gatehouse_app.utils.constants import UserStatus, AuditAction, OrganizationRole
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
|
||||
caller = g.current_user
|
||||
data = request.get_json() or {}
|
||||
|
||||
if not data.get("confirm"):
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Deletion requires explicit confirmation. Send {\"confirm\": true} to proceed.",
|
||||
status=400,
|
||||
error_type="CONFIRMATION_REQUIRED",
|
||||
)
|
||||
|
||||
target = _User.query.filter_by(id=user_id).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 delete your own account via this endpoint.",
|
||||
status=400,
|
||||
error_type="BAD_REQUEST",
|
||||
)
|
||||
|
||||
# Caller must be OWNER/ADMIN of a shared org.
|
||||
# Include soft-deleted memberships so that already-soft-deleted users can
|
||||
# still be hard-deleted by an admin who shared an org with them.
|
||||
target_org_ids = {m.organization_id for m in target.organization_memberships}
|
||||
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")
|
||||
|
||||
# Block deletion if target is an org owner — they must transfer first
|
||||
owner_memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == target.id,
|
||||
OrganizationMember.role == OrganizationRole.OWNER,
|
||||
OrganizationMember.deleted_at == None,
|
||||
).all()
|
||||
if owner_memberships:
|
||||
org_names = [
|
||||
m.organization.name
|
||||
for m in owner_memberships
|
||||
if m.organization and not m.organization.deleted_at
|
||||
]
|
||||
return api_response(
|
||||
success=False,
|
||||
message=(
|
||||
f"Cannot delete an organization owner. "
|
||||
f"{target.email} is the owner of: {', '.join(org_names)}. "
|
||||
"Transfer ownership to another member first."
|
||||
),
|
||||
status=403,
|
||||
error_type="OWNER_PROTECTION",
|
||||
)
|
||||
|
||||
# ── Collect counts for audit metadata ────────────────────────────────────
|
||||
from gatehouse_app.models.ssh_ca.ssh_key import SSHKey
|
||||
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate, CertificateStatus
|
||||
|
||||
ssh_key_count = SSHKey.query.filter_by(user_id=target.id, deleted_at=None).count()
|
||||
active_cert_count = SSHCertificate.query.filter_by(
|
||||
user_id=target.id, revoked=False
|
||||
).filter(SSHCertificate.deleted_at == None).count()
|
||||
|
||||
# ── Revoke all active SSH certificates before deletion ───────────────────
|
||||
active_certs = SSHCertificate.query.filter_by(
|
||||
user_id=target.id, revoked=False
|
||||
).filter(SSHCertificate.deleted_at == None).all()
|
||||
for cert in active_certs:
|
||||
try:
|
||||
cert.revoke("account_deleted")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if active_certs:
|
||||
try:
|
||||
_db.session.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Hard delete ───────────────────────────────────────────────────────────
|
||||
target_email = target.email # capture before deletion
|
||||
target_id_str = str(target.id)
|
||||
|
||||
try:
|
||||
_db.session.delete(target) # cascades to all child tables
|
||||
_db.session.flush()
|
||||
except Exception as exc:
|
||||
_db.session.rollback()
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Hard delete failed for {target_id_str}: {exc}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Failed to delete user account. Please try again.",
|
||||
status=500,
|
||||
error_type="SERVER_ERROR",
|
||||
)
|
||||
|
||||
# ── Audit log (written as the caller so it survives the deletion) ─────────
|
||||
AuditService.log_action(
|
||||
action=AuditAction.USER_HARD_DELETE,
|
||||
user_id=caller.id,
|
||||
organization_id=admin_in_shared_org.organization_id,
|
||||
resource_type="user",
|
||||
resource_id=target_id_str,
|
||||
description=f"Admin permanently deleted user account: {target_email}",
|
||||
metadata={
|
||||
"deleted_user_id": target_id_str,
|
||||
"deleted_user_email": target_email,
|
||||
"ssh_keys_deleted": ssh_key_count,
|
||||
"certs_revoked": active_cert_count,
|
||||
},
|
||||
)
|
||||
|
||||
_db.session.commit()
|
||||
|
||||
return api_response(
|
||||
message=f"User account {target_email} has been permanently deleted.",
|
||||
data={
|
||||
"deleted_user_id": target_id_str,
|
||||
"deleted_user_email": target_email,
|
||||
"ssh_keys_deleted": ssh_key_count,
|
||||
"certs_revoked": active_cert_count,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Users blueprint subpackage."""
|
||||
from gatehouse_app.api.v1.users import me, admin
|
||||
@@ -0,0 +1,842 @@
|
||||
"""Admin user management endpoints."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from flask import g, request
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required, full_access_required
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_admin_access(caller, target):
|
||||
"""Return the first OrganizationMember row where caller is OWNER/ADMIN in a shared org with target, or None.
|
||||
|
||||
Works even when the target user has been soft-deleted, as long as the
|
||||
OrganizationMember row is still active (deleted_at IS NULL).
|
||||
"""
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
|
||||
# Query directly — don't rely on the ORM relationship which may be stale
|
||||
# when the user row is soft-deleted.
|
||||
target_memberships = OrganizationMember.query.filter_by(
|
||||
user_id=target.id, deleted_at=None
|
||||
).all()
|
||||
target_org_ids = {m.organization_id for m in target_memberships}
|
||||
if not target_org_ids:
|
||||
return None
|
||||
return 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()
|
||||
|
||||
|
||||
def _find_user_for_admin(user_id):
|
||||
"""Look up a user by ID for admin use.
|
||||
|
||||
Returns the User row whether or not it has been soft-deleted, so that
|
||||
admins can manage accounts that the user themselves deleted but that still
|
||||
have an active org membership.
|
||||
"""
|
||||
from gatehouse_app.models.user.user import User as _User
|
||||
return _User.query.filter_by(id=user_id).first()
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/users", methods=["GET"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_list_users():
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.user.user import User as _User
|
||||
from sqlalchemy import or_
|
||||
|
||||
caller = g.current_user
|
||||
|
||||
admin_memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == caller.id,
|
||||
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
|
||||
OrganizationMember.deleted_at == None,
|
||||
).all()
|
||||
|
||||
if not admin_memberships:
|
||||
return api_response(success=False, message="Admin or owner role required", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
admin_org_ids = [m.organization_id for m in admin_memberships]
|
||||
|
||||
member_rows = OrganizationMember.query.filter(
|
||||
OrganizationMember.organization_id.in_(admin_org_ids),
|
||||
OrganizationMember.deleted_at == None,
|
||||
).all()
|
||||
visible_user_ids = list({row.user_id for row in member_rows})
|
||||
|
||||
q = request.args.get("q", "").strip()
|
||||
try:
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(200, max(1, int(request.args.get("per_page", 50))))
|
||||
except ValueError:
|
||||
page, per_page = 1, 50
|
||||
|
||||
query = _User.query.filter(_User.id.in_(visible_user_ids))
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
query = query.filter(or_(_User.email.ilike(like), _User.full_name.ilike(like)))
|
||||
|
||||
total = query.count()
|
||||
users = query.order_by(_User.email).offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
member_lookup = {}
|
||||
for row in member_rows:
|
||||
if row.user_id not in member_lookup:
|
||||
member_lookup[row.user_id] = {
|
||||
"organization_id": row.organization_id,
|
||||
"role": row.role.value if hasattr(row.role, "value") else row.role,
|
||||
}
|
||||
|
||||
users_data = []
|
||||
for u in users:
|
||||
d = u.to_dict()
|
||||
m = member_lookup.get(u.id, {})
|
||||
d["org_role"] = m.get("role", "member")
|
||||
d["org_id"] = m.get("organization_id")
|
||||
d["is_deleted"] = u.deleted_at is not None
|
||||
users_data.append(d)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"users": users_data, "count": total,
|
||||
"page": page, "per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
},
|
||||
message="Users retrieved successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/users/<user_id>", methods=["GET"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_get_user(user_id):
|
||||
from gatehouse_app.models.ssh_ca.ssh_key import SSHKey
|
||||
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.utils.constants import AuthMethodType
|
||||
|
||||
caller = g.current_user
|
||||
target = _find_user_for_admin(user_id)
|
||||
if not target:
|
||||
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
if not _get_admin_access(caller, target):
|
||||
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
OAUTH_TYPES = {
|
||||
AuthMethodType.GOOGLE, AuthMethodType.GITHUB,
|
||||
AuthMethodType.MICROSOFT, AuthMethodType.OIDC,
|
||||
}
|
||||
auth_methods = AuthenticationMethod.query.filter_by(user_id=user_id, deleted_at=None).all()
|
||||
|
||||
has_password = any(
|
||||
m.method_type == AuthMethodType.PASSWORD and m.password_hash
|
||||
for m in auth_methods
|
||||
)
|
||||
totp_method = next(
|
||||
(m for m in auth_methods if m.method_type == AuthMethodType.TOTP and m.verified),
|
||||
None,
|
||||
)
|
||||
totp_enabled = totp_method is not None
|
||||
linked_providers = [
|
||||
{
|
||||
"provider": m.method_type.value,
|
||||
"email": (m.provider_data or {}).get("email"),
|
||||
"name": (m.provider_data or {}).get("name"),
|
||||
"connected_since": m.created_at.isoformat() if m.created_at else None,
|
||||
}
|
||||
for m in auth_methods if m.method_type in OAUTH_TYPES
|
||||
]
|
||||
|
||||
user_dict = target.to_dict()
|
||||
user_dict["has_password"] = has_password
|
||||
user_dict["totp_enabled"] = totp_enabled
|
||||
user_dict["totp_enabled_at"] = (
|
||||
totp_method.totp_verified_at.isoformat()
|
||||
if totp_method and totp_method.totp_verified_at
|
||||
else (totp_method.created_at.isoformat() if totp_method and totp_method.created_at else None)
|
||||
)
|
||||
user_dict["linked_providers"] = linked_providers
|
||||
user_dict["is_deleted"] = target.deleted_at is not None
|
||||
|
||||
ssh_keys = SSHKey.query.filter_by(user_id=user_id, deleted_at=None).all()
|
||||
return api_response(
|
||||
data={"user": user_dict, "ssh_keys": [k.to_dict() for k in ssh_keys]},
|
||||
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):
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.extensions import db as _db
|
||||
from gatehouse_app.utils.constants import UserStatus, AuditAction, OrganizationRole
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
|
||||
caller = g.current_user
|
||||
target = _find_user_for_admin(user_id)
|
||||
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")
|
||||
|
||||
admin_in_shared_org = _get_admin_access(caller, target)
|
||||
if not admin_in_shared_org:
|
||||
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
owner_memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == target.id,
|
||||
OrganizationMember.role == OrganizationRole.OWNER,
|
||||
OrganizationMember.deleted_at == None,
|
||||
).all()
|
||||
if owner_memberships:
|
||||
org_names = [m.organization.name for m in owner_memberships if m.organization and not m.organization.deleted_at]
|
||||
return api_response(
|
||||
success=False,
|
||||
message=(
|
||||
f"Cannot suspend an organization owner. {target.email} is the owner of: {', '.join(org_names)}. "
|
||||
"Transfer ownership to another member first."
|
||||
),
|
||||
status=403, error_type="OWNER_PROTECTION",
|
||||
)
|
||||
|
||||
if target.status in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_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):
|
||||
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 = _find_user_for_admin(user_id)
|
||||
if not target:
|
||||
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
admin_in_shared_org = _get_admin_access(caller, target)
|
||||
if not admin_in_shared_org:
|
||||
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
if target.status not in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_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("/admin/users/<user_id>/verify-email", methods=["POST"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_verify_user_email(user_id):
|
||||
from gatehouse_app.models.auth.email_verification_token import EmailVerificationToken
|
||||
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 = _find_user_for_admin(user_id)
|
||||
if not target:
|
||||
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
admin_in_shared_org = _get_admin_access(caller, target)
|
||||
if not admin_in_shared_org:
|
||||
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
target.email_verified = True
|
||||
was_inactive = target.status == UserStatus.INACTIVE
|
||||
if was_inactive:
|
||||
target.status = UserStatus.ACTIVE
|
||||
|
||||
EmailVerificationToken.query.filter_by(user_id=target.id, used_at=None).delete()
|
||||
_db.session.commit()
|
||||
|
||||
AuditService.log_action(
|
||||
action=AuditAction.ADMIN_EMAIL_VERIFY,
|
||||
user_id=caller.id,
|
||||
organization_id=admin_in_shared_org.organization_id,
|
||||
resource_type="user", resource_id=str(target.id),
|
||||
description=f"Admin force-verified email for {target.email}",
|
||||
metadata={"target_user_id": str(target.id), "target_email": target.email, "was_inactive": was_inactive},
|
||||
)
|
||||
return api_response(data={"user": target.to_dict()}, message="Email verified and account activated successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/users/<user_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_hard_delete_user(user_id):
|
||||
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
|
||||
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate
|
||||
from gatehouse_app.models.ssh_ca.certificate_audit_log import CertificateAuditLog
|
||||
from gatehouse_app.models.auth.authentication_method import OAuthState
|
||||
from gatehouse_app.models.security.organization_security_policy import OrganizationSecurityPolicy
|
||||
from gatehouse_app.extensions import db as _db
|
||||
from gatehouse_app.utils.constants import AuditAction, OrganizationRole
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
|
||||
caller = g.current_user
|
||||
data = request.get_json() or {}
|
||||
|
||||
if not data.get("confirm"):
|
||||
return api_response(
|
||||
success=False,
|
||||
message='Deletion requires explicit confirmation. Send {"confirm": true} to proceed.',
|
||||
status=400, error_type="CONFIRMATION_REQUIRED",
|
||||
)
|
||||
|
||||
target = _User.query.filter_by(id=user_id).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 delete your own account via this endpoint.", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
target_org_ids = {m.organization_id for m in target.organization_memberships}
|
||||
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")
|
||||
|
||||
owner_memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == target.id,
|
||||
OrganizationMember.role == OrganizationRole.OWNER,
|
||||
OrganizationMember.deleted_at == None,
|
||||
).all()
|
||||
if owner_memberships:
|
||||
org_names = [m.organization.name for m in owner_memberships if m.organization and not m.organization.deleted_at]
|
||||
return api_response(
|
||||
success=False,
|
||||
message=(
|
||||
f"Cannot delete an organization owner. {target.email} is the owner of: {', '.join(org_names)}. "
|
||||
"Transfer ownership to another member first."
|
||||
),
|
||||
status=403, error_type="OWNER_PROTECTION",
|
||||
)
|
||||
|
||||
ssh_key_count = SSHKey.query.filter_by(user_id=target.id, deleted_at=None).count()
|
||||
active_certs = SSHCertificate.query.filter_by(user_id=target.id, revoked=False).filter(SSHCertificate.deleted_at == None).all()
|
||||
active_cert_count = len(active_certs)
|
||||
|
||||
for cert in active_certs:
|
||||
try:
|
||||
cert.revoke("account_deleted")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if active_certs:
|
||||
try:
|
||||
_db.session.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
target_email = target.email
|
||||
target_id_str = str(target.id)
|
||||
|
||||
try:
|
||||
# NULL out FK references that don't cascade on delete so the
|
||||
# session.delete() below doesn't hit FK constraint violations.
|
||||
|
||||
# org_invite_tokens.invited_by_id — SET NULL is already on the FK column,
|
||||
# but OrganizationMember.invited_by_id has no ondelete clause.
|
||||
_db.session.execute(
|
||||
_db.text("UPDATE organization_members SET invited_by_id = NULL WHERE invited_by_id = :uid"),
|
||||
{"uid": target_id_str},
|
||||
)
|
||||
|
||||
# certificate_audit_logs.user_id — nullable, no ondelete clause.
|
||||
CertificateAuditLog.query.filter_by(user_id=target_id_str).update(
|
||||
{"user_id": None}, synchronize_session=False
|
||||
)
|
||||
|
||||
# organization_security_policies.updated_by_user_id — nullable, no ondelete.
|
||||
OrganizationSecurityPolicy.query.filter_by(updated_by_user_id=target_id_str).update(
|
||||
{"updated_by_user_id": None}, synchronize_session=False
|
||||
)
|
||||
|
||||
# oauth_states.user_id — nullable, no ondelete.
|
||||
OAuthState.query.filter_by(user_id=target_id_str).delete(synchronize_session=False)
|
||||
|
||||
_db.session.delete(target)
|
||||
_db.session.flush()
|
||||
except Exception as exc:
|
||||
_db.session.rollback()
|
||||
_logger.error(f"Hard delete failed for {target_id_str}: {exc}")
|
||||
return api_response(success=False, message="Failed to delete user account. Please try again.", status=500, error_type="SERVER_ERROR")
|
||||
|
||||
AuditService.log_action(
|
||||
action=AuditAction.USER_HARD_DELETE,
|
||||
user_id=caller.id,
|
||||
organization_id=admin_in_shared_org.organization_id,
|
||||
resource_type="user", resource_id=target_id_str,
|
||||
description=f"Admin permanently deleted user account: {target_email}",
|
||||
metadata={
|
||||
"deleted_user_id": target_id_str, "deleted_user_email": target_email,
|
||||
"ssh_keys_deleted": ssh_key_count, "certs_revoked": active_cert_count,
|
||||
},
|
||||
)
|
||||
|
||||
_db.session.commit()
|
||||
return api_response(
|
||||
message=f"User account {target_email} has been permanently deleted.",
|
||||
data={"deleted_user_id": target_id_str, "deleted_user_email": target_email,
|
||||
"ssh_keys_deleted": ssh_key_count, "certs_revoked": active_cert_count},
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/users/<user_id>/restore", methods=["POST"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_restore_user(user_id):
|
||||
"""Restore a soft-deleted user account.
|
||||
|
||||
A user who self-deleted but still has an active org membership (and active
|
||||
auth methods) can be restored by an admin. Clearing ``deleted_at`` makes
|
||||
the account usable again without touching any auth methods.
|
||||
"""
|
||||
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 = _find_user_for_admin(user_id)
|
||||
if not target:
|
||||
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
if not _get_admin_access(caller, target):
|
||||
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
if target.deleted_at is None:
|
||||
return api_response(
|
||||
success=False, message="User account is not deleted — nothing to restore.",
|
||||
status=409, error_type="CONFLICT",
|
||||
)
|
||||
|
||||
target.deleted_at = None
|
||||
if target.status not in (UserStatus.ACTIVE, UserStatus.INACTIVE):
|
||||
target.status = UserStatus.ACTIVE
|
||||
_db.session.commit()
|
||||
|
||||
AuditService.log_action(
|
||||
action=AuditAction.USER_UNSUSPEND, # closest existing action
|
||||
user_id=caller.id,
|
||||
organization_id=_get_admin_access(caller, target).organization_id,
|
||||
resource_type="user", resource_id=str(target.id),
|
||||
description=f"Admin restored soft-deleted user account {target.email}",
|
||||
metadata={"target_user_id": str(target.id), "target_email": target.email, "admin_email": caller.email},
|
||||
)
|
||||
return api_response(
|
||||
data={"user": target.to_dict()},
|
||||
message=f"User account {target.email} has been restored successfully.",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/users/<user_id>/mfa", methods=["GET"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_get_user_mfa(user_id):
|
||||
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.utils.constants import AuthMethodType
|
||||
|
||||
caller = g.current_user
|
||||
target = _find_user_for_admin(user_id)
|
||||
if not target:
|
||||
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
if not _get_admin_access(caller, target):
|
||||
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
mfa_methods = []
|
||||
|
||||
totp_method = AuthenticationMethod.query.filter_by(
|
||||
user_id=user_id, method_type=AuthMethodType.TOTP, verified=True, deleted_at=None,
|
||||
).first()
|
||||
if totp_method:
|
||||
enabled_at = (
|
||||
totp_method.totp_verified_at.isoformat()
|
||||
if totp_method.totp_verified_at
|
||||
else (totp_method.created_at.isoformat() if totp_method.created_at else None)
|
||||
)
|
||||
mfa_methods.append({
|
||||
"id": str(totp_method.id),
|
||||
"type": "totp",
|
||||
"name": "Authenticator app (TOTP)",
|
||||
"verified": totp_method.verified,
|
||||
"enabled_at": enabled_at,
|
||||
"created_at": totp_method.created_at.isoformat() if totp_method.created_at else None,
|
||||
"last_used_at": totp_method.last_used_at.isoformat() if totp_method.last_used_at else None,
|
||||
})
|
||||
|
||||
webauthn_method = AuthenticationMethod.query.filter_by(
|
||||
user_id=user_id, method_type=AuthMethodType.WEBAUTHN, deleted_at=None,
|
||||
).first()
|
||||
if webauthn_method and webauthn_method.provider_data:
|
||||
for cred in webauthn_method.provider_data.get("credentials", []):
|
||||
if not cred.get("deleted_at"):
|
||||
mfa_methods.append({
|
||||
"id": cred.get("id") or cred.get("credential_id"),
|
||||
"type": "webauthn",
|
||||
"name": cred.get("name") or cred.get("device_type") or "Passkey",
|
||||
"device_type": cred.get("device_type", ""),
|
||||
"transports": cred.get("transports", []),
|
||||
"verified": True,
|
||||
"created_at": cred.get("created_at"),
|
||||
"last_used_at": cred.get("last_used_at"),
|
||||
})
|
||||
|
||||
return api_response(
|
||||
data={"user": {"id": str(target.id), "email": target.email, "full_name": target.full_name}, "mfa_methods": mfa_methods},
|
||||
message="MFA methods retrieved",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/users/<user_id>/mfa/<method_type>", methods=["DELETE"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_remove_user_mfa(user_id, method_type):
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyCompliance
|
||||
from gatehouse_app.models.security.organization_security_policy import OrganizationSecurityPolicy
|
||||
from gatehouse_app.extensions import db as _db
|
||||
from gatehouse_app.utils.constants import AuthMethodType, AuditAction, MfaComplianceStatus, UserStatus as _UserStatus
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
from datetime import timedelta
|
||||
|
||||
caller = g.current_user
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
VALID_TYPES = {"totp", "webauthn", "all"}
|
||||
method_type = method_type.lower().strip()
|
||||
if method_type not in VALID_TYPES:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=f"Invalid method_type '{method_type}'. Must be one of: {', '.join(sorted(VALID_TYPES))}",
|
||||
status=400, error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
target = _find_user_for_admin(user_id)
|
||||
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="Use the regular MFA management endpoints to modify your own MFA methods.", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
admin_in_shared_org = _get_admin_access(caller, target)
|
||||
if not admin_in_shared_org:
|
||||
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
removed = []
|
||||
|
||||
if method_type in ("totp", "all"):
|
||||
totp_methods = AuthenticationMethod.query.filter_by(user_id=user_id, method_type=AuthMethodType.TOTP, deleted_at=None).all()
|
||||
if totp_methods:
|
||||
for totp_method in totp_methods:
|
||||
totp_method.deleted_at = now
|
||||
totp_method.totp_secret = None
|
||||
totp_method.totp_backup_codes = None
|
||||
totp_method.totp_verified_at = None
|
||||
_db.session.add(totp_method)
|
||||
removed.append("totp")
|
||||
elif method_type == "totp":
|
||||
return api_response(success=False, message="User does not have TOTP configured", status=404, error_type="NOT_FOUND")
|
||||
|
||||
if method_type in ("webauthn", "all"):
|
||||
webauthn_method = AuthenticationMethod.query.filter_by(user_id=user_id, method_type=AuthMethodType.WEBAUTHN, deleted_at=None).first()
|
||||
if webauthn_method:
|
||||
credential_id = request.args.get("credential_id")
|
||||
if credential_id:
|
||||
credentials = (webauthn_method.provider_data or {}).get("credentials", [])
|
||||
found = False
|
||||
new_credentials = []
|
||||
for cred in credentials:
|
||||
cid = cred.get("id") or cred.get("credential_id")
|
||||
if cid == credential_id and not cred.get("deleted_at"):
|
||||
cred["deleted_at"] = now.isoformat()
|
||||
found = True
|
||||
removed.append(f"webauthn:{credential_id[:16]}")
|
||||
new_credentials.append(cred)
|
||||
if not found:
|
||||
return api_response(success=False, message=f"WebAuthn credential '{credential_id}' not found", status=404, error_type="NOT_FOUND")
|
||||
active_remaining = sum(1 for c in new_credentials if not c.get("deleted_at"))
|
||||
if active_remaining == 0:
|
||||
webauthn_method.deleted_at = now
|
||||
else:
|
||||
if webauthn_method.provider_data is None:
|
||||
webauthn_method.provider_data = {}
|
||||
webauthn_method.provider_data["credentials"] = new_credentials
|
||||
flag_modified(webauthn_method, "provider_data")
|
||||
_db.session.add(webauthn_method)
|
||||
else:
|
||||
webauthn_method.deleted_at = now
|
||||
if webauthn_method.provider_data:
|
||||
for cred in webauthn_method.provider_data.get("credentials", []):
|
||||
cred["deleted_at"] = now.isoformat()
|
||||
flag_modified(webauthn_method, "provider_data")
|
||||
_db.session.add(webauthn_method)
|
||||
removed.append("webauthn")
|
||||
elif method_type == "webauthn":
|
||||
return api_response(success=False, message="User does not have any WebAuthn passkeys configured", status=404, error_type="NOT_FOUND")
|
||||
|
||||
if not removed:
|
||||
return api_response(success=False, message="No MFA methods found to remove", status=404, error_type="NOT_FOUND")
|
||||
|
||||
compliance_records = MfaPolicyCompliance.query.filter_by(user_id=user_id).filter(MfaPolicyCompliance.deleted_at == None).all()
|
||||
for record in compliance_records:
|
||||
if record.status in (MfaComplianceStatus.COMPLIANT, MfaComplianceStatus.PAST_DUE, MfaComplianceStatus.SUSPENDED):
|
||||
record.status = MfaComplianceStatus.IN_GRACE
|
||||
record.compliant_at = None
|
||||
record.suspended_at = None
|
||||
org_policy = OrganizationSecurityPolicy.query.filter_by(organization_id=record.organization_id, deleted_at=None).first()
|
||||
grace_days = org_policy.mfa_grace_period_days if org_policy else 14
|
||||
record.deadline_at = now + timedelta(days=grace_days)
|
||||
record.applied_at = now
|
||||
record.notification_count = 0
|
||||
record.last_notified_at = None
|
||||
|
||||
if target.status == _UserStatus.COMPLIANCE_SUSPENDED:
|
||||
target.status = _UserStatus.ACTIVE
|
||||
_db.session.add(target)
|
||||
|
||||
_db.session.commit()
|
||||
|
||||
AuditService.log_action(
|
||||
action=AuditAction.ADMIN_MFA_REMOVE,
|
||||
user_id=caller.id,
|
||||
organization_id=admin_in_shared_org.organization_id,
|
||||
resource_type="user", resource_id=str(target.id),
|
||||
description=f"Admin removed MFA method(s) [{', '.join(removed)}] for user {target.email}",
|
||||
metadata={"target_user_id": str(target.id), "target_user_email": target.email, "removed_methods": removed, "admin_email": caller.email},
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={"removed_methods": removed, "removed_count": len(removed), "user": {"id": str(target.id), "email": target.email}},
|
||||
message=f"Removed {len(removed)} MFA method(s) for {target.email}",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/users/<user_id>/password", methods=["POST"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_set_user_password(user_id):
|
||||
from flask_bcrypt import Bcrypt
|
||||
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.extensions import db as _db
|
||||
from gatehouse_app.utils.constants import AuthMethodType, AuditAction
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
|
||||
caller = g.current_user
|
||||
data = request.get_json() or {}
|
||||
new_password = data.get("password", "").strip()
|
||||
|
||||
if len(new_password) < 8:
|
||||
return api_response(success=False, message="Password must be at least 8 characters", status=400, error_type="VALIDATION_ERROR")
|
||||
|
||||
target = _find_user_for_admin(user_id)
|
||||
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="Use the regular password change endpoint to update your own password.", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
admin_in_shared_org = _get_admin_access(caller, target)
|
||||
if not admin_in_shared_org:
|
||||
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
bcrypt = Bcrypt()
|
||||
password_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
pw_method = AuthenticationMethod.query.filter_by(user_id=user_id, method_type=AuthMethodType.PASSWORD, deleted_at=None).first()
|
||||
method_was_created = False
|
||||
if pw_method:
|
||||
pw_method.password_hash = password_hash
|
||||
pw_method.updated_at = now
|
||||
_db.session.add(pw_method)
|
||||
action_description = f"Admin reset password for user {target.email}"
|
||||
else:
|
||||
method_was_created = True
|
||||
pw_method = AuthenticationMethod(
|
||||
user_id=user_id, method_type=AuthMethodType.PASSWORD,
|
||||
password_hash=password_hash, verified=True, created_at=now,
|
||||
)
|
||||
_db.session.add(pw_method)
|
||||
action_description = f"Admin set password for user {target.email} (new method created)"
|
||||
|
||||
_db.session.commit()
|
||||
|
||||
AuditService.log_action(
|
||||
action=AuditAction.ADMIN_PASSWORD_SET,
|
||||
user_id=caller.id,
|
||||
organization_id=admin_in_shared_org.organization_id,
|
||||
resource_type="user", resource_id=str(target.id),
|
||||
description=action_description,
|
||||
metadata={"target_user_id": str(target.id), "target_user_email": target.email, "admin_email": caller.email, "method_created": method_was_created},
|
||||
)
|
||||
return api_response(data={"user": {"id": str(target.id), "email": target.email}}, message=f"Password updated for {target.email}")
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/users/<user_id>/linked-accounts", methods=["GET"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_get_user_linked_accounts(user_id):
|
||||
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.utils.constants import AuthMethodType
|
||||
|
||||
caller = g.current_user
|
||||
target = _find_user_for_admin(user_id)
|
||||
if not target:
|
||||
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
|
||||
|
||||
if not _get_admin_access(caller, target):
|
||||
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
OAUTH_TYPES = {AuthMethodType.GOOGLE, AuthMethodType.GITHUB, AuthMethodType.MICROSOFT, AuthMethodType.OIDC}
|
||||
|
||||
oauth_methods = AuthenticationMethod.query.filter(
|
||||
AuthenticationMethod.user_id == user_id,
|
||||
AuthenticationMethod.method_type.in_(OAUTH_TYPES),
|
||||
AuthenticationMethod.deleted_at == None,
|
||||
).all()
|
||||
|
||||
linked_accounts = []
|
||||
for method in oauth_methods:
|
||||
pd = method.provider_data or {}
|
||||
connected_since = method.created_at.isoformat() if method.created_at else None
|
||||
linked_accounts.append({
|
||||
"id": str(method.id),
|
||||
"provider_type": method.method_type.value,
|
||||
"email": pd.get("email"),
|
||||
"name": pd.get("name"),
|
||||
"provider_user_id": method.provider_user_id,
|
||||
# both names so old and new clients both work
|
||||
"linked_at": connected_since,
|
||||
"connected_since": connected_since,
|
||||
"verified": method.verified,
|
||||
})
|
||||
|
||||
all_active_methods = AuthenticationMethod.query.filter_by(user_id=user_id, deleted_at=None).count()
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"user": {"id": str(target.id), "email": target.email, "full_name": target.full_name},
|
||||
"linked_accounts": linked_accounts,
|
||||
"total_auth_methods": all_active_methods,
|
||||
},
|
||||
message="Linked accounts retrieved",
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/admin/users/<user_id>/linked-accounts/<provider>", methods=["DELETE"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def admin_unlink_user_provider(user_id, provider):
|
||||
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.extensions import db as _db
|
||||
from gatehouse_app.utils.constants import AuthMethodType, AuditAction
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
|
||||
caller = g.current_user
|
||||
|
||||
OAUTH_TYPES = {AuthMethodType.GOOGLE, AuthMethodType.GITHUB, AuthMethodType.MICROSOFT, AuthMethodType.OIDC}
|
||||
PROVIDER_MAP = {t.value: t for t in OAUTH_TYPES}
|
||||
|
||||
target = _find_user_for_admin(user_id)
|
||||
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="Use the regular account settings to unlink your own providers.", status=400, error_type="BAD_REQUEST")
|
||||
|
||||
admin_in_shared_org = _get_admin_access(caller, target)
|
||||
if not admin_in_shared_org:
|
||||
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
|
||||
|
||||
provider_lower = provider.lower().strip()
|
||||
method_to_unlink = None
|
||||
if provider_lower in PROVIDER_MAP:
|
||||
method_to_unlink = AuthenticationMethod.query.filter_by(
|
||||
user_id=user_id, method_type=PROVIDER_MAP[provider_lower], deleted_at=None,
|
||||
).first()
|
||||
else:
|
||||
method_to_unlink = AuthenticationMethod.query.filter(
|
||||
AuthenticationMethod.id == provider,
|
||||
AuthenticationMethod.user_id == user_id,
|
||||
AuthenticationMethod.method_type.in_(OAUTH_TYPES),
|
||||
AuthenticationMethod.deleted_at == None,
|
||||
).first()
|
||||
|
||||
if not method_to_unlink:
|
||||
return api_response(success=False, message=f"Provider '{provider}' is not linked to this user's account", status=404, error_type="NOT_FOUND")
|
||||
|
||||
all_active = AuthenticationMethod.query.filter_by(user_id=user_id, deleted_at=None).all()
|
||||
remaining = [m for m in all_active if m.id != method_to_unlink.id]
|
||||
has_password_remaining = any(m.method_type == AuthMethodType.PASSWORD and m.password_hash for m in remaining)
|
||||
has_other_oauth_remaining = any(m.method_type in OAUTH_TYPES for m in remaining)
|
||||
|
||||
if not has_password_remaining and not has_other_oauth_remaining:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Cannot unlink this provider — it is the user's only sign-in method. Ensure the user has a password or another linked provider before unlinking.",
|
||||
status=400, error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
provider_name = method_to_unlink.method_type.value
|
||||
method_to_unlink.deleted_at = now
|
||||
_db.session.add(method_to_unlink)
|
||||
_db.session.commit()
|
||||
|
||||
AuditService.log_action(
|
||||
action=AuditAction.ADMIN_OAUTH_UNLINK,
|
||||
user_id=caller.id,
|
||||
organization_id=admin_in_shared_org.organization_id,
|
||||
resource_type="user", resource_id=str(target.id),
|
||||
description=f"Admin unlinked {provider_name} OAuth provider from user {target.email}",
|
||||
metadata={"target_user_id": str(target.id), "target_user_email": target.email, "provider": provider_name, "admin_email": caller.email},
|
||||
)
|
||||
return api_response(
|
||||
data={"provider": provider_name, "user": {"id": str(target.id), "email": target.email}},
|
||||
message=f"Successfully unlinked {provider_name} from {target.email}",
|
||||
)
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Current user (self-service) endpoints."""
|
||||
from flask import g, request
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.utils.decorators import login_required, full_access_required
|
||||
from gatehouse_app.schemas.user_schema import UserUpdateSchema, ChangePasswordSchema
|
||||
from gatehouse_app.services.user_service import UserService
|
||||
from gatehouse_app.services.auth_service import AuthService
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me", methods=["GET"])
|
||||
@login_required
|
||||
def get_me():
|
||||
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.utils.constants import AuthMethodType
|
||||
|
||||
user = g.current_user
|
||||
user_dict = user.to_dict()
|
||||
|
||||
OAUTH_TYPES = {
|
||||
AuthMethodType.GOOGLE, AuthMethodType.GITHUB,
|
||||
AuthMethodType.MICROSOFT, AuthMethodType.OIDC,
|
||||
}
|
||||
auth_methods = AuthenticationMethod.query.filter_by(user_id=user.id, deleted_at=None).all()
|
||||
|
||||
has_password = any(m.method_type == AuthMethodType.PASSWORD and m.password_hash for m in auth_methods)
|
||||
totp_enabled = any(m.method_type == AuthMethodType.TOTP and m.verified for m in auth_methods)
|
||||
linked_providers = [m.method_type.value for m in auth_methods if m.method_type in OAUTH_TYPES]
|
||||
|
||||
user_dict["has_password"] = has_password
|
||||
user_dict["totp_enabled"] = totp_enabled
|
||||
user_dict["linked_providers"] = linked_providers
|
||||
|
||||
return api_response(data={"user": user_dict}, message="User profile retrieved successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me", methods=["PATCH"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def update_me():
|
||||
try:
|
||||
schema = UserUpdateSchema()
|
||||
data = schema.load(request.json)
|
||||
user = UserService.update_user(g.current_user, **data)
|
||||
return api_response(data={"user": user.to_dict()}, message="Profile updated successfully")
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me", methods=["DELETE"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def delete_me():
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
from gatehouse_app.services.organization_service import OrganizationService
|
||||
|
||||
user = g.current_user
|
||||
|
||||
owned_memberships = OrganizationMember.query.filter_by(
|
||||
user_id=user.id, role=OrganizationRole.OWNER, deleted_at=None,
|
||||
).all()
|
||||
|
||||
transfer_needed = []
|
||||
auto_delete = []
|
||||
|
||||
for membership in owned_memberships:
|
||||
org = membership.organization
|
||||
if org.deleted_at is not None:
|
||||
continue
|
||||
if org.get_member_count() > 1:
|
||||
transfer_needed.append(org.name)
|
||||
else:
|
||||
auto_delete.append(org)
|
||||
|
||||
if transfer_needed:
|
||||
names = ", ".join(f'"{n}"' for n in transfer_needed)
|
||||
return api_response(
|
||||
success=False,
|
||||
message=(
|
||||
f"You are the owner of {len(transfer_needed)} organization"
|
||||
f"{'s' if len(transfer_needed) > 1 else ''} that still "
|
||||
f"{'have' if len(transfer_needed) > 1 else 'has'} other members "
|
||||
f"({names}). Transfer ownership to another member first."
|
||||
),
|
||||
status=409,
|
||||
error_type="USER_IS_SOLE_OWNER",
|
||||
error_details={"transfer_ownership": transfer_needed},
|
||||
)
|
||||
|
||||
for org in auto_delete:
|
||||
OrganizationService.force_delete_organization(org, user_id=user.id)
|
||||
|
||||
UserService.delete_user(user, soft=True)
|
||||
return api_response(message="Account deleted successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me/password", methods=["POST"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def change_password():
|
||||
try:
|
||||
schema = ChangePasswordSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
if data["new_password"] != data["new_password_confirm"]:
|
||||
return api_response(
|
||||
success=False, message="New passwords do not match", status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details={"new_password_confirm": ["Passwords do not match"]},
|
||||
)
|
||||
|
||||
AuthService.change_password(
|
||||
user=g.current_user,
|
||||
current_password=data["current_password"],
|
||||
new_password=data["new_password"],
|
||||
)
|
||||
return api_response(message="Password changed successfully")
|
||||
except ValidationError as e:
|
||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me/organizations", methods=["GET"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def get_my_organizations():
|
||||
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": orgs, "count": len(orgs)}, message="Organizations retrieved successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me/principals", methods=["GET"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def get_my_principals():
|
||||
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
|
||||
user_id = user.id
|
||||
|
||||
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
|
||||
|
||||
role = membership.role
|
||||
is_admin = role in (OrganizationRole.ADMIN, OrganizationRole.OWNER)
|
||||
|
||||
direct_principal_ids = set()
|
||||
via_dept_principal_ids = set()
|
||||
|
||||
for pm in PrincipalMembership.query.filter_by(user_id=user_id, deleted_at=None).all():
|
||||
if pm.principal and pm.principal.organization_id == org.id and pm.principal.deleted_at is None:
|
||||
direct_principal_ids.add(pm.principal_id)
|
||||
|
||||
for dm in DepartmentMembership.query.filter_by(user_id=user_id, deleted_at=None).all():
|
||||
if dm.department and dm.department.organization_id == org.id and dm.department.deleted_at is None:
|
||||
for dp in DepartmentPrincipal.query.filter_by(department_id=dm.department_id, deleted_at=None).all():
|
||||
if dp.principal and dp.principal.deleted_at is None:
|
||||
via_dept_principal_ids.add(dp.principal_id)
|
||||
|
||||
effective_principal_ids = direct_principal_ids | via_dept_principal_ids
|
||||
|
||||
my_principals = []
|
||||
if effective_principal_ids:
|
||||
for p in Principal.query.filter(
|
||||
Principal.id.in_(list(effective_principal_ids)),
|
||||
Principal.deleted_at == None,
|
||||
).all():
|
||||
my_principals.append({
|
||||
"id": p.id, "name": p.name, "description": p.description,
|
||||
"direct": p.id in direct_principal_ids,
|
||||
})
|
||||
|
||||
all_principals = []
|
||||
if is_admin:
|
||||
for p in Principal.query.filter_by(organization_id=org.id, deleted_at=None).all():
|
||||
all_principals.append({"id": p.id, "name": p.name, "description": p.description})
|
||||
|
||||
orgs_result.append({
|
||||
"org_id": org.id, "org_name": org.name,
|
||||
"role": role.value if hasattr(role, "value") else role,
|
||||
"is_admin": is_admin,
|
||||
"my_principals": my_principals,
|
||||
"all_principals": all_principals,
|
||||
})
|
||||
|
||||
return api_response(data={"orgs": orgs_result}, message="Principals retrieved successfully")
|
||||
|
||||
|
||||
@api_v1_bp.route("/users/me/invites", methods=["GET"])
|
||||
@login_required
|
||||
def get_my_pending_invites():
|
||||
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():
|
||||
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
|
||||
|
||||
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
|
||||
]
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
Reference in New Issue
Block a user