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:
2026-03-04 18:49:04 +05:45
parent ea1bacc794
commit 7cb522b590
63 changed files with 7896 additions and 10863 deletions
@@ -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
+175
View File
@@ -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",
)
+261
View File
@@ -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")