Fix(Feat): CA, Audits, Rte Limit

CA Encryption, Serials, Rate Limiter, Account suspension blocks login
Transfer Ownership & Delete Account
This commit is contained in:
2026-03-02 23:53:51 +05:45
parent be87fd90b1
commit 5250d18eb0
23 changed files with 1399 additions and 34 deletions
+69 -2
View File
@@ -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)
+83 -4
View File
@@ -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)
+216 -4
View File
@@ -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,
+89 -8
View File
@@ -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',
+221 -1
View File
@@ -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,
},
)