Fix(Feat): CA, Audits, Rte Limit
CA Encryption, Serials, Rate Limiter, Account suspension blocks login Transfer Ownership & Delete Account
This commit is contained in:
@@ -22,7 +22,11 @@ from gatehouse_app.extensions import bcrypt as flask_bcrypt
|
||||
from gatehouse_app.extensions import redis_client as _redis_client_ref # may be None until app init
|
||||
from gatehouse_app.models import User, OIDCClient
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||
from gatehouse_app.exceptions.auth_exceptions import (
|
||||
InvalidCredentialsError,
|
||||
AccountSuspendedError,
|
||||
AccountInactiveError,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for Redis-backed OIDC pending state
|
||||
@@ -343,6 +347,20 @@ def oidc_complete():
|
||||
|
||||
user_id = str(gh_session.user_id)
|
||||
|
||||
# Check the user is still active (not suspended after session was issued)
|
||||
from gatehouse_app.models.user.user import User as _User
|
||||
from gatehouse_app.utils.constants import UserStatus
|
||||
_complete_user = _User.query.filter_by(id=user_id, deleted_at=None).first()
|
||||
if not _complete_user or _complete_user.status in (
|
||||
UserStatus.SUSPENDED, UserStatus.COMPLIANCE_SUSPENDED, UserStatus.INACTIVE
|
||||
):
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Your account is not active or has been suspended.",
|
||||
status=403,
|
||||
error_type="ACCOUNT_SUSPENDED",
|
||||
)
|
||||
|
||||
# Retrieve stashed OIDC params (consume = True removes from Redis atomically)
|
||||
params = _fetch_oidc_params(oidc_session_id, consume=True)
|
||||
if not params:
|
||||
@@ -565,6 +583,28 @@ def oidc_authorize():
|
||||
session["oidc_user_id"] = user_id
|
||||
|
||||
logger.debug("[OIDC] User authentication successful: user_id=%s, email=%s", user_id, email)
|
||||
except AccountSuspendedError:
|
||||
logger.debug("[OIDC] User authentication failed: account suspended for email=%s", email)
|
||||
return _show_login_page(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
response_type=response_type,
|
||||
error="Your account has been suspended. Please contact an administrator.",
|
||||
)
|
||||
except AccountInactiveError:
|
||||
logger.debug("[OIDC] User authentication failed: account inactive for email=%s", email)
|
||||
return _show_login_page(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
response_type=response_type,
|
||||
error="Your account is not active. Please verify your email.",
|
||||
)
|
||||
except InvalidCredentialsError:
|
||||
logger.debug("[OIDC] User authentication failed: invalid credentials for email=%s", email)
|
||||
return _show_login_page(
|
||||
@@ -600,7 +640,34 @@ def oidc_authorize():
|
||||
if not user:
|
||||
logger.debug("[OIDC] Redirecting with error: server_error (user not found)")
|
||||
return _redirect_with_error(redirect_uri, "server_error", "User not found", state)
|
||||
|
||||
|
||||
# Check account is still active (user could have been suspended after session start)
|
||||
from gatehouse_app.utils.constants import UserStatus as _UserStatus
|
||||
if user.status in (_UserStatus.SUSPENDED, _UserStatus.COMPLIANCE_SUSPENDED):
|
||||
session.pop("oidc_user_id", None) # clear stale session
|
||||
logger.debug("[OIDC] User is suspended, clearing session and showing login error: user_id=%s", user_id)
|
||||
return _show_login_page(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
response_type=response_type,
|
||||
error="Your account has been suspended. Please contact an administrator.",
|
||||
)
|
||||
if user.status == _UserStatus.INACTIVE:
|
||||
session.pop("oidc_user_id", None)
|
||||
logger.debug("[OIDC] User is inactive, clearing session and showing login error: user_id=%s", user_id)
|
||||
return _show_login_page(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
response_type=response_type,
|
||||
error="Your account is not active. Please verify your email.",
|
||||
)
|
||||
|
||||
logger.debug("[OIDC] Generating authorization code...")
|
||||
logger.debug("[OIDC] Authorization code params: client_id=%s, user_id=%s, redirect_uri=%s", client_id, user_id, redirect_uri)
|
||||
logger.debug("[OIDC] Authorization code params: scopes=%s, state=%s, nonce=%s", valid_scopes, state, nonce)
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from flask import request, session, g, jsonify, current_app
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1 import api_v1_bp
|
||||
from gatehouse_app.extensions import limiter
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.schemas.auth_schema import (
|
||||
RegisterSchema,
|
||||
@@ -32,6 +33,7 @@ from gatehouse_app.exceptions.validation_exceptions import ConflictError, NotFou
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/register", methods=["POST"])
|
||||
@limiter.limit(lambda: current_app.config["RATELIMIT_AUTH_REGISTER"])
|
||||
def register():
|
||||
"""
|
||||
Register a new user.
|
||||
@@ -135,6 +137,7 @@ def register():
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/login", methods=["POST"])
|
||||
@limiter.limit(lambda: current_app.config["RATELIMIT_AUTH_LOGIN"])
|
||||
def login():
|
||||
"""
|
||||
Login user.
|
||||
@@ -325,8 +328,13 @@ def get_current_user():
|
||||
data={
|
||||
"user": user.to_dict(),
|
||||
"organizations": [
|
||||
{"id": org.id, "name": org.name, "slug": org.slug}
|
||||
for org in user.get_organizations()
|
||||
{
|
||||
"id": membership.organization.id,
|
||||
"name": membership.organization.name,
|
||||
"slug": membership.organization.slug,
|
||||
"role": membership.role,
|
||||
}
|
||||
for membership in user.organization_memberships
|
||||
],
|
||||
},
|
||||
message="User retrieved successfully",
|
||||
@@ -478,6 +486,7 @@ def verify_totp_enrollment():
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/totp/verify", methods=["POST"])
|
||||
@limiter.limit(lambda: current_app.config["RATELIMIT_AUTH_TOTP_VERIFY"])
|
||||
def verify_totp():
|
||||
"""
|
||||
Verify TOTP code during login.
|
||||
@@ -520,6 +529,18 @@ def verify_totp():
|
||||
error_type="AUTHENTICATION_ERROR",
|
||||
)
|
||||
|
||||
# Check account suspension before completing TOTP verification
|
||||
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",
|
||||
)
|
||||
|
||||
# Verify TOTP code
|
||||
AuthService.authenticate_with_totp(
|
||||
user,
|
||||
@@ -908,7 +929,18 @@ def begin_webauthn_login():
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
# Check account suspension before proceeding
|
||||
from gatehouse_app.utils.constants import UserStatus
|
||||
if user.status in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_SUSPENDED):
|
||||
logger.warning(f"WebAuthn login begin - suspended account attempt: {user.email}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Account is suspended. Contact an administrator.",
|
||||
status=403,
|
||||
error_type="ACCOUNT_SUSPENDED",
|
||||
)
|
||||
|
||||
# Check if user has any WebAuthn credentials
|
||||
if not user.has_webauthn_enabled():
|
||||
logger.warning(f"WebAuthn login begin - no credentials for user: {user.email}")
|
||||
@@ -991,7 +1023,19 @@ def complete_webauthn_login():
|
||||
status=401,
|
||||
error_type="AUTHENTICATION_ERROR",
|
||||
)
|
||||
|
||||
|
||||
# Check account suspension before completing login
|
||||
from gatehouse_app.utils.constants import UserStatus
|
||||
if user.status in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_SUSPENDED):
|
||||
session.pop("webauthn_pending_user_id", None)
|
||||
logger.warning(f"WebAuthn login complete - suspended account attempt: {user.email}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Account is suspended. Contact an administrator.",
|
||||
status=403,
|
||||
error_type="ACCOUNT_SUSPENDED",
|
||||
)
|
||||
|
||||
# Extract challenge from client data
|
||||
client_data = data.get("response", {}).get("clientDataJSON", "")
|
||||
|
||||
@@ -1129,6 +1173,19 @@ def delete_webauthn_credential(credential_id):
|
||||
"""
|
||||
user = g.current_user
|
||||
|
||||
# First check that the specific credential actually belongs to this user.
|
||||
# Only then check whether it is the last one — otherwise a user with zero
|
||||
# credentials gets a misleading "Cannot delete the last passkey" error
|
||||
# instead of a 404.
|
||||
credential_exists = WebAuthnService.credential_belongs_to_user(credential_id, user)
|
||||
if not credential_exists:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Credential not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Check if this is the last credential
|
||||
credential_count = user.get_webauthn_credential_count()
|
||||
if credential_count <= 1:
|
||||
@@ -1238,6 +1295,7 @@ _pw_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():
|
||||
"""Request a password reset email.
|
||||
|
||||
@@ -1294,6 +1352,7 @@ def forgot_password():
|
||||
|
||||
|
||||
@api_v1_bp.route("/auth/reset-password", methods=["POST"])
|
||||
@limiter.limit(lambda: current_app.config["RATELIMIT_AUTH_RESET_PASSWORD"])
|
||||
def reset_password():
|
||||
"""Reset a user's password using a reset token.
|
||||
|
||||
@@ -1601,11 +1660,31 @@ def get_token():
|
||||
302: Redirect to ``<redirect>?token=<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:
|
||||
# Validate redirect URL against allowed origins to prevent open-redirect
|
||||
# token exfiltration attacks (CWE-601).
|
||||
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)
|
||||
|
||||
|
||||
@@ -226,6 +226,10 @@ def delete_organization(org_id):
|
||||
"""
|
||||
Delete organization (soft delete).
|
||||
|
||||
The owner may only delete the organization if they are the *sole* remaining
|
||||
member. If other active members exist they must first transfer ownership
|
||||
(or remove all other members) before deleting the organization.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
|
||||
@@ -234,9 +238,26 @@ def delete_organization(org_id):
|
||||
401: Not authenticated
|
||||
403: Not the owner
|
||||
404: Organization not found
|
||||
409: Organization still has other members — transfer ownership first
|
||||
"""
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
|
||||
# Guard: block deletion while non-owner members still exist so ownership
|
||||
# can be transferred rather than silently orphaning them.
|
||||
active_member_count = org.get_member_count()
|
||||
if active_member_count > 1:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=(
|
||||
"This organization still has other members. "
|
||||
"Please transfer ownership to another member or remove all "
|
||||
"other members before deleting the organization."
|
||||
),
|
||||
status=409,
|
||||
error_type="ORG_HAS_MEMBERS",
|
||||
error_details={"member_count": active_member_count},
|
||||
)
|
||||
|
||||
OrganizationService.delete_organization(
|
||||
org=org,
|
||||
user_id=g.current_user.id,
|
||||
@@ -446,6 +467,152 @@ def update_member_role(org_id, user_id):
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/transfer-ownership", methods=["POST"])
|
||||
@login_required
|
||||
@full_access_required
|
||||
def transfer_organization_ownership(org_id):
|
||||
"""Transfer organization ownership from the current user to another member.
|
||||
|
||||
Only the current OWNER of the organization may call this endpoint.
|
||||
The caller will be demoted to ADMIN and the target user will be promoted to OWNER.
|
||||
|
||||
Request body:
|
||||
new_owner_user_id (str): UUID of the member to promote to OWNER.
|
||||
|
||||
Returns:
|
||||
200: Ownership transferred successfully
|
||||
400: Validation error / missing fields
|
||||
403: Caller is not the OWNER of this org
|
||||
404: Organization or target member not found
|
||||
409: Target is already the OWNER
|
||||
"""
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole, 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",
|
||||
)
|
||||
|
||||
# Fetch org (raises NotFound internally)
|
||||
org = OrganizationService.get_organization_by_id(org_id)
|
||||
|
||||
# Confirm caller is the current OWNER
|
||||
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",
|
||||
)
|
||||
|
||||
# Verify the target is an active member
|
||||
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",
|
||||
)
|
||||
|
||||
# ── Atomic role swap ─────────────────────────────────────────────────────
|
||||
# Demote caller → ADMIN, promote target → OWNER.
|
||||
# Both updates go through OrganizationService so all hooks/auditing fire.
|
||||
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} "
|
||||
f"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 "
|
||||
f"{target_membership.user.email if target_membership.user else new_owner_user_id}."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@api_v1_bp.route("/organizations/<org_id>/audit-logs", methods=["GET"])
|
||||
@login_required
|
||||
@require_admin
|
||||
@@ -756,10 +923,30 @@ def accept_invite(token):
|
||||
inviter_id=invite.invited_by_id,
|
||||
)
|
||||
except Exception:
|
||||
pass # Already a member is fine
|
||||
from gatehouse_app.extensions import db
|
||||
db.session.rollback() # Clear broken transaction so invite.accept() can commit
|
||||
|
||||
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(
|
||||
@@ -1379,6 +1566,7 @@ def create_org_ca(org_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 marshmallow import Schema, fields as ma_fields, validate, ValidationError as MaValidationError
|
||||
from sshkey_tools.keys import Ed25519PrivateKey, RsaPrivateKey, EcdsaPrivateKey
|
||||
|
||||
@@ -1448,13 +1636,16 @@ def create_org_ca(org_id):
|
||||
public_key_str = private_key_obj.public_key.to_string()
|
||||
fingerprint = compute_ssh_fingerprint(public_key_str)
|
||||
|
||||
# Encrypt the private key before storing in the database
|
||||
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=private_key_pem,
|
||||
private_key=encrypted_private_key,
|
||||
public_key=public_key_str,
|
||||
fingerprint=fingerprint,
|
||||
default_cert_validity_hours=data["default_cert_validity_hours"],
|
||||
@@ -1462,7 +1653,24 @@ def create_org_ca(org_id):
|
||||
is_active=True,
|
||||
)
|
||||
db.session.add(ca)
|
||||
db.session.commit()
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as commit_exc:
|
||||
db.session.rollback()
|
||||
# Surface unique-constraint violations (soft-deleted record with same name) as a
|
||||
# user-friendly 400 instead of a 500.
|
||||
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()},
|
||||
@@ -1570,6 +1778,7 @@ 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
|
||||
@@ -1609,8 +1818,11 @@ def rotate_org_ca(org_id, ca_id):
|
||||
new_public_key = private_key_obj.public_key.to_string()
|
||||
new_fingerprint = compute_ssh_fingerprint(new_public_key)
|
||||
|
||||
# Encrypt the new private key before storing
|
||||
encrypted_new_private_key = encrypt_ca_key(new_private_key)
|
||||
|
||||
ca.rotate_key(
|
||||
new_private_key=new_private_key,
|
||||
new_private_key=encrypted_new_private_key,
|
||||
new_public_key=new_public_key,
|
||||
new_fingerprint=new_fingerprint,
|
||||
reason=reason,
|
||||
|
||||
@@ -15,6 +15,7 @@ from gatehouse_app.exceptions import (
|
||||
)
|
||||
from gatehouse_app.utils.constants import AuditAction
|
||||
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
|
||||
|
||||
@@ -78,11 +79,16 @@ def _get_or_create_system_ca():
|
||||
with open(pub_key_path) as f:
|
||||
pub_key = f.read().strip()
|
||||
|
||||
# Load private key for the record (stored but not actually used for signing here)
|
||||
# Load private key for the record (encrypt before storing in DB)
|
||||
priv_key = ""
|
||||
if os.path.exists(key_path):
|
||||
with open(key_path) as f:
|
||||
priv_key = f.read()
|
||||
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 # fallback: store as-is if encryption unavailable
|
||||
|
||||
fingerprint = compute_ssh_fingerprint(pub_key)
|
||||
|
||||
@@ -120,7 +126,7 @@ def _get_or_create_system_ca():
|
||||
return None
|
||||
|
||||
|
||||
def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=None, cert_type_str='user'):
|
||||
def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=None, cert_type_str='user', cert_identity=None):
|
||||
"""Save a signed certificate to the ssh_certificates table.
|
||||
|
||||
Args:
|
||||
@@ -130,6 +136,8 @@ def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=N
|
||||
signing_response: SSHCertificateSigningResponse
|
||||
request_ip: Client IP address
|
||||
cert_type_str: 'user' or 'host' (from the sign request)
|
||||
cert_identity: Rich OpenSSH key_id string (e.g. "user@host (Name) [org:slug]").
|
||||
Falls back to str(ssh_key_id) when not provided.
|
||||
|
||||
Returns:
|
||||
SSHCertificate instance or None if persistence failed
|
||||
@@ -153,7 +161,7 @@ def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=N
|
||||
ssh_key_id=ssh_key_id,
|
||||
certificate=signing_response.certificate,
|
||||
serial=signing_response.serial,
|
||||
key_id=str(ssh_key_id),
|
||||
key_id=cert_identity or str(ssh_key_id),
|
||||
cert_type=resolved_cert_type,
|
||||
principals=signing_response.principals,
|
||||
valid_after=signing_response.valid_after,
|
||||
@@ -465,7 +473,7 @@ def sign_certificate():
|
||||
|
||||
# ── Check account suspension ──────────────────────────────────────────────
|
||||
from gatehouse_app.utils.constants import UserStatus
|
||||
if user.status == UserStatus.SUSPENDED:
|
||||
if user.status in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_SUSPENDED):
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Your account is suspended. Contact an administrator.",
|
||||
@@ -482,6 +490,18 @@ def sign_certificate():
|
||||
key_id = data.get('key_id') or data.get('cert_id')
|
||||
expiry_hours = data.get('expiry_hours')
|
||||
|
||||
# ── Log the request ───────────────────────────────────────────────────────
|
||||
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 '')
|
||||
),
|
||||
)
|
||||
|
||||
# ── Resolve which principals the user is allowed to use ──────────────────
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
|
||||
@@ -601,11 +621,24 @@ def sign_certificate():
|
||||
else:
|
||||
policy_extensions = None # let signing service use its own defaults
|
||||
|
||||
# ── Build rich key_id identity for the OpenSSH cert ─────────────────────
|
||||
# This appears in `ssh-keygen -L -f cert.pub` as the Key ID field and
|
||||
# is stored in the DB cert record so it's auditable.
|
||||
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=key_id,
|
||||
key_id=cert_identity,
|
||||
expiry_hours=int(expiry_hours) if expiry_hours else None,
|
||||
extensions=policy_extensions,
|
||||
)
|
||||
@@ -620,7 +653,11 @@ def sign_certificate():
|
||||
)
|
||||
|
||||
try:
|
||||
response = ssh_ca_service.sign_certificate(signing_request, ca_private_key=db_ca.private_key)
|
||||
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,
|
||||
@@ -649,6 +686,7 @@ def sign_certificate():
|
||||
signing_response=response,
|
||||
request_ip=request.remote_addr,
|
||||
cert_type_str=cert_type,
|
||||
cert_identity=cert_identity,
|
||||
)
|
||||
|
||||
AuditLog.log(
|
||||
@@ -657,9 +695,42 @@ def sign_certificate():
|
||||
resource_type='SSHCertificate',
|
||||
resource_id=cert_record.id if cert_record else key_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f'Certificate issued for principals: {", ".join(principals)}',
|
||||
description=(
|
||||
f'Certificate serial={response.serial} issued for {user.email}; '
|
||||
f'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}; '
|
||||
f'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,
|
||||
@@ -753,6 +824,16 @@ def revoke_certificate(cert_id):
|
||||
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',
|
||||
|
||||
@@ -73,11 +73,51 @@ def delete_me():
|
||||
"""
|
||||
Delete current user account (soft delete).
|
||||
|
||||
Blocked if the user is the sole owner of any organization that has other
|
||||
active members — they must transfer ownership or dissolve those organizations
|
||||
first.
|
||||
|
||||
Returns:
|
||||
200: Account deleted successfully
|
||||
401: Not authenticated
|
||||
409: User is sole owner of one or more organizations with other members
|
||||
"""
|
||||
UserService.delete_user(g.current_user, soft=True)
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
user = g.current_user
|
||||
|
||||
# Find orgs where this user is the sole owner AND other members exist.
|
||||
owned_memberships = OrganizationMember.query.filter_by(
|
||||
user_id=user.id,
|
||||
role=OrganizationRole.OWNER,
|
||||
deleted_at=None,
|
||||
).all()
|
||||
|
||||
blocked_orgs = []
|
||||
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:
|
||||
blocked_orgs.append(org.name)
|
||||
|
||||
if blocked_orgs:
|
||||
names = ", ".join(f'"{n}"' for n in blocked_orgs)
|
||||
return api_response(
|
||||
success=False,
|
||||
message=(
|
||||
f"You are the sole owner of {len(blocked_orgs)} organization"
|
||||
f"{'s' if len(blocked_orgs) > 1 else ''}: {names}. "
|
||||
"Transfer ownership or delete those organizations before deleting your account."
|
||||
),
|
||||
status=409,
|
||||
error_type="USER_IS_SOLE_OWNER",
|
||||
error_details={"organizations": blocked_orgs},
|
||||
)
|
||||
|
||||
UserService.delete_user(user, soft=True)
|
||||
|
||||
return api_response(
|
||||
message="Account deleted successfully",
|
||||
@@ -454,6 +494,31 @@ def admin_suspend_user(user_id):
|
||||
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")
|
||||
|
||||
@@ -645,3 +710,158 @@ def get_my_memberships():
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user