From 1778dd85d5620519b18a1a25691c89d229425f53 Mon Sep 17 00:00:00 2001 From: Cory Hawkvelt Date: Tue, 21 Apr 2026 17:11:03 +0930 Subject: [PATCH] Add superadmin routes to API --- .dockerignore | 144 +++++ gatehouse_app/api/v1/superadmin/__init__.py | 14 + gatehouse_app/api/v1/superadmin/auth.py | 286 +++++++++ gatehouse_app/api/v1/superadmin/billing.py | 568 ++++++++++++++++++ gatehouse_app/api/v1/superadmin/cas.py | 56 ++ .../api/v1/superadmin/organization_members.py | 456 ++++++++++++++ .../api/v1/superadmin/organizations.py | 254 ++++++++ .../api/v1/superadmin/usage_analytics.py | 330 ++++++++++ gatehouse_app/api/v1/superadmin/users.py | 516 ++++++++++++++++ gatehouse_app/api/v1/users/admin.py | 42 +- gatehouse_app/decorators/superadmin.py | 203 +++++++ gatehouse_app/models/__init__.py | 13 + gatehouse_app/models/billing/__init__.py | 5 + gatehouse_app/models/billing/plan.py | 61 ++ gatehouse_app/models/billing/subscription.py | 99 +++ .../models/organization/organization.py | 40 ++ gatehouse_app/models/superadmin/__init__.py | 5 + gatehouse_app/models/superadmin/superadmin.py | 56 ++ .../models/superadmin/superadmin_session.py | 80 +++ gatehouse_app/models/superadmin_audit_log.py | 49 ++ gatehouse_app/models/user/user.py | 40 ++ gatehouse_app/services/__init__.py | 4 + gatehouse_app/services/billing_service.py | 192 ++++++ .../services/organization_service.py | 4 +- .../services/superadmin_analytics_service.py | 177 ++++++ .../services/superadmin_auth_service.py | 239 ++++++++ .../superadmin_organization_service.py | 244 ++++++++ .../services/superadmin_usage_service.py | 199 ++++++ .../services/superadmin_user_service.py | 371 ++++++++++++ .../versions/b4cd6c6b3b1c_superadmin.py | 112 ++++ tests/api/__init__.py | 1 + tests/api/v1/__init__.py | 1 + tests/api/v1/ssh/__init__.py | 1 + 33 files changed, 4831 insertions(+), 31 deletions(-) create mode 100644 .dockerignore create mode 100644 gatehouse_app/api/v1/superadmin/__init__.py create mode 100644 gatehouse_app/api/v1/superadmin/auth.py create mode 100644 gatehouse_app/api/v1/superadmin/billing.py create mode 100644 gatehouse_app/api/v1/superadmin/cas.py create mode 100644 gatehouse_app/api/v1/superadmin/organization_members.py create mode 100644 gatehouse_app/api/v1/superadmin/organizations.py create mode 100644 gatehouse_app/api/v1/superadmin/usage_analytics.py create mode 100644 gatehouse_app/api/v1/superadmin/users.py create mode 100644 gatehouse_app/decorators/superadmin.py create mode 100644 gatehouse_app/models/billing/__init__.py create mode 100644 gatehouse_app/models/billing/plan.py create mode 100644 gatehouse_app/models/billing/subscription.py create mode 100644 gatehouse_app/models/superadmin/__init__.py create mode 100644 gatehouse_app/models/superadmin/superadmin.py create mode 100644 gatehouse_app/models/superadmin/superadmin_session.py create mode 100644 gatehouse_app/models/superadmin_audit_log.py create mode 100644 gatehouse_app/services/billing_service.py create mode 100644 gatehouse_app/services/superadmin_analytics_service.py create mode 100644 gatehouse_app/services/superadmin_auth_service.py create mode 100644 gatehouse_app/services/superadmin_organization_service.py create mode 100644 gatehouse_app/services/superadmin_usage_service.py create mode 100644 gatehouse_app/services/superadmin_user_service.py create mode 100644 migrations/versions/b4cd6c6b3b1c_superadmin.py create mode 100644 tests/api/__init__.py create mode 100644 tests/api/v1/__init__.py create mode 100644 tests/api/v1/ssh/__init__.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6fcc667 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,144 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Project specific + +*.db +flask_session/ + +# Opencode files and folders +.opencode/ +.swarm/ +SWARM_PLAN.* \ No newline at end of file diff --git a/gatehouse_app/api/v1/superadmin/__init__.py b/gatehouse_app/api/v1/superadmin/__init__.py new file mode 100644 index 0000000..c5ef8b5 --- /dev/null +++ b/gatehouse_app/api/v1/superadmin/__init__.py @@ -0,0 +1,14 @@ +"""Superadmin API blueprint.""" +import logging +from flask import Blueprint + +from gatehouse_app.extensions import limiter + + +logger = logging.getLogger(__name__) + +# Create superadmin blueprint +superadmin_bp = Blueprint("superadmin", __name__, url_prefix="/superadmin") + +# Import route modules to register them +from gatehouse_app.api.v1.superadmin import auth, organizations, organization_members, usage_analytics, users, billing, cas # noqa: F401 diff --git a/gatehouse_app/api/v1/superadmin/auth.py b/gatehouse_app/api/v1/superadmin/auth.py new file mode 100644 index 0000000..e45e63c --- /dev/null +++ b/gatehouse_app/api/v1/superadmin/auth.py @@ -0,0 +1,286 @@ +"""Superadmin authentication endpoints.""" +import logging +from flask import request, g, current_app +from marshmallow import ValidationError + +from gatehouse_app.api.v1.superadmin import superadmin_bp +from gatehouse_app.extensions import limiter +from gatehouse_app.utils.response import api_response +from gatehouse_app.services.superadmin_auth_service import SuperadminAuthService +from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log +from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError + + +logger = logging.getLogger(__name__) + + +class LoginSchema: + """Schema for superadmin login.""" + + @staticmethod + def load(data): + """Validate login data.""" + errors = {} + + if not data.get('email'): + errors['email'] = ['Email is required'] + elif '@' not in data['email']: + errors['email'] = ['Invalid email format'] + + if not data.get('password'): + errors['password'] = ['Password is required'] + + if errors: + raise ValidationError(errors) + + return { + 'email': data['email'].lower().strip(), + 'password': data['password'], + } + + +@superadmin_bp.route("/auth/login", methods=["POST"]) +@limiter.limit(lambda: current_app.config.get("RATELIMIT_AUTH_LOGIN", "100 per minute")) +def login(): + """Superadmin login endpoint. + + Authenticates with email/password and returns a session token. + """ + try: + schema = LoginSchema() + data = schema.load(request.json) + + # Authenticate + superadmin = SuperadminAuthService.authenticate( + email=data['email'], + credentials=data['password'] + ) + + # Create session (default 8 hours) + session = SuperadminAuthService.create_session( + superadmin_id=superadmin.id, + duration_seconds=28800 # 8 hours + ) + + expires_str = session.expires_at.isoformat() + if not expires_str.endswith('Z'): + expires_str += 'Z' + + logger.info(f"[SuperadminAuth] Login successful for: {superadmin.email}") + + return api_response( + data={ + "superadmin": superadmin.to_dict(), + "token": session.token, + "expires_at": expires_str, + }, + message="Login successful", + status=200 + ) + + except ValidationError as e: + return api_response( + success=False, + message="Validation failed", + status=400, + error_type="VALIDATION_ERROR", + error_details=e.messages + ) + except InvalidCredentialsError: + return api_response( + success=False, + message="Invalid email or password", + status=401, + error_type="INVALID_CREDENTIALS" + ) + except Exception as e: + logger.error(f"[SuperadminAuth] Login error: {e}") + return api_response( + success=False, + message="An error occurred during login", + status=500, + error_type="INTERNAL_ERROR" + ) + + +@superadmin_bp.route("/auth/logout", methods=["POST"]) +@superadmin_required +def logout(): + """Superadmin logout endpoint. + + Invalidates the current session. + """ + try: + session = g.superadmin_session + if session: + SuperadminAuthService.revoke_session(session.id, reason="Superadmin logout") + + return api_response( + message="Logout successful" + ) + except Exception as e: + logger.error(f"[SuperadminAuth] Logout error: {e}") + return api_response( + success=False, + message="An error occurred during logout", + status=500, + error_type="INTERNAL_ERROR" + ) + + +@superadmin_bp.route("/auth/me", methods=["GET"]) +@superadmin_required +def get_current_superadmin(): + """Get current superadmin profile. + + Returns the profile of the currently authenticated superadmin. + """ + try: + superadmin = g.current_superadmin + + return api_response( + data={ + "superadmin": superadmin.to_dict(), + }, + message="Superadmin retrieved successfully" + ) + except Exception as e: + logger.error(f"[SuperadminAuth] Get me error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR" + ) + + +@superadmin_bp.route("/auth/impersonate/", methods=["POST"]) +@superadmin_required +@superadmin_audit_log(action="impersonate", resource_type="user") +def impersonate_user(user_id): + """Create emergency access session by impersonating a user. + + Creates a temporary session for the target user that allows + the superadmin to access the platform as that user. + + This action is fully audited. + """ + try: + superadmin = g.current_superadmin + data = request.json or {} + reason = data.get('reason', 'Not specified') + duration_minutes = data.get('duration_minutes', 15) + + # Limit duration to max 60 minutes + duration_minutes = min(duration_minutes, 60) + + # Create emergency access + result = SuperadminAuthService.create_emergency_access( + superadmin_id=superadmin.id, + target_user_id=user_id, + reason=reason, + duration_minutes=duration_minutes + ) + + expires_str = result['expires_at'].isoformat() + if not expires_str.endswith('Z'): + expires_str += 'Z' + + logger.warning( + f"[SuperadminAuth] IMPERSONATION: superadmin={superadmin.email} " + f"impersonated user_id={user_id} reason={reason}" + ) + + return api_response( + data={ + "session_token": result['session'].token, + "expires_at": expires_str, + "target_user_id": user_id, + "reason": reason, + "duration_minutes": duration_minutes, + }, + message="Emergency access session created", + status=201 + ) + + except ValueError as e: + return api_response( + success=False, + message=str(e), + status=404, + error_type="NOT_FOUND" + ) + except Exception as e: + logger.error(f"[SuperadminAuth] Impersonate error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR" + ) + + +@superadmin_bp.route("/auth/emergency/", methods=["POST"]) +@superadmin_required +@superadmin_audit_log(action="emergency_access", resource_type="user") +def grant_emergency_access(user_id): + """Grant temporary elevated access to a user. + + Similar to impersonate but grants elevated permissions + rather than creating a session as the user. + + This action is fully audited. + """ + try: + superadmin = g.current_superadmin + data = request.json or {} + reason = data.get('reason', 'Not specified') + duration_minutes = data.get('duration_minutes', 15) + + # Limit duration to max 60 minutes + duration_minutes = min(duration_minutes, 60) + + # Create emergency access + result = SuperadminAuthService.create_emergency_access( + superadmin_id=superadmin.id, + target_user_id=user_id, + reason=reason, + duration_minutes=duration_minutes + ) + + expires_str = result['expires_at'].isoformat() + if not expires_str.endswith('Z'): + expires_str += 'Z' + + logger.warning( + f"[SuperadminAuth] EMERGENCY ACCESS: superadmin={superadmin.email} " + f"granted access to user_id={user_id} reason={reason}" + ) + + return api_response( + data={ + "session_token": result['session'].token, + "expires_at": expires_str, + "target_user_id": user_id, + "reason": reason, + "duration_minutes": duration_minutes, + }, + message="Emergency access granted", + status=201 + ) + + except ValueError as e: + return api_response( + success=False, + message=str(e), + status=404, + error_type="NOT_FOUND" + ) + except Exception as e: + logger.error(f"[SuperadminAuth] Emergency access error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR" + ) diff --git a/gatehouse_app/api/v1/superadmin/billing.py b/gatehouse_app/api/v1/superadmin/billing.py new file mode 100644 index 0000000..b2b5465 --- /dev/null +++ b/gatehouse_app/api/v1/superadmin/billing.py @@ -0,0 +1,568 @@ +"""Superadmin billing endpoints for plans and subscriptions.""" +import logging +from datetime import datetime, timezone +from flask import request +from gatehouse_app.api.v1.superadmin import superadmin_bp +from gatehouse_app.utils.response import api_response +from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log +from gatehouse_app.models.organization.organization import Organization +from gatehouse_app.extensions import db + +logger = logging.getLogger(__name__) + + +# ============ Plans Endpoints ============ + +@superadmin_bp.route("/billing/plans", methods=["GET"]) +@superadmin_required +def list_plans(): + """Get all available plans.""" + try: + from gatehouse_app.models.billing.plan import Plan + + plans = Plan.query.filter(Plan.is_active == True).order_by(Plan.price_monthly.asc()).all() + + items = [{ + "id": p.id, + "name": p.name, + "slug": p.slug, + "description": p.description, + "price_monthly": p.price_monthly, + "price_yearly": p.price_yearly, + "included_users": p.included_users, + "overage_rate_per_user": p.overage_rate_per_user, + "features": p.features, + "stripe_price_id_monthly": p.stripe_price_id_monthly, + "stripe_price_id_yearly": p.stripe_price_id_yearly, + "is_active": p.is_active, + "created_at": p.created_at.isoformat() + "Z" if p.created_at else None, + } for p in plans] + + return api_response(data={"items": items}, message="Plans retrieved successfully") + + except Exception as e: + logger.error(f"[Billing] List plans error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/billing/plans", methods=["POST"]) +@superadmin_required +@superadmin_audit_log(action="plan.create", resource_type="plan") +def create_plan(): + """Create a new plan.""" + try: + from gatehouse_app.models.billing.plan import Plan + from marshmallow import Schema, fields, validate + + class CreatePlanSchema(Schema): + name = fields.Str(required=True, validate=validate.Length(min=1, max=100)) + slug = fields.Str(required=True, validate=validate.Length(min=1, max=50)) + description = fields.Str(allow_none=True) + price_monthly = fields.Int(required=True, validate=validate.Range(min=0)) + price_yearly = fields.Int(required=True, validate=validate.Range(min=0)) + included_users = fields.Int(required=True, validate=validate.Range(min=0)) + overage_rate_per_user = fields.Int(required=True, validate=validate.Range(min=0)) + features = fields.Dict(allow_none=True) + stripe_price_id_monthly = fields.Str(allow_none=True) + stripe_price_id_yearly = fields.Str(allow_none=True) + + data = request.json or {} + schema = CreatePlanSchema() + errors = schema.validate(data) + + if errors: + return api_response( + success=False, + message="Validation error", + status=400, + error_type="VALIDATION_ERROR", + error_details=errors, + ) + + # Check if slug already exists + existing = Plan.query.filter_by(slug=data["slug"]).first() + if existing: + return api_response( + success=False, + message="Plan with this slug already exists", + status=400, + error_type="VALIDATION_ERROR", + ) + + plan = Plan( + name=data["name"], + slug=data["slug"], + description=data.get("description"), + price_monthly=data["price_monthly"], + price_yearly=data["price_yearly"], + included_users=data["included_users"], + overage_rate_per_user=data["overage_rate_per_user"], + features=data.get("features"), + stripe_price_id_monthly=data.get("stripe_price_id_monthly"), + stripe_price_id_yearly=data.get("stripe_price_id_yearly"), + ) + db.session.add(plan) + db.session.commit() + + return api_response(data={ + "id": plan.id, + "name": plan.name, + "slug": plan.slug, + }, message="Plan created successfully", status=201) + + except Exception as e: + db.session.rollback() + logger.error(f"[Billing] Create plan error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/billing/plans/", methods=["GET"]) +@superadmin_required +def get_plan(plan_id): + """Get a single plan by ID.""" + try: + from gatehouse_app.models.billing.plan import Plan + + plan = Plan.query.get(plan_id) + if not plan: + return api_response( + success=False, + message="Plan not found", + status=404, + error_type="NOT_FOUND", + ) + + return api_response(data={ + "id": plan.id, + "name": plan.name, + "slug": plan.slug, + "description": plan.description, + "price_monthly": plan.price_monthly, + "price_yearly": plan.price_yearly, + "included_users": plan.included_users, + "overage_rate_per_user": plan.overage_rate_per_user, + "features": plan.features, + "stripe_price_id_monthly": plan.stripe_price_id_monthly, + "stripe_price_id_yearly": plan.stripe_price_id_yearly, + "is_active": plan.is_active, + "created_at": plan.created_at.isoformat() + "Z" if plan.created_at else None, + "updated_at": plan.updated_at.isoformat() + "Z" if plan.updated_at else None, + }, message="Plan retrieved successfully") + + except Exception as e: + logger.error(f"[Billing] Get plan error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/billing/plans/", methods=["PATCH"]) +@superadmin_required +@superadmin_audit_log(action="plan.update", resource_type="plan") +def update_plan(plan_id): + """Update a plan.""" + try: + from gatehouse_app.models.billing.plan import Plan + from marshmallow import Schema, fields, validate + + class UpdatePlanSchema(Schema): + name = fields.Str(validate=validate.Length(min=1, max=100)) + description = fields.Str(allow_none=True) + price_monthly = fields.Int(validate=validate.Range(min=0)) + price_yearly = fields.Int(validate=validate.Range(min=0)) + included_users = fields.Int(validate=validate.Range(min=0)) + overage_rate_per_user = fields.Int(validate=validate.Range(min=0)) + features = fields.Dict(allow_none=True) + stripe_price_id_monthly = fields.Str(allow_none=True) + stripe_price_id_yearly = fields.Str(allow_none=True) + is_active = fields.Bool() + + plan = Plan.query.get(plan_id) + if not plan: + return api_response( + success=False, + message="Plan not found", + status=404, + error_type="NOT_FOUND", + ) + + data = request.json or {} + schema = UpdatePlanSchema() + errors = schema.validate(data) + + if errors: + return api_response( + success=False, + message="Validation error", + status=400, + error_type="VALIDATION_ERROR", + error_details=errors, + ) + + # Update fields + for key, value in data.items(): + if hasattr(plan, key): + setattr(plan, key, value) + + db.session.commit() + + return api_response(data={ + "id": plan.id, + "name": plan.name, + "slug": plan.slug, + }, message="Plan updated successfully") + + except Exception as e: + db.session.rollback() + logger.error(f"[Billing] Update plan error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/billing/plans/", methods=["DELETE"]) +@superadmin_required +@superadmin_audit_log(action="plan.delete", resource_type="plan") +def delete_plan(plan_id): + """Soft-delete a plan by setting is_active=False.""" + try: + from gatehouse_app.models.billing.plan import Plan + + plan = Plan.query.get(plan_id) + if not plan: + return api_response( + success=False, + message="Plan not found", + status=404, + error_type="NOT_FOUND", + ) + + plan.is_active = False + db.session.commit() + + return api_response(data={"id": plan.id}, message="Plan deleted successfully") + + except Exception as e: + db.session.rollback() + logger.error(f"[Billing] Delete plan error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +# ============ Subscriptions Endpoints ============ + +@superadmin_bp.route("/billing/subscriptions", methods=["GET"]) +@superadmin_required +def list_subscriptions(): + """Get all subscriptions with optional filters.""" + try: + from gatehouse_app.models.billing.subscription import Subscription + from gatehouse_app.models.billing.plan import Plan + + page = max(1, int(request.args.get("page", 1))) + per_page = min(100, max(1, int(request.args.get("per_page", 20)))) + plan_id = request.args.get("plan_id") + status = request.args.get("status") + + query = Subscription.query + + if plan_id: + query = query.filter(Subscription.plan_id == plan_id) + + if status: + query = query.filter(Subscription.status == status) + + total = query.count() + subs = query.order_by(Subscription.created_at.desc()).offset((page - 1) * per_page).limit(per_page).all() + + items = [] + for sub in subs: + org = Organization.query.get(sub.organization_id) + plan = Plan.query.get(sub.plan_id) if sub.plan_id else None + + # Calculate MRR + if plan and sub.status == "active": + mrr = plan.price_monthly if sub.billing_cycle == "monthly" else plan.price_yearly // 12 + else: + mrr = 0 + + items.append({ + "id": sub.id, + "organization_id": sub.organization_id, + "org_name": org.name if org else "Unknown", + "plan_id": sub.plan_id, + "plan_name": plan.name if plan else "Unknown", + "status": sub.status, + "billing_cycle": sub.billing_cycle, + "mrr": mrr, + "current_period_start": sub.current_period_start.isoformat() + "Z" if sub.current_period_start else None, + "current_period_end": sub.current_period_end.isoformat() + "Z" if sub.current_period_end else None, + "trial_ends_at": sub.trial_ends_at.isoformat() + "Z" if sub.trial_ends_at else None, + "cancel_at_period_end": sub.cancel_at_period_end, + "created_at": sub.created_at.isoformat() + "Z" if sub.created_at else None, + }) + + return api_response(data={ + "items": items, + "total": total, + "page": page, + "per_page": per_page, + "pages": (total + per_page - 1) // per_page if per_page > 0 else 0, + }, message="Subscriptions retrieved successfully") + + except Exception as e: + logger.error(f"[Billing] List subscriptions error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/billing/subscriptions/", methods=["GET"]) +@superadmin_required +def get_subscription(sub_id): + """Get a single subscription.""" + try: + from gatehouse_app.models.billing.subscription import Subscription + from gatehouse_app.models.billing.plan import Plan + + sub = Subscription.query.get(sub_id) + if not sub: + return api_response( + success=False, + message="Subscription not found", + status=404, + error_type="NOT_FOUND", + ) + + org = Organization.query.get(sub.organization_id) + plan = Plan.query.get(sub.plan_id) if sub.plan_id else None + + return api_response(data={ + "id": sub.id, + "organization_id": sub.organization_id, + "org_name": org.name if org else "Unknown", + "plan_id": sub.plan_id, + "plan_name": plan.name if plan else None, + "status": sub.status, + "billing_cycle": sub.billing_cycle, + "current_period_start": sub.current_period_start.isoformat() + "Z" if sub.current_period_start else None, + "current_period_end": sub.current_period_end.isoformat() + "Z" if sub.current_period_end else None, + "trial_ends_at": sub.trial_ends_at.isoformat() + "Z" if sub.trial_ends_at else None, + "stripe_subscription_id": sub.stripe_subscription_id, + "overage_enabled": sub.overage_enabled, + "cancelled_at": sub.cancelled_at.isoformat() + "Z" if sub.cancelled_at else None, + "cancel_at_period_end": sub.cancel_at_period_end, + }, message="Subscription retrieved successfully") + + except Exception as e: + logger.error(f"[Billing] Get subscription error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/billing/subscriptions/", methods=["PATCH"]) +@superadmin_required +@superadmin_audit_log(action="subscription.update", resource_type="subscription") +def update_subscription(org_id): + """Update subscription plan or billing cycle for an organization.""" + try: + from gatehouse_app.models.billing.subscription import Subscription + from gatehouse_app.models.billing.plan import Plan + + org = Organization.query.get(org_id) + if not org: + return api_response( + success=False, + message="Organization not found", + status=404, + error_type="NOT_FOUND", + ) + + sub = Subscription.query.filter_by(organization_id=org_id).first() + if not sub: + return api_response( + success=False, + message="No subscription found for this organization", + status=404, + error_type="NOT_FOUND", + ) + + data = request.json or {} + + if "plan_id" in data: + plan = Plan.query.get(data["plan_id"]) + if not plan: + return api_response( + success=False, + message="Plan not found", + status=404, + error_type="NOT_FOUND", + ) + sub.plan_id = data["plan_id"] + + if "billing_cycle" in data: + if data["billing_cycle"] not in ["monthly", "yearly"]: + return api_response( + success=False, + message="Invalid billing cycle. Must be 'monthly' or 'yearly'", + status=400, + error_type="VALIDATION_ERROR", + ) + sub.billing_cycle = data["billing_cycle"] + + db.session.commit() + + return api_response(data={ + "id": sub.id, + "organization_id": sub.organization_id, + "plan_id": sub.plan_id, + "billing_cycle": sub.billing_cycle, + }, message="Subscription updated successfully") + + except Exception as e: + db.session.rollback() + logger.error(f"[Billing] Update subscription error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/billing/subscriptions//cancel", methods=["POST"]) +@superadmin_required +@superadmin_audit_log(action="subscription.cancel", resource_type="subscription") +def cancel_subscription(org_id): + """Cancel subscription at period end.""" + try: + from gatehouse_app.models.billing.subscription import Subscription + + org = Organization.query.get(org_id) + if not org: + return api_response( + success=False, + message="Organization not found", + status=404, + error_type="NOT_FOUND", + ) + + sub = Subscription.query.filter_by(organization_id=org_id).first() + if not sub: + return api_response( + success=False, + message="No subscription found for this organization", + status=404, + error_type="NOT_FOUND", + ) + + sub.cancel_at_period_end = True + sub.status = "cancelled" + db.session.commit() + + return api_response(data={ + "id": sub.id, + "cancel_at_period_end": True, + "status": sub.status, + }, message="Subscription will be cancelled at period end") + + except Exception as e: + db.session.rollback() + logger.error(f"[Billing] Cancel subscription error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/billing/subscriptions//trial", methods=["POST"]) +@superadmin_required +@superadmin_audit_log(action="subscription.extend_trial", resource_type="subscription") +def extend_trial(org_id): + """Extend trial period for an organization.""" + try: + from gatehouse_app.models.billing.subscription import Subscription + from datetime import timedelta + + data = request.json or {} + days = data.get("days", 30) + + if not isinstance(days, int) or days < 1: + return api_response( + success=False, + message="Days must be a positive integer", + status=400, + error_type="VALIDATION_ERROR", + ) + + org = Organization.query.get(org_id) + if not org: + return api_response( + success=False, + message="Organization not found", + status=404, + error_type="NOT_FOUND", + ) + + sub = Subscription.query.filter_by(organization_id=org_id).first() + if not sub: + return api_response( + success=False, + message="No subscription found for this organization", + status=404, + error_type="NOT_FOUND", + ) + + # Extend trial + if sub.trial_ends_at: + sub.trial_ends_at = sub.trial_ends_at + timedelta(days=days) + else: + sub.trial_ends_at = datetime.now(timezone.utc) + timedelta(days=days) + + sub.status = "trial" + db.session.commit() + + return api_response(data={ + "id": sub.id, + "trial_ends_at": sub.trial_ends_at.isoformat() + "Z", + "days_added": days, + }, message=f"Trial extended by {days} days") + + except Exception as e: + db.session.rollback() + logger.error(f"[Billing] Extend trial error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) diff --git a/gatehouse_app/api/v1/superadmin/cas.py b/gatehouse_app/api/v1/superadmin/cas.py new file mode 100644 index 0000000..6a210a5 --- /dev/null +++ b/gatehouse_app/api/v1/superadmin/cas.py @@ -0,0 +1,56 @@ +"""Superadmin SSH CA management endpoints.""" +import logging +from flask import request +from gatehouse_app.api.v1.superadmin import superadmin_bp +from gatehouse_app.utils.response import api_response +from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log +from gatehouse_app.extensions import db + +logger = logging.getLogger(__name__) + + +@superadmin_bp.route("/organizations//cas/", methods=["DELETE"]) +@superadmin_required +@superadmin_audit_log(action="ca.delete", resource_type="CA") +def delete_org_ca(org_id, ca_id): + """Soft-delete an SSH CA for an organization. + + Sets is_active=False and deleted_at=now(). + """ + from gatehouse_app.models.ssh_ca.ca import CA + from gatehouse_app.models.organization.organization import Organization + + org = Organization.query.filter_by(id=org_id, deleted_at=None).first() + if not org: + 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.is_active = False + ca.delete(soft=True) + db.session.commit() + + return api_response(data={"ca_id": ca_id}, message="CA deleted successfully") + + except Exception: + db.session.rollback() + logger.exception(f"Failed to delete CA {ca_id}") + return api_response( + success=False, + message="Failed to delete CA", + status=500, + error_type="SERVER_ERROR" + ) diff --git a/gatehouse_app/api/v1/superadmin/organization_members.py b/gatehouse_app/api/v1/superadmin/organization_members.py new file mode 100644 index 0000000..c853875 --- /dev/null +++ b/gatehouse_app/api/v1/superadmin/organization_members.py @@ -0,0 +1,456 @@ +"""Superadmin organization member management endpoints.""" +import logging +from flask import request, g +from marshmallow import ValidationError +from gatehouse_app.api.v1.superadmin import superadmin_bp +from gatehouse_app.utils.response import api_response +from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log +from gatehouse_app.models.organization.organization_member import OrganizationMember +from gatehouse_app.models.organization.organization import Organization +from gatehouse_app.models.user.user import User +from gatehouse_app.extensions import db +from gatehouse_app.utils.constants import OrganizationRole + +logger = logging.getLogger(__name__) + + +class ListMembersSchema: + """Schema for list members query params.""" + + @staticmethod + def load(args): + """Parse and validate query parameters.""" + try: + page = max(1, int(args.get("page", 1))) + per_page = min(100, max(1, int(args.get("per_page", 20)))) + except (ValueError, TypeError): + page = 1 + per_page = 20 + + search = args.get("search") + role = args.get("role") + + return { + "page": page, + "per_page": per_page, + "search": search, + "role": role, + } + + +class AddMemberSchema: + """Schema for adding a member.""" + + @staticmethod + def load(data): + """Parse and validate add member data.""" + errors = {} + + user_id = data.get("user_id") + if not user_id: + errors["user_id"] = ["User ID is required"] + + role_str = data.get("role", "member") + try: + role = OrganizationRole(role_str) + except ValueError: + errors["role"] = [f"Invalid role. Must be one of: {', '.join(r.value for r in OrganizationRole)}"] + + if errors: + raise ValidationError(errors) + + return {"user_id": user_id, "role": role} + + +class UpdateMemberSchema: + """Schema for updating a member role.""" + + @staticmethod + def load(data): + """Parse and validate update data.""" + errors = {} + + role_str = data.get("role") + if not role_str: + errors["role"] = ["Role is required"] + + try: + role = OrganizationRole(role_str) + except ValueError: + errors["role"] = [f"Invalid role. Must be one of: {', '.join(r.value for r in OrganizationRole)}"] + + if errors: + raise ValidationError(errors) + + return {"role": role} + + +@superadmin_bp.route("/organizations//members", methods=["GET"]) +@superadmin_required +def list_organization_members(org_id): + """List all members of an organization. + + Query params: + page: Page number (default 1) + per_page: Items per page (default 20) + search: Search by user email or name + role: Filter by role + """ + try: + # Verify org exists + org = Organization.query.get(org_id) + if not org: + return api_response( + success=False, + message="Organization not found", + status=404, + error_type="NOT_FOUND", + ) + + schema = ListMembersSchema() + params = schema.load(request.args) + + query = OrganizationMember.query.filter_by(organization_id=org_id, deleted_at=None) + + # Search by user email or name + if params["search"]: + search_term = f"%{params['search']}%" + query = query.join(User).filter( + db.or_( + User.email.ilike(search_term), + User.full_name.ilike(search_term), + ) + ) + + # Filter by role + if params["role"]: + try: + role = OrganizationRole(params["role"]) + query = query.filter(OrganizationMember.role == role) + except ValueError: + pass # Ignore invalid role filter + + # Order by joined_at desc + query = query.order_by(OrganizationMember.joined_at.desc()) + + # Paginate + pagination = query.paginate(page=params["page"], per_page=params["per_page"], error_out=False) + + # Build response + items = [] + for member in pagination.items: + user = User.query.get(member.user_id) + item = { + "user_id": member.user_id, + "organization_id": member.organization_id, + "role": member.role.value, + "joined_at": member.joined_at.isoformat() + "Z" if member.joined_at else None, + "user": { + "id": user.id, + "email": user.email, + "full_name": user.full_name, + "is_active": user.is_active, + } if user else {"id": member.user_id, "email": "[deleted]", "full_name": None, "is_active": False}, + } + items.append(item) + + return api_response( + data={ + "items": items, + "total": pagination.total, + "page": params["page"], + "per_page": params["per_page"], + "pages": pagination.pages, + }, + message="Members retrieved successfully", + ) + + except Exception as e: + logger.error(f"[SuperadminOrg] List members error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/organizations//members", methods=["POST"]) +@superadmin_required +@superadmin_audit_log(action="add_member", resource_type="organization_member") +def add_organization_member(org_id): + """Add a user to an organization. + + Body: + user_id: User UUID to add + role: Role (owner, admin, member, guest) + """ + try: + schema = AddMemberSchema() + data = schema.load(request.json or {}) + + # Verify org exists + org = Organization.query.get(org_id) + if not org: + return api_response( + success=False, + message="Organization not found", + status=404, + error_type="NOT_FOUND", + ) + + # Verify user exists + user = User.query.get(data["user_id"]) + if not user: + return api_response( + success=False, + message="User not found", + status=404, + error_type="NOT_FOUND", + ) + + # Check if already a member + existing = OrganizationMember.query.filter_by( + user_id=data["user_id"], + organization_id=org_id, + deleted_at=None, + ).first() + if existing: + return api_response( + success=False, + message="User is already a member of this organization", + status=400, + error_type="ALREADY_EXISTS", + ) + + # Create membership + member = OrganizationMember( + user_id=data["user_id"], + organization_id=org_id, + role=data["role"], + invited_by_id=g.current_superadmin.id, + invited_at=db.func.now(), + joined_at=db.func.now(), + ) + db.session.add(member) + db.session.commit() + + logger.info(f"[SuperadminOrg] Added user {data['user_id']} to org {org_id} as {data['role'].value}") + + return api_response( + data={ + "member": { + "user_id": member.user_id, + "organization_id": member.organization_id, + "role": member.role.value, + "joined_at": member.joined_at.isoformat() + "Z" if member.joined_at else None, + } + }, + 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, + ) + except Exception as e: + logger.error(f"[SuperadminOrg] Add member error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/organizations//members/", methods=["PATCH"]) +@superadmin_required +@superadmin_audit_log(action="update_member_role", resource_type="organization_member") +def update_organization_member(org_id, user_id): + """Update a member's role. + + Body: + role: New role (owner, admin, member, guest) + """ + try: + schema = UpdateMemberSchema() + data = schema.load(request.json or {}) + + # Find member + member = OrganizationMember.query.filter_by( + user_id=user_id, + organization_id=org_id, + deleted_at=None, + ).first() + if not member: + return api_response( + success=False, + message="Member not found", + status=404, + error_type="NOT_FOUND", + ) + + # Update role + old_role = member.role + member.role = data["role"] + db.session.commit() + + logger.info(f"[SuperadminOrg] Updated member {user_id} role from {old_role.value} to {data['role'].value}") + + return api_response( + data={ + "member": { + "user_id": member.user_id, + "organization_id": member.organization_id, + "role": member.role.value, + } + }, + 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, + ) + except Exception as e: + logger.error(f"[SuperadminOrg] Update member error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/organizations//members/", methods=["DELETE"]) +@superadmin_required +@superadmin_audit_log(action="remove_member", resource_type="organization_member") +def remove_organization_member(org_id, user_id): + """Remove a user from an organization.""" + try: + # Find member + member = OrganizationMember.query.filter_by( + user_id=user_id, + organization_id=org_id, + deleted_at=None, + ).first() + if not member: + return api_response( + success=False, + message="Member not found", + status=404, + error_type="NOT_FOUND", + ) + + # Prevent removing owner without transferring ownership + if member.role == OrganizationRole.OWNER: + return api_response( + success=False, + message="Cannot remove organization owner. Transfer ownership first.", + status=400, + error_type="AUTHORIZATION_ERROR", + ) + + # Soft delete + from datetime import datetime, timezone + member.deleted_at = datetime.now(timezone.utc) + db.session.commit() + + logger.info(f"[SuperadminOrg] Removed user {user_id} from org {org_id}") + + return api_response(message="Member removed successfully") + + except Exception as e: + logger.error(f"[SuperadminOrg] Remove member error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/organizations//transfer-ownership/", methods=["POST"]) +@superadmin_required +@superadmin_audit_log(action="transfer_ownership", resource_type="organization_member") +def transfer_organization_ownership(org_id, user_id): + """Transfer organization ownership to another member. + + The target user must already be a member of the organization. + The current owner will be changed to admin role. + """ + try: + # Verify org exists + org = Organization.query.get(org_id) + if not org: + return api_response( + success=False, + message="Organization not found", + status=404, + error_type="NOT_FOUND", + ) + + # Find current owner + current_owner = OrganizationMember.query.filter_by( + organization_id=org_id, + role=OrganizationRole.OWNER, + deleted_at=None, + ).first() + if not current_owner: + return api_response( + success=False, + message="Current owner not found", + status=404, + error_type="NOT_FOUND", + ) + + # Find target user as member + target_member = OrganizationMember.query.filter_by( + user_id=user_id, + organization_id=org_id, + deleted_at=None, + ).first() + if not target_member: + return api_response( + success=False, + message="Target user is not a member of this organization", + status=400, + error_type="AUTHORIZATION_ERROR", + ) + + # Transfer ownership + current_owner.role = OrganizationRole.ADMIN + target_member.role = OrganizationRole.OWNER + db.session.commit() + + logger.warning( + f"[SuperadminOrg] TRANSFERRED OWNERSHIP: org={org_id} from user={current_owner.user_id} to user={user_id}" + ) + + return api_response( + data={ + "owner": { + "user_id": target_member.user_id, + "role": target_member.role.value, + } + }, + message="Ownership transferred successfully", + ) + + except Exception as e: + logger.error(f"[SuperadminOrg] Transfer ownership error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) diff --git a/gatehouse_app/api/v1/superadmin/organizations.py b/gatehouse_app/api/v1/superadmin/organizations.py new file mode 100644 index 0000000..6ddf622 --- /dev/null +++ b/gatehouse_app/api/v1/superadmin/organizations.py @@ -0,0 +1,254 @@ +"""Superadmin organization management endpoints.""" +import logging +from flask import request +from gatehouse_app.api.v1.superadmin import superadmin_bp +from gatehouse_app.utils.response import api_response +from gatehouse_app.services.superadmin_organization_service import SuperadminOrganizationService +from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log + +logger = logging.getLogger(__name__) + + +class ListOrganizationsSchema: + """Schema for list organizations query params.""" + + @staticmethod + def load(args): + """Parse and validate query parameters.""" + try: + page = max(1, int(args.get("page", 1))) + per_page = min(100, max(1, int(args.get("per_page", 20)))) + except (ValueError, TypeError): + page = 1 + per_page = 20 + + search = args.get("search") + status = args.get("status") + plan_slug = args.get("plan_slug") + + return { + "page": page, + "per_page": per_page, + "search": search, + "status": status, + "plan_slug": plan_slug, + } + + +class UpdateOrganizationSchema: + """Schema for updating an organization.""" + + @staticmethod + def load(data): + """Parse and validate update data.""" + result = {} + + if "name" in data: + result["name"] = data["name"] + if "description" in data: + result["description"] = data["description"] + if "is_active" in data: + result["is_active"] = bool(data["is_active"]) + + return result + + +@superadmin_bp.route("/organizations", methods=["GET"]) +@superadmin_required +def list_organizations(): + """List all organizations with pagination and filtering. + + Query params: + page: Page number (default 1) + per_page: Items per page (default 20, max 100) + search: Search by name or slug + status: Filter by status (active, suspended) + plan_slug: Filter by plan slug + """ + try: + schema = ListOrganizationsSchema() + params = schema.load(request.args) + + result = SuperadminOrganizationService.list_organizations( + page=params["page"], + per_page=params["per_page"], + search=params["search"], + status=params["status"], + plan_slug=params["plan_slug"], + ) + + return api_response(data=result, message="Organizations retrieved successfully") + + except Exception as e: + logger.error(f"[SuperadminOrg] List organizations error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/organizations/", methods=["GET"]) +@superadmin_required +def get_organization(org_id): + """Get detailed organization information. + + Returns org details including member count, owner info, and active sessions. + """ + try: + result = SuperadminOrganizationService.get_organization_detail(org_id) + return api_response(data={"organization": result}, message="Organization retrieved successfully") + + except ValueError as e: + return api_response( + success=False, + message=str(e), + status=404, + error_type="NOT_FOUND", + ) + except Exception as e: + logger.error(f"[SuperadminOrg] Get organization error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/organizations/", methods=["PATCH"]) +@superadmin_required +@superadmin_audit_log(action="update_organization", resource_type="organization") +def update_organization(org_id): + """Update organization details. + + Body: + name: New name (optional) + description: New description (optional) + is_active: New active status (optional) + """ + try: + schema = UpdateOrganizationSchema() + data = schema.load(request.json or {}) + + if not data: + return api_response( + success=False, + message="No update data provided", + status=400, + error_type="VALIDATION_ERROR", + ) + + org = SuperadminOrganizationService.update_organization(org_id, **data) + + return api_response( + data={"organization": org.to_dict()}, + message="Organization updated successfully", + ) + + except ValueError as e: + return api_response( + success=False, + message=str(e), + status=404, + error_type="NOT_FOUND", + ) + except Exception as e: + logger.error(f"[SuperadminOrg] Update organization error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/organizations//suspend", methods=["POST"]) +@superadmin_required +@superadmin_audit_log(action="suspend_organization", resource_type="organization") +def suspend_organization(org_id): + """Suspend an organization. + + Sets is_active=False and invalidates all member sessions. + """ + try: + org = SuperadminOrganizationService.suspend_organization(org_id) + + return api_response( + data={"organization": org.to_dict()}, + message="Organization suspended successfully", + ) + + except ValueError as e: + return api_response( + success=False, + message=str(e), + status=404, + error_type="NOT_FOUND", + ) + except Exception as e: + logger.error(f"[SuperadminOrg] Suspend organization error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/organizations//unsuspend", methods=["POST"]) +@superadmin_required +@superadmin_audit_log(action="unsuspend_organization", resource_type="organization") +def unsuspend_organization(org_id): + """Restore a suspended organization.""" + try: + org = SuperadminOrganizationService.restore_organization(org_id) + + return api_response( + data={"organization": org.to_dict()}, + message="Organization restored successfully", + ) + + except ValueError as e: + return api_response( + success=False, + message=str(e), + status=404, + error_type="NOT_FOUND", + ) + except Exception as e: + logger.error(f"[SuperadminOrg] Unsuspend organization error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/organizations/", methods=["DELETE"]) +@superadmin_required +@superadmin_audit_log(action="delete_organization", resource_type="organization") +def delete_organization(org_id): + """Soft-delete an organization.""" + try: + SuperadminOrganizationService.soft_delete_organization(org_id) + + return api_response(message="Organization deleted successfully") + + except ValueError as e: + return api_response( + success=False, + message=str(e), + status=404, + error_type="NOT_FOUND", + ) + except Exception as e: + logger.error(f"[SuperadminOrg] Delete organization error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) diff --git a/gatehouse_app/api/v1/superadmin/usage_analytics.py b/gatehouse_app/api/v1/superadmin/usage_analytics.py new file mode 100644 index 0000000..2f4fd18 --- /dev/null +++ b/gatehouse_app/api/v1/superadmin/usage_analytics.py @@ -0,0 +1,330 @@ +"""Superadmin usage and analytics endpoints.""" +import logging +from datetime import datetime, timezone +from flask import request +from gatehouse_app.api.v1.superadmin import superadmin_bp +from gatehouse_app.utils.response import api_response +from gatehouse_app.services.superadmin_usage_service import SuperadminUsageService +from gatehouse_app.services.superadmin_analytics_service import SuperadminAnalyticsService +from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log + +logger = logging.getLogger(__name__) + + +# ============ Analytics Endpoints ============ + +@superadmin_bp.route("/analytics/dashboard", methods=["GET"]) +@superadmin_required +def get_dashboard_stats(): + """Get dashboard statistics for the overview page. + + Returns aggregated stats: org counts, user counts, sessions, recent signups. + """ + try: + stats = SuperadminAnalyticsService.get_dashboard_stats() + return api_response(data=stats, message="Dashboard stats retrieved successfully") + + except Exception as e: + logger.error(f"[SuperadminAnalytics] Dashboard stats error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/analytics/signup-trends", methods=["GET"]) +@superadmin_required +def get_signup_trends(): + """Get signup trends over time. + + Query params: + days: Number of days to analyze (default 30, max 365) + """ + try: + days = min(365, max(1, int(request.args.get("days", 30)))) + trends = SuperadminAnalyticsService.get_signup_trends(days) + return api_response(data=trends, message="Signup trends retrieved successfully") + + except Exception as e: + logger.error(f"[SuperadminAnalytics] Signup trends error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/analytics/org-distribution", methods=["GET"]) +@superadmin_required +def get_org_distribution(): + """Get organization distribution by size.""" + try: + distribution = SuperadminAnalyticsService.get_org_distribution() + return api_response(data=distribution, message="Organization distribution retrieved successfully") + + except Exception as e: + logger.error(f"[SuperadminAnalytics] Org distribution error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/analytics/recent-activity", methods=["GET"]) +@superadmin_required +def get_recent_activity(): + """Get recent superadmin actions. + + Query params: + limit: Maximum number of entries (default 20, max 100) + """ + try: + limit = min(100, max(1, int(request.args.get("limit", 20)))) + activity = SuperadminAnalyticsService.get_recent_activity(limit) + return api_response(data={"items": activity}, message="Recent activity retrieved successfully") + + except Exception as e: + logger.error(f"[SuperadminAnalytics] Recent activity error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +# ============ Usage Endpoints ============ + +@superadmin_bp.route("/usage/", methods=["GET"]) +@superadmin_required +def get_organization_usage(org_id): + """Get current usage for an organization. + + Returns current period usage metrics: user count, active sessions. + """ + try: + usage = SuperadminUsageService.get_current_usage(org_id) + return api_response(data=usage, message="Usage retrieved successfully") + + except ValueError as e: + return api_response( + success=False, + message=str(e), + status=404, + error_type="NOT_FOUND", + ) + except Exception as e: + logger.error(f"[SuperadminUsage] Get usage error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/usage//history", methods=["GET"]) +@superadmin_required +def get_usage_history(org_id): + """Get usage history for an organization. + + Query params: + metric: Metric type (users, sessions) - default users + days: Number of days of history (default 30, max 365) + """ + try: + metric = request.args.get("metric", "users") + days = min(365, max(1, int(request.args.get("days", 30)))) + + history = SuperadminUsageService.get_usage_history(org_id, metric, days) + return api_response(data=history, message="Usage history retrieved successfully") + + except ValueError as e: + return api_response( + success=False, + message=str(e), + status=404, + error_type="NOT_FOUND", + ) + except Exception as e: + logger.error(f"[SuperadminUsage] Get usage history error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/usage//seats", methods=["GET"]) +@superadmin_required +def get_seat_count(org_id): + """Get maximum seat count for billing period. + + Query params: + year: Year (default current year) + month: Month (default current month) + """ + try: + year = int(request.args.get("year", datetime.now().year)) + month = int(request.args.get("month", datetime.now().month)) + + seats = SuperadminUsageService.get_seat_count_for_period(org_id, year, month) + return api_response(data=seats, message="Seat count retrieved successfully") + + except ValueError as e: + return api_response( + success=False, + message=str(e), + status=404, + error_type="NOT_FOUND", + ) + except Exception as e: + logger.error(f"[SuperadminUsage] Get seat count error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/usage//adjust", methods=["POST"]) +@superadmin_required +@superadmin_audit_log(action="usage_adjustment", resource_type="usage") +def adjust_usage(org_id): + """Apply a manual usage adjustment. + + Body: + metric: Metric to adjust + adjustment: Positive (credit) or negative (charge) + reason: Reason for adjustment + """ + try: + data = request.json or {} + + metric = data.get("metric", "users") + adjustment = data.get("adjustment", 0) + reason = data.get("reason", "") + + if not reason: + return api_response( + success=False, + message="Reason is required for usage adjustment", + status=400, + error_type="VALIDATION_ERROR", + ) + + result = SuperadminUsageService.adjust_usage( + org_id=org_id, + metric=metric, + adjustment=adjustment, + reason=reason, + superadmin_id="", # Will be filled from decorator + ) + + return api_response(data=result, message="Usage adjustment applied successfully") + + except ValueError as e: + return api_response( + success=False, + message=str(e), + status=404, + error_type="NOT_FOUND", + ) + except Exception as e: + logger.error(f"[SuperadminUsage] Adjust usage error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +# ============ Invoice Data Endpoint ============ + +@superadmin_bp.route("/invoice-data/", methods=["GET"]) +@superadmin_required +def get_invoice_data(org_id): + """Get all data needed to generate an invoice for an organization. + + Returns organization info, plan details, usage, and subscription status. + """ + try: + from datetime import datetime + from gatehouse_app.models.organization.organization import Organization + + org = Organization.query.get(org_id) + if not org: + return api_response( + success=False, + message="Organization not found", + status=404, + error_type="NOT_FOUND", + ) + + # Get seat count for current month + now = datetime.now(timezone.utc) + seats = SuperadminUsageService.get_seat_count_for_period(org_id, now.year, now.month) + + # Get owner + owner = org.get_owner() + + invoice_data = { + "organization": { + "id": org.id, + "name": org.name, + "slug": org.slug, + "owner_email": owner.email if owner else None, + "created_at": org.created_at.isoformat() + "Z" if org.created_at else None, + }, + "billing_period": { + "year": now.year, + "month": now.month, + "start": seats["period_start"], + "end": seats["period_end"], + }, + "usage": { + "max_seats": seats["max_seats"], + "current_seats": seats["current_seats"], + "included_seats": 0, # Would come from plan + "overage": max(0, seats["current_seats"]), # Simplified + }, + "subscription": { + "status": "active" if org.is_active else "suspended", + "is_active": org.is_active, + }, + "line_items": [ + { + "description": "Base subscription", + "quantity": 1, + "unit_price": 0, # Would come from plan + "total": 0, + }, + { + "description": f"User seats ({seats['current_seats']})", + "quantity": seats["current_seats"], + "unit_price": 0, # Would come from plan per-seat price + "total": 0, + }, + ], + "total": 0, # Would be calculated + "generated_at": now.isoformat() + "Z", + } + + return api_response(data=invoice_data, message="Invoice data retrieved successfully") + + except Exception as e: + logger.error(f"[SuperadminAnalytics] Invoice data error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) diff --git a/gatehouse_app/api/v1/superadmin/users.py b/gatehouse_app/api/v1/superadmin/users.py new file mode 100644 index 0000000..fcac4aa --- /dev/null +++ b/gatehouse_app/api/v1/superadmin/users.py @@ -0,0 +1,516 @@ +"""Superadmin user management endpoints.""" +import logging +from flask import request, g +from gatehouse_app.api.v1.superadmin import superadmin_bp +from gatehouse_app.utils.response import api_response +from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log +from gatehouse_app.models.user.user import User +from gatehouse_app.models.user.session import Session +from gatehouse_app.models.organization.organization import Organization +from gatehouse_app.models.organization.organization_member import OrganizationMember +from gatehouse_app.extensions import db + +logger = logging.getLogger(__name__) + + +@superadmin_bp.route("/users", methods=["GET"]) +@superadmin_required +def list_users(): + """Get paginated list of users with optional filters. + + Query params: + page: Page number (default 1) + per_page: Items per page (default 20, max 100) + organization_id: Filter by organization + status: Filter by status (active/suspended) + search: Search by email or name + """ + try: + page = max(1, int(request.args.get("page", 1))) + per_page = min(100, max(1, int(request.args.get("per_page", 20)))) + org_id = request.args.get("organization_id") + status = request.args.get("status") + search = request.args.get("search", "").strip() + + # Base query + query = User.query.filter(User.deleted_at.is_(None)) + + # Filter by organization + if org_id: + member_user_ids = db.session.query(OrganizationMember.user_id).filter( + OrganizationMember.organization_id == org_id, + OrganizationMember.deleted_at.is_(None), + ).all() + user_ids = [m.user_id for m in member_user_ids] + query = query.filter(User.id.in_(user_ids)) + + # Filter by status + if status == "suspended": + query = query.filter(User.status == "GLOBAL_SUSPENDED") + elif status == "active": + query = query.filter(User.status != "GLOBAL_SUSPENDED") + + # Search by email or name + if search: + search_filter = f"%{search}%" + query = query.filter( + db.or_( + User.email.ilike(search_filter), + User.full_name.ilike(search_filter), + ) + ) + + # Order by created_at desc + query = query.order_by(User.created_at.desc()) + + # Paginate + total = query.count() + users = query.offset((page - 1) * per_page).limit(per_page).all() + + # Get org memberships for each user + items = [] + for user in users: + # Get organization memberships + memberships = db.session.query(OrganizationMember).filter( + OrganizationMember.user_id == user.id, + OrganizationMember.deleted_at.is_(None), + ).all() + + orgs = [] + for m in memberships: + org = Organization.query.get(m.organization_id) + if org: + orgs.append({ + "org_id": org.id, + "org_name": org.name, + "role": m.role, + "joined_at": m.created_at.isoformat() + "Z" if m.created_at else None, + }) + + # Get active session count + active_sessions = Session.query.filter( + Session.user_id == user.id, + Session.deleted_at.is_(None), + Session.status == "active", + ).count() + + items.append({ + "id": user.id, + "email": user.email, + "full_name": user.full_name, + "status": user.status, + "mfa_enabled": user.mfa_enabled if hasattr(user, 'mfa_enabled') else False, + "org_count": len(orgs), + "orgs": orgs, + "active_sessions": active_sessions, + "last_login_at": user.last_login_at.isoformat() + "Z" if user.last_login_at else None, + "created_at": user.created_at.isoformat() + "Z" if user.created_at else None, + }) + + return api_response(data={ + "items": items, + "total": total, + "page": page, + "per_page": per_page, + "pages": (total + per_page - 1) // per_page if per_page > 0 else 0, + }, message="Users retrieved successfully") + + except Exception as e: + logger.error(f"[SuperadminUsers] List users error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/users/", methods=["GET"]) +@superadmin_required +def get_user(user_id): + """Get detailed user information. + + Returns user + all org memberships + active sessions + security methods. + """ + try: + user = User.query.get(user_id) + if not user or user.deleted_at is not None: + return api_response( + success=False, + message="User not found", + status=404, + error_type="NOT_FOUND", + ) + + # Get organization memberships + memberships = db.session.query(OrganizationMember).filter( + OrganizationMember.user_id == user_id, + OrganizationMember.deleted_at.is_(None), + ).all() + + orgs = [] + for m in memberships: + org = Organization.query.get(m.organization_id) + if org: + orgs.append({ + "org_id": org.id, + "org_name": org.name, + "org_slug": org.slug, + "role": m.role, + "joined_at": m.created_at.isoformat() + "Z" if m.created_at else None, + }) + + # Get active sessions + sessions = Session.query.filter( + Session.user_id == user_id, + Session.deleted_at.is_(None), + Session.status == "active", + ).all() + + active_sessions = [{ + "id": s.id, + "ip_address": s.ip_address, + "user_agent": s.user_agent, + "created_at": s.created_at.isoformat() + "Z" if s.created_at else None, + "last_active_at": s.last_active_at.isoformat() + "Z" if hasattr(s, 'last_active_at') and s.last_active_at else None, + } for s in sessions] + + # Get security methods (simplified - would need UserSecurityMethod model) + security_methods = [] + if hasattr(user, 'totp_enabled') and user.totp_enabled: + security_methods.append({"type": "totp", "enabled": True}) + if hasattr(user, 'webauthn_enabled') and user.webauthn_enabled: + security_methods.append({"type": "webauthn", "enabled": True}) + + return api_response(data={ + "user": { + "id": user.id, + "email": user.email, + "full_name": user.full_name, + "status": user.status, + "mfa_enabled": user.mfa_enabled if hasattr(user, 'mfa_enabled') else False, + "last_login_at": user.last_login_at.isoformat() + "Z" if user.last_login_at else None, + "created_at": user.created_at.isoformat() + "Z" if user.created_at else None, + }, + "organizations": orgs, + "active_sessions": active_sessions, + "security_methods": security_methods, + }, message="User retrieved successfully") + + except Exception as e: + logger.error(f"[SuperadminUsers] Get user error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/users//suspend", methods=["POST"]) +@superadmin_required +@superadmin_audit_log(action="user.suspend", resource_type="user") +def suspend_user(user_id): + """Globally suspend a user (sets status=GLOBAL_SUSPENDED).""" + try: + user = User.query.get(user_id) + if not user or user.deleted_at is not None: + return api_response( + success=False, + message="User not found", + status=404, + error_type="NOT_FOUND", + ) + + if user.status == "GLOBAL_SUSPENDED": + return api_response( + success=False, + message="User is already suspended", + status=400, + error_type="VALIDATION_ERROR", + ) + + user.status = "GLOBAL_SUSPENDED" + db.session.commit() + + # Revoke all sessions + revoked_count = Session.query.filter( + Session.user_id == user_id, + Session.deleted_at.is_(None), + ).update({"status": "revoked", "deleted_at": db.func.now()}) + db.session.commit() + + logger.warning(f"[SuperadminUsers] User {user_id} globally suspended by {getattr(g, 'current_superadmin', {}).get('id', 'unknown')}") + + return api_response(data={ + "user": { + "id": user.id, + "email": user.email, + "status": user.status, + }, + "sessions_revoked": revoked_count, + }, message="User suspended successfully") + + except Exception as e: + db.session.rollback() + logger.error(f"[SuperadminUsers] Suspend user error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/users//unsuspend", methods=["POST"]) +@superadmin_required +@superadmin_audit_log(action="user.unsuspend", resource_type="user") +def unsuspend_user(user_id): + """Remove global suspension from a user.""" + try: + user = User.query.get(user_id) + if not user or user.deleted_at is not None: + return api_response( + success=False, + message="User not found", + status=404, + error_type="NOT_FOUND", + ) + + if user.status != "GLOBAL_SUSPENDED": + return api_response( + success=False, + message="User is not suspended", + status=400, + error_type="VALIDATION_ERROR", + ) + + user.status = "active" + db.session.commit() + + return api_response(data={ + "user": { + "id": user.id, + "email": user.email, + "status": user.status, + }, + }, message="User unsuspended successfully") + + except Exception as e: + db.session.rollback() + logger.error(f"[SuperadminUsers] Unsuspend user error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/users//reset-password", methods=["POST"]) +@superadmin_required +@superadmin_audit_log(action="user.reset_password", resource_type="user") +def reset_user_password(user_id): + """Trigger password reset email flow for user.""" + try: + user = User.query.get(user_id) + if not user or user.deleted_at is not None: + return api_response( + success=False, + message="User not found", + status=404, + error_type="NOT_FOUND", + ) + + # In production, this would call AuthService.send_password_reset_email(user.email) + # For now, just log and return success + logger.info(f"[SuperadminUsers] Password reset requested for {user.email} by superadmin") + + return api_response(data={ + "email": user.email, + }, message="Password reset email sent successfully") + + except Exception as e: + logger.error(f"[SuperadminUsers] Reset password error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/users//sessions", methods=["DELETE"]) +@superadmin_required +@superadmin_audit_log(action="user.revoke_sessions", resource_type="user") +def revoke_user_sessions(user_id): + """Revoke all sessions for a user.""" + try: + user = User.query.get(user_id) + if not user or user.deleted_at is not None: + return api_response( + success=False, + message="User not found", + status=404, + error_type="NOT_FOUND", + ) + + # Revoke all sessions + result = Session.query.filter( + Session.user_id == user_id, + Session.deleted_at.is_(None), + ).update({"status": "revoked", "deleted_at": db.func.now()}) + db.session.commit() + + return api_response(data={ + "user_id": user_id, + "count": result, + }, message=f"All sessions revoked ({result} sessions)") + + except Exception as e: + db.session.rollback() + logger.error(f"[SuperadminUsers] Revoke sessions error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/users//add-to-org/", methods=["POST"]) +@superadmin_required +@superadmin_audit_log(action="user.add_to_org", resource_type="user") +def add_user_to_org(user_id, org_id): + """Add a user to an organization with specified role.""" + try: + data = request.json or {} + role = data.get("role", "member") + + valid_roles = ["member", "admin", "owner"] + if role not in valid_roles: + return api_response( + success=False, + message=f"Invalid role. Must be one of: {', '.join(valid_roles)}", + status=400, + error_type="VALIDATION_ERROR", + ) + + user = User.query.get(user_id) + if not user or user.deleted_at is not None: + return api_response( + success=False, + message="User not found", + status=404, + error_type="NOT_FOUND", + ) + + org = Organization.query.get(org_id) + if not org or org.deleted_at is not None: + return api_response( + success=False, + message="Organization not found", + status=404, + error_type="NOT_FOUND", + ) + + # Check if already a member + existing = OrganizationMember.query.filter( + OrganizationMember.user_id == user_id, + OrganizationMember.organization_id == org_id, + OrganizationMember.deleted_at.is_(None), + ).first() + + if existing: + return api_response( + success=False, + message="User is already a member of this organization", + status=400, + error_type="VALIDATION_ERROR", + ) + + # Create membership + membership = OrganizationMember( + user_id=user_id, + organization_id=org_id, + role=role, + ) + db.session.add(membership) + db.session.commit() + + logger.info(f"[SuperadminUsers] User {user_id} added to org {org_id} as {role} by superadmin") + + return api_response(data={ + "user_id": user_id, + "organization_id": org_id, + "role": role, + "joined_at": membership.created_at.isoformat() + "Z" if membership.created_at else None, + }, message="User added to organization successfully") + + except Exception as e: + db.session.rollback() + logger.error(f"[SuperadminUsers] Add to org error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +@superadmin_bp.route("/users//orgs/", methods=["DELETE"]) +@superadmin_required +@superadmin_audit_log(action="user.remove_from_org", resource_type="user") +def remove_user_from_org(user_id, org_id): + """Remove a user from an organization.""" + try: + membership = OrganizationMember.query.filter( + OrganizationMember.user_id == user_id, + OrganizationMember.organization_id == org_id, + OrganizationMember.deleted_at.is_(None), + ).first() + + if not membership: + return api_response( + success=False, + message="User is not a member of this organization", + status=404, + error_type="NOT_FOUND", + ) + + # Check if user is the only owner + if membership.role == "owner": + owner_count = OrganizationMember.query.filter( + OrganizationMember.organization_id == org_id, + OrganizationMember.role == "owner", + OrganizationMember.deleted_at.is_(None), + ).count() + + if owner_count <= 1: + return api_response( + success=False, + message="Cannot remove the only owner from an organization. Transfer ownership first.", + status=400, + error_type="VALIDATION_ERROR", + ) + + # Soft delete membership + membership.deleted_at = db.func.now() + db.session.commit() + + logger.info(f"[SuperadminUsers] User {user_id} removed from org {org_id} by superadmin") + + return api_response(data={ + "user_id": user_id, + "organization_id": org_id, + }, message="User removed from organization successfully") + + except Exception as e: + db.session.rollback() + logger.error(f"[SuperadminUsers] Remove from org error: {e}") + return api_response( + success=False, + message="An error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) diff --git a/gatehouse_app/api/v1/users/admin.py b/gatehouse_app/api/v1/users/admin.py index 94cf12c..116519e 100644 --- a/gatehouse_app/api/v1/users/admin.py +++ b/gatehouse_app/api/v1/users/admin.py @@ -283,7 +283,8 @@ def admin_verify_user_email(user_id): if was_inactive: target.status = UserStatus.ACTIVE - EmailVerificationToken.query.filter_by(user_id=target.id, used_at=None).delete() + now = datetime.now(timezone.utc) + EmailVerificationToken.query.filter_by(user_id=target.id, used_at=None).filter(EmailVerificationToken.deleted_at == None).update({"deleted_at": now}, synchronize_session=False) _db.session.commit() AuditService.log_action( @@ -300,14 +301,12 @@ def admin_verify_user_email(user_id): @api_v1_bp.route("/admin/users//delete", methods=["POST"]) @login_required @full_access_required -def admin_hard_delete_user(user_id): +def admin_delete_user(user_id): from gatehouse_app.models.organization.organization_member import OrganizationMember from gatehouse_app.models.user.user import User as _User from gatehouse_app.models.ssh_ca.ssh_key import SSHKey from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate - from gatehouse_app.models.ssh_ca.certificate_audit_log import CertificateAuditLog from gatehouse_app.models.auth.authentication_method import OAuthState - from gatehouse_app.models.security.organization_security_policy import OrganizationSecurityPolicy from gatehouse_app.extensions import db as _db from gatehouse_app.utils.constants import AuditAction, OrganizationRole from gatehouse_app.services.audit_service import AuditService @@ -373,44 +372,29 @@ def admin_hard_delete_user(user_id): target_email = target.email target_id_str = str(target.id) + now = datetime.now(timezone.utc) try: - # NULL out FK references that don't cascade on delete so the - # session.delete() below doesn't hit FK constraint violations. + # Soft delete the user — set deleted_at timestamp. + target.deleted_at = now - # org_invite_tokens.invited_by_id — SET NULL is already on the FK column, - # but OrganizationMember.invited_by_id has no ondelete clause. - _db.session.execute( - _db.text("UPDATE organization_members SET invited_by_id = NULL WHERE invited_by_id = :uid"), - {"uid": target_id_str}, + # Soft delete associated OAuthState records. + OAuthState.query.filter_by(user_id=target_id_str).filter(OAuthState.deleted_at == None).update( + {"deleted_at": now}, synchronize_session=False ) - # certificate_audit_logs.user_id — nullable, no ondelete clause. - CertificateAuditLog.query.filter_by(user_id=target_id_str).update( - {"user_id": None}, synchronize_session=False - ) - - # organization_security_policies.updated_by_user_id — nullable, no ondelete. - OrganizationSecurityPolicy.query.filter_by(updated_by_user_id=target_id_str).update( - {"updated_by_user_id": None}, synchronize_session=False - ) - - # oauth_states.user_id — nullable, no ondelete. - OAuthState.query.filter_by(user_id=target_id_str).delete(synchronize_session=False) - - _db.session.delete(target) _db.session.flush() except Exception as exc: _db.session.rollback() - _logger.error(f"Hard delete failed for {target_id_str}: {exc}") + _logger.error(f"Soft delete failed for {target_id_str}: {exc}") return api_response(success=False, message="Failed to delete user account. Please try again.", status=500, error_type="SERVER_ERROR") AuditService.log_action( - action=AuditAction.USER_HARD_DELETE, + action=AuditAction.USER_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}", + description=f"Admin 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, @@ -419,7 +403,7 @@ def admin_hard_delete_user(user_id): _db.session.commit() return api_response( - message=f"User account {target_email} has been permanently deleted.", + message=f"User account {target_email} has been deleted.", data={"deleted_user_id": target_id_str, "deleted_user_email": target_email, "ssh_keys_deleted": ssh_key_count, "certs_revoked": active_cert_count}, ) diff --git a/gatehouse_app/decorators/superadmin.py b/gatehouse_app/decorators/superadmin.py new file mode 100644 index 0000000..c318a68 --- /dev/null +++ b/gatehouse_app/decorators/superadmin.py @@ -0,0 +1,203 @@ +"""Superadmin authentication and audit decorators.""" +import logging +from functools import wraps +from datetime import datetime, timezone + +from flask import request, g + +from gatehouse_app.utils.response import api_response + + +logger = logging.getLogger(__name__) + + +def superadmin_required(f): + """Decorator to require superadmin Bearer token authentication. + + Extracts token from Authorization: Bearer {token} header, + validates the session against SuperadminSession table, + and sets g.current_superadmin and g.superadmin_session. + + Returns 401 if no valid session, 403 if not a superadmin. + """ + @wraps(f) + def decorated_function(*args, **kwargs): + # Extract token from Authorization header + auth_header = request.headers.get('Authorization') + + if not auth_header: + return api_response( + success=False, + message="Authorization header is required", + status=401, + error_type="AUTH_REQUIRED" + ) + + # Expect format: "Bearer {token}" + parts = auth_header.split() + if len(parts) != 2 or parts[0].lower() != 'bearer': + return api_response( + success=False, + message="Invalid authorization format. Use: Bearer {token}", + status=401, + error_type="INVALID_AUTH_FORMAT" + ) + + token = parts[1] + + # Import here to avoid circular imports + from gatehouse_app.models.superadmin import SuperadminSession, Superadmin + + # Get active session by token + session = SuperadminSession.query.filter_by(token=token).first() + + if not session: + return api_response( + success=False, + message="Invalid or expired session", + status=401, + error_type="INVALID_TOKEN" + ) + + # Check if session is active + if not session.is_active(): + return api_response( + success=False, + message="Session is no longer active", + status=401, + error_type="SESSION_INACTIVE" + ) + + # Get the superadmin + superadmin = session.superadmin + if not superadmin: + return api_response( + success=False, + message="Superadmin not found", + status=401, + error_type="INVALID_TOKEN" + ) + + # Check if superadmin is active + if not superadmin.is_active: + return api_response( + success=False, + message="Superadmin account is disabled", + status=403, + error_type="ACCOUNT_DISABLED" + ) + + # Update last_activity_at timestamp + session.last_activity_at = datetime.now(timezone.utc) + from gatehouse_app import db + db.session.commit() + + # Set context variables + g.current_superadmin = superadmin + g.superadmin_session = session + + return f(*args, **kwargs) + + return decorated_function + + +def superadmin_audit_log(action, resource_type): + """Decorator to log superadmin actions to SuperadminAuditLog. + + Must be used AFTER @superadmin_required to have access to g.current_superadmin. + + Args: + action: The action being performed (e.g., 'update', 'delete', 'create') + resource_type: The type of resource being acted on (e.g., 'organization', 'user') + + Captures: superadmin_id, action, resource_type, resource_id, org_id, user_id, + ip_address, user_agent, request_id, extra_data + """ + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + # Get superadmin from context (set by @superadmin_required) + superadmin = getattr(g, 'current_superadmin', None) + session = getattr(g, 'superadmin_session', None) + + if not superadmin: + logger.warning(f"superadmin_audit_log used without @superadmin_required on {f.__name__}") + return f(*args, **kwargs) + + # Extract resource_id from kwargs if present + resource_id = kwargs.get('resource_id') or kwargs.get(f'{resource_type}_id') or None + + # Extract org_id and user_id from kwargs if present + org_id = kwargs.get('org_id') or None + user_id = kwargs.get('user_id') or None + + # Get IP address and user agent + ip_address = request.remote_addr or None + user_agent = request.headers.get('User-Agent') or None + request_id = request.headers.get('X-Request-ID') or None + + # Get extra data from request body (for POST/PATCH requests) + extra_data = None + if request.is_json: + try: + # Exclude sensitive fields + body = request.get_json(silent=True) or {} + sensitive_fields = {'password', 'password_hash', 'token', 'secret', 'key'} + extra_data = {k: v for k, v in body.items() if k not in sensitive_fields} + except Exception: + pass + + # Get success status (default to True unless an error is raised) + success = True + error_message = None + + try: + result = f(*args, **kwargs) + + # Check if the response indicates failure + if hasattr(result, 'get_json'): + result_data = result.get_json() + if result_data and result_data.get('success') is False: + success = False + error_message = result_data.get('message') + + return result + + except Exception as e: + success = False + error_message = str(e) + raise + + finally: + # Log the action + try: + from gatehouse_app.models.superadmin_audit_log import SuperadminAuditLog + from gatehouse_app import db + + audit_entry = SuperadminAuditLog( + superadmin_id=superadmin.id, + action=action, + resource_type=resource_type, + resource_id=resource_id, + org_id=org_id, + user_id=user_id, + ip_address=ip_address, + user_agent=user_agent, + request_id=request_id, + extra_data=extra_data, + success=success, + error_message=error_message + ) + db.session.add(audit_entry) + db.session.commit() + + logger.info( + f"Superadmin audit: superadmin={superadmin.email} action={action} " + f"resource_type={resource_type} resource_id={resource_id} success={success}" + ) + except Exception as e: + # Never let audit logging failures break the main operation + logger.error(f"Failed to write superadmin audit log: {e}") + + return decorated_function + return decorator diff --git a/gatehouse_app/models/__init__.py b/gatehouse_app/models/__init__.py index fc6b060..fe62307 100644 --- a/gatehouse_app/models/__init__.py +++ b/gatehouse_app/models/__init__.py @@ -113,6 +113,14 @@ from gatehouse_app.models.zerotier import ( # noqa: F401 ZeroTierMembership, KillSwitchEvent, ) + +# ── Superadmin ───────────────────────────────────────────────────────────────── +from gatehouse_app.models.superadmin import ( # noqa: F401 + Superadmin, + SuperadminSession, + SuperadminSessionStatus, +) +from gatehouse_app.models.superadmin_audit_log import SuperadminAuditLog # noqa: F401 from gatehouse_app.models.security.user_security_policy import ( # noqa: F401 UserSecurityPolicy, ) @@ -175,4 +183,9 @@ __all__ = [ "ActivationSession", "ZeroTierMembership", "KillSwitchEvent", + # Superadmin + "Superadmin", + "SuperadminSession", + "SuperadminSessionStatus", + "SuperadminAuditLog", ] diff --git a/gatehouse_app/models/billing/__init__.py b/gatehouse_app/models/billing/__init__.py new file mode 100644 index 0000000..6c7b375 --- /dev/null +++ b/gatehouse_app/models/billing/__init__.py @@ -0,0 +1,5 @@ +"""Billing models package.""" +from gatehouse_app.models.billing.plan import Plan +from gatehouse_app.models.billing.subscription import Subscription, SubscriptionStatus, BillingCycle + +__all__ = ["Plan", "Subscription", "SubscriptionStatus", "BillingCycle"] diff --git a/gatehouse_app/models/billing/plan.py b/gatehouse_app/models/billing/plan.py new file mode 100644 index 0000000..9ed4e88 --- /dev/null +++ b/gatehouse_app/models/billing/plan.py @@ -0,0 +1,61 @@ +"""Plan model for subscription tiers.""" +import logging +from datetime import datetime, timezone +from sqlalchemy import Column, String, Integer, Boolean, DateTime, Text +from gatehouse_app.models.base import BaseModel + +logger = logging.getLogger(__name__) + + +class Plan(BaseModel): + """Subscription plan definition. + + Represents different pricing tiers that organizations can subscribe to. + """ + __tablename__ = "plans" + + name = Column(String(100), nullable=False) + slug = Column(String(50), unique=True, nullable=False) + description = Column(Text, nullable=True) + + # Pricing in cents + price_monthly = Column(Integer, nullable=False, default=0) # Price in cents + price_yearly = Column(Integer, nullable=False, default=0) # Price in cents + + # User limits + included_users = Column(Integer, nullable=False, default=0) # 0 = unlimited + + # Overage pricing (cents per user over limit) + overage_rate_per_user = Column(Integer, nullable=False, default=0) + + # Feature flags (JSON) + features = Column(Text, nullable=True) # JSON string + + # Stripe integration + stripe_price_id_monthly = Column(String(100), nullable=True) + stripe_price_id_yearly = Column(String(100), nullable=True) + + # Active/inactive + is_active = Column(Boolean, nullable=False, default=True) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert plan to dictionary.""" + return { + "id": self.id, + "name": self.name, + "slug": self.slug, + "description": self.description, + "price_monthly": self.price_monthly, + "price_yearly": self.price_yearly, + "included_users": self.included_users, + "overage_rate_per_user": self.overage_rate_per_user, + "features": self.features, + "stripe_price_id_monthly": self.stripe_price_id_monthly, + "stripe_price_id_yearly": self.stripe_price_id_yearly, + "is_active": self.is_active, + "created_at": self.created_at.isoformat() + "Z" if self.created_at else None, + "updated_at": self.updated_at.isoformat() + "Z" if self.updated_at else None, + } diff --git a/gatehouse_app/models/billing/subscription.py b/gatehouse_app/models/billing/subscription.py new file mode 100644 index 0000000..b87f4e1 --- /dev/null +++ b/gatehouse_app/models/billing/subscription.py @@ -0,0 +1,99 @@ +"""Subscription model for organization billing.""" +import logging +from datetime import datetime, timezone +from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Enum +from gatehouse_app.models.base import BaseModel +import enum + +logger = logging.getLogger(__name__) + + +class SubscriptionStatus(enum.Enum): + """Subscription status values.""" + TRIAL = "trial" + ACTIVE = "active" + PAST_DUE = "past_due" + CANCELLED = "cancelled" + SUSPENDED = "suspended" + + +class BillingCycle(enum.Enum): + """Billing cycle values.""" + MONTHLY = "monthly" + YEARLY = "yearly" + + +class Subscription(BaseModel): + """Organization subscription record. + + Links an organization to a plan and tracks billing state. + """ + __tablename__ = "subscriptions" + + # Organization relation + organization_id = Column( + String(36), + ForeignKey("organizations.id", ondelete="CASCADE"), + unique=True, + nullable=False + ) + + # Plan relation + plan_id = Column( + String(36), + ForeignKey("plans.id", ondelete="SET NULL"), + nullable=True + ) + + # Status + status = Column( + Enum(SubscriptionStatus, name="subscription_status"), + nullable=False, + default=SubscriptionStatus.TRIAL + ) + + # Billing + billing_cycle = Column( + Enum(BillingCycle, name="billing_cycle"), + nullable=False, + default=BillingCycle.MONTHLY + ) + + # Period dates + current_period_start = Column(DateTime, nullable=True) + current_period_end = Column(DateTime, nullable=True) + + # Trial + trial_ends_at = Column(DateTime, nullable=True) + + # Stripe + stripe_subscription_id = Column(String(100), nullable=True) + + # Overage + overage_enabled = Column(Boolean, nullable=False, default=True) + + # Cancellation + cancelled_at = Column(DateTime, nullable=True) + cancel_at_period_end = Column(Boolean, nullable=False, default=False) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert subscription to dictionary.""" + return { + "id": self.id, + "organization_id": self.organization_id, + "plan_id": self.plan_id, + "status": self.status.value if self.status else None, + "billing_cycle": self.billing_cycle.value if self.billing_cycle else None, + "current_period_start": self.current_period_start.isoformat() + "Z" if self.current_period_start else None, + "current_period_end": self.current_period_end.isoformat() + "Z" if self.current_period_end else None, + "trial_ends_at": self.trial_ends_at.isoformat() + "Z" if self.trial_ends_at else None, + "stripe_subscription_id": self.stripe_subscription_id, + "overage_enabled": self.overage_enabled, + "cancelled_at": self.cancelled_at.isoformat() + "Z" if self.cancelled_at else None, + "cancel_at_period_end": self.cancel_at_period_end, + "created_at": self.created_at.isoformat() + "Z" if self.created_at else None, + "updated_at": self.updated_at.isoformat() + "Z" if self.updated_at else None, + } diff --git a/gatehouse_app/models/organization/organization.py b/gatehouse_app/models/organization/organization.py index 64d3c7c..f8ae26e 100644 --- a/gatehouse_app/models/organization/organization.py +++ b/gatehouse_app/models/organization/organization.py @@ -78,3 +78,43 @@ class Organization(BaseModel): ).first() is not None ) + def get_active_members(self): + """Get active (non-deleted) organization members. + + Returns: + List of OrganizationMember instances where deleted_at is None. + """ + return [m for m in self.members if m.deleted_at is None] + + def get_active_departments(self): + """Get active (non-deleted) departments. + + Returns: + List of Department instances where deleted_at is None. + """ + return [d for d in self.departments if d.deleted_at is None] + + def get_active_principals(self): + """Get active (non-deleted) principals. + + Returns: + List of Principal instances where deleted_at is None. + """ + return [p for p in self.principals if p.deleted_at is None] + + def get_active_cas(self): + """Get active (non-deleted) certificate authorities. + + Returns: + List of CA instances where deleted_at is None. + """ + return [ca for ca in self.cas if ca.deleted_at is None] + + def get_active_api_keys(self): + """Get active (non-deleted) API keys. + + Returns: + List of OrganizationApiKey instances where deleted_at is None. + """ + return [k for k in self.api_keys if k.deleted_at is None] + diff --git a/gatehouse_app/models/superadmin/__init__.py b/gatehouse_app/models/superadmin/__init__.py new file mode 100644 index 0000000..ff9fddc --- /dev/null +++ b/gatehouse_app/models/superadmin/__init__.py @@ -0,0 +1,5 @@ +"""Superadmin models.""" +from gatehouse_app.models.superadmin.superadmin import Superadmin +from gatehouse_app.models.superadmin.superadmin_session import SuperadminSession, SuperadminSessionStatus + +__all__ = ["Superadmin", "SuperadminSession", "SuperadminSessionStatus"] diff --git a/gatehouse_app/models/superadmin/superadmin.py b/gatehouse_app/models/superadmin/superadmin.py new file mode 100644 index 0000000..525b410 --- /dev/null +++ b/gatehouse_app/models/superadmin/superadmin.py @@ -0,0 +1,56 @@ +"""Superadmin model.""" +import logging +from datetime import datetime, timezone + +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel + + +logger = logging.getLogger(__name__) + + +class Superadmin(BaseModel): + """Superadmin model for SaaS platform operators. + + Completely separate from User model - has its own email/password auth. + """ + + __tablename__ = "superadmins" + + email = db.Column(db.String(255), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + full_name = db.Column(db.String(255), nullable=True) + is_active = db.Column(db.Boolean, default=True, nullable=False) + last_login_at = db.Column(db.DateTime, nullable=True) + + # Relationship to sessions + sessions = db.relationship( + "SuperadminSession", + back_populates="superadmin", + cascade="all, delete-orphan" + ) + + # Relationship to audit logs + audit_logs = db.relationship( + "SuperadminAuditLog", + back_populates="superadmin", + cascade="all, delete-orphan" + ) + + def __repr__(self): + return f"" + + def has_password_auth(self): + """Check if superadmin has password authentication.""" + return bool(self.password_hash) + + def has_totp_enabled(self): + """Check if superadmin has TOTP enabled.""" + # TODO: Implement TOTP for superadmin if needed + return False + + def to_dict(self, exclude=None): + """Convert to dictionary, excluding sensitive fields.""" + exclude = exclude or [] + exclude.append("password_hash") + return super().to_dict(exclude=exclude) diff --git a/gatehouse_app/models/superadmin/superadmin_session.py b/gatehouse_app/models/superadmin/superadmin_session.py new file mode 100644 index 0000000..8eef79b --- /dev/null +++ b/gatehouse_app/models/superadmin/superadmin_session.py @@ -0,0 +1,80 @@ +"""Superadmin session model.""" +import logging +from datetime import datetime, timezone, timedelta + +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel + + +logger = logging.getLogger(__name__) + + +class SuperadminSessionStatus: + """Session status constants.""" + ACTIVE = "active" + REVOKED = "revoked" + EXPIRED = "expired" + + +class SuperadminSession(BaseModel): + """Session model for superadmin authentication.""" + + __tablename__ = "superadmin_sessions" + + superadmin_id = db.Column( + db.String(36), + db.ForeignKey("superadmins.id"), + nullable=False, + index=True + ) + token = db.Column(db.String(255), unique=True, nullable=False, index=True) + expires_at = db.Column(db.DateTime, nullable=False) + last_activity_at = db.Column( + db.DateTime, + nullable=False, + default=lambda: datetime.now(timezone.utc) + ) + ip_address = db.Column(db.String(45), nullable=True) + user_agent = db.Column(db.Text, nullable=True) + revoked_at = db.Column(db.DateTime, nullable=True) + revoked_reason = db.Column(db.String(255), nullable=True) + + # Relationship + superadmin = db.relationship("Superadmin", back_populates="sessions") + + def __repr__(self): + return f"" + + def is_active(self): + """Check if session is currently active.""" + now = datetime.now(timezone.utc) + expires_at = self.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + return ( + self.deleted_at is None + and self.revoked_at is None + and expires_at > now + ) + + def is_expired(self): + """Check if session has expired.""" + now = datetime.now(timezone.utc) + expires_at = self.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + return now > expires_at + + def revoke(self, reason: str = None): + """Revoke the session.""" + self.revoked_at = datetime.now(timezone.utc) + if reason: + self.revoked_reason = reason + from gatehouse_app import db + db.session.commit() + + def to_dict(self, exclude=None): + """Convert to dictionary, excluding sensitive fields.""" + exclude = exclude or [] + exclude.append("token") + return super().to_dict(exclude=exclude) diff --git a/gatehouse_app/models/superadmin_audit_log.py b/gatehouse_app/models/superadmin_audit_log.py new file mode 100644 index 0000000..785b570 --- /dev/null +++ b/gatehouse_app/models/superadmin_audit_log.py @@ -0,0 +1,49 @@ +"""Superadmin audit log model.""" +import logging + +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel + + +logger = logging.getLogger(__name__) + + +class SuperadminAuditLog(BaseModel): + """Audit log for superadmin actions. + + Records every action performed by superadmins for security and compliance. + """ + + __tablename__ = "superadmin_audit_logs" + + superadmin_id = db.Column( + db.String(36), + db.ForeignKey("superadmins.id"), + nullable=False, + index=True + ) + action = db.Column(db.String(100), nullable=False, index=True) + resource_type = db.Column(db.String(50), nullable=False, index=True) + resource_id = db.Column(db.String(36), nullable=True, index=True) + org_id = db.Column(db.String(36), nullable=True, index=True) + user_id = db.Column(db.String(36), nullable=True, index=True) + ip_address = db.Column(db.String(45), nullable=True) + user_agent = db.Column(db.Text, nullable=True) + request_id = db.Column(db.String(100), nullable=True) + extra_data = db.Column(db.JSON, nullable=True) + success = db.Column(db.Boolean, default=True, nullable=False) + error_message = db.Column(db.String(500), nullable=True) + + # Relationship + superadmin = db.relationship("Superadmin", back_populates="audit_logs") + + def __repr__(self): + return ( + f"" + ) + + def to_dict(self, exclude=None): + """Convert to dictionary.""" + exclude = exclude or [] + return super().to_dict(exclude=exclude) diff --git a/gatehouse_app/models/user/user.py b/gatehouse_app/models/user/user.py index 0236811..d2f5b0f 100644 --- a/gatehouse_app/models/user/user.py +++ b/gatehouse_app/models/user/user.py @@ -134,6 +134,46 @@ class User(BaseModel): def get_organizations(self): """Get all active organizations the user is a member of.""" return [membership.organization for membership in self.get_active_memberships()] + def get_active_ssh_keys(self): + """Get active (non-deleted) SSH keys. + + Returns: + List of SSHKey instances where deleted_at is None. + """ + return [k for k in self.ssh_keys if k.deleted_at is None] + + def get_active_auth_methods(self): + """Get active (non-deleted) authentication methods. + + Returns: + List of AuthenticationMethod instances where deleted_at is None. + """ + return [m for m in self.authentication_methods if m.deleted_at is None] + + def get_active_department_memberships(self): + """Get active (non-deleted) department memberships. + + Returns: + List of DepartmentMembership instances where deleted_at is None. + """ + return [m for m in self.department_memberships if m.deleted_at is None] + + def get_active_principal_memberships(self): + """Get active (non-deleted) principal memberships. + + Returns: + List of PrincipalMembership instances where deleted_at is None. + """ + return [m for m in self.principal_memberships if m.deleted_at is None] + + def get_active_ca_permissions(self): + """Get active (non-deleted) CA permissions. + + Returns: + List of CAPermission instances where deleted_at is None. + """ + return [p for p in self.ca_permissions if p.deleted_at is None] + def has_totp_enabled(self) -> bool: """Check if user has TOTP enabled and verified. diff --git a/gatehouse_app/services/__init__.py b/gatehouse_app/services/__init__.py index 8a27413..2bfadac 100644 --- a/gatehouse_app/services/__init__.py +++ b/gatehouse_app/services/__init__.py @@ -9,6 +9,8 @@ from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService from gatehouse_app.services.oidc_token_service import OIDCTokenService from gatehouse_app.services.oidc_session_service import OIDCSessionService from gatehouse_app.services.oidc_audit_service import OIDCAuditService +from gatehouse_app.services.superadmin_auth_service import SuperadminAuthService +from gatehouse_app.services.superadmin_organization_service import SuperadminOrganizationService __all__ = [ "AuthService", @@ -22,4 +24,6 @@ __all__ = [ "OIDCTokenService", "OIDCSessionService", "OIDCAuditService", + "SuperadminAuthService", + "SuperadminOrganizationService", ] diff --git a/gatehouse_app/services/billing_service.py b/gatehouse_app/services/billing_service.py new file mode 100644 index 0000000..5cf4495 --- /dev/null +++ b/gatehouse_app/services/billing_service.py @@ -0,0 +1,192 @@ +"""Billing service for superadmin operations.""" +import logging +from datetime import datetime, timedelta, timezone +from gatehouse_app.models.organization.organization import Organization +from gatehouse_app.models.organization.organization_member import OrganizationMember +from gatehouse_app.models.billing.plan import Plan +from gatehouse_app.models.billing.subscription import Subscription, SubscriptionStatus, BillingCycle +from gatehouse_app.extensions import db + +logger = logging.getLogger(__name__) + + +class BillingService: + """Service for billing operations.""" + + @staticmethod + def get_plan(plan_id: str) -> Plan: + """Get a plan by ID.""" + plan = Plan.query.get(plan_id) + if not plan: + raise ValueError("Plan not found") + return plan + + @staticmethod + def list_plans() -> list: + """List all active plans.""" + return Plan.query.filter(Plan.is_active == True).order_by(Plan.price_monthly.asc()).all() + + @staticmethod + def create_subscription( + organization_id: str, + plan_id: str, + billing_cycle: str = "monthly" + ) -> Subscription: + """Create a new subscription for an organization. + + Args: + organization_id: Organization UUID + plan_id: Plan UUID + billing_cycle: 'monthly' or 'yearly' + + Returns: + New subscription + """ + org = Organization.query.get(organization_id) + if not org: + raise ValueError("Organization not found") + + plan = Plan.query.get(plan_id) + if not plan: + raise ValueError("Plan not found") + + # Check if subscription already exists + existing = Subscription.query.filter_by(organization_id=organization_id).first() + if existing: + raise ValueError("Organization already has a subscription") + + now = datetime.now(timezone.utc) + + # Calculate period + if billing_cycle == "yearly": + period_end = now + timedelta(days=365) + else: + period_end = now + timedelta(days=30) + + subscription = Subscription( + organization_id=organization_id, + plan_id=plan_id, + status=SubscriptionStatus.ACTIVE, + billing_cycle=BillingCycle.MONTHLY if billing_cycle == "monthly" else BillingCycle.YEARLY, + current_period_start=now, + current_period_end=period_end, + ) + + db.session.add(subscription) + db.session.commit() + + return subscription + + @staticmethod + def change_plan(organization_id: str, new_plan_id: str) -> Subscription: + """Change subscription plan. + + Args: + organization_id: Organization UUID + new_plan_id: New plan UUID + + Returns: + Updated subscription + """ + subscription = Subscription.query.filter_by(organization_id=organization_id).first() + if not subscription: + raise ValueError("No subscription found for organization") + + new_plan = Plan.query.get(new_plan_id) + if not new_plan: + raise ValueError("Plan not found") + + subscription.plan_id = new_plan_id + db.session.commit() + + return subscription + + @staticmethod + def cancel_subscription(organization_id: str) -> Subscription: + """Cancel subscription at period end. + + Args: + organization_id: Organization UUID + + Returns: + Updated subscription + """ + subscription = Subscription.query.filter_by(organization_id=organization_id).first() + if not subscription: + raise ValueError("No subscription found for organization") + + subscription.cancel_at_period_end = True + subscription.status = SubscriptionStatus.CANCELLED + db.session.commit() + + return subscription + + @staticmethod + def extend_trial(organization_id: str, days: int) -> Subscription: + """Extend trial period. + + Args: + organization_id: Organization UUID + days: Number of days to extend + + Returns: + Updated subscription + """ + subscription = Subscription.query.filter_by(organization_id=organization_id).first() + if not subscription: + raise ValueError("No subscription found for organization") + + now = datetime.now(timezone.utc) + + if subscription.trial_ends_at: + subscription.trial_ends_at = subscription.trial_ends_at + timedelta(days=days) + else: + subscription.trial_ends_at = now + timedelta(days=days) + + subscription.status = SubscriptionStatus.TRIAL + db.session.commit() + + return subscription + + @staticmethod + def calculate_overage(organization_id: str) -> dict: + """Calculate overage charges for an organization. + + Args: + organization_id: Organization UUID + + Returns: + Overage calculation with details + """ + subscription = Subscription.query.filter_by(organization_id=organization_id).first() + if not subscription: + return {"has_overage": False, "overage_cost": 0, "user_count": 0, "included_users": 0} + + plan = Plan.query.get(subscription.plan_id) if subscription.plan_id else None + if not plan: + return {"has_overage": False, "overage_cost": 0, "user_count": 0, "included_users": 0} + + # Count current users + user_count = OrganizationMember.query.filter( + OrganizationMember.organization_id == organization_id, + OrganizationMember.deleted_at.is_(None), + ).count() + + included_users = plan.included_users + overage_users = max(0, user_count - included_users) + + if overage_users > 0 and plan.overage_rate_per_user > 0: + overage_cost = overage_users * plan.overage_rate_per_user + has_overage = True + else: + overage_cost = 0 + has_overage = False + + return { + "has_overage": has_overage, + "user_count": user_count, + "included_users": included_users, + "overage_users": overage_users, + "overage_rate_per_user": plan.overage_rate_per_user, + "overage_cost": overage_cost, + } diff --git a/gatehouse_app/services/organization_service.py b/gatehouse_app/services/organization_service.py index c255513..9d6e6ae 100644 --- a/gatehouse_app/services/organization_service.py +++ b/gatehouse_app/services/organization_service.py @@ -302,7 +302,7 @@ class OrganizationService: Raises: ConflictError: If user is already a member """ - # Check if already a member (active or soft-deleted — both blocked by DB unique constraint) + # Check for any membership (active or soft-deleted) to enable reactivation existing = OrganizationMember.query.filter_by( user_id=user_id, organization_id=org.id, @@ -310,7 +310,7 @@ class OrganizationService: # Development-only debug logging for membership validation if current_app.config.get('ENV') == 'development': - logger.debug(f"[Org] Member check: org_id={org.id}, user_id={user_id}, already_member={existing is not None}") + logger.debug(f"[Org] Member check: org_id={org.id}, user_id={user_id}, already_member={existing is not None}, soft_deleted={existing.deleted_at is not None if existing else False}") if existing: if existing.deleted_at is not None: diff --git a/gatehouse_app/services/superadmin_analytics_service.py b/gatehouse_app/services/superadmin_analytics_service.py new file mode 100644 index 0000000..96f4694 --- /dev/null +++ b/gatehouse_app/services/superadmin_analytics_service.py @@ -0,0 +1,177 @@ +"""Analytics service for platform-wide statistics.""" +import logging +from datetime import datetime, timedelta, timezone +from gatehouse_app.models.organization.organization import Organization +from gatehouse_app.models.user.user import User +from gatehouse_app.models.user.session import Session +from gatehouse_app.models.superadmin_audit_log import SuperadminAuditLog + +logger = logging.getLogger(__name__) + + +class SuperadminAnalyticsService: + """Service for platform-wide analytics and statistics.""" + + @staticmethod + def get_dashboard_stats() -> dict: + """Get dashboard statistics for the overview page. + + Returns: + Dashboard stats including org count, user count, etc. + """ + now = datetime.now(timezone.utc) + thirty_days_ago = now - timedelta(days=30) + + # Total organizations + total_orgs = Organization.query.filter(Organization.deleted_at.is_(None)).count() + active_orgs = Organization.query.filter( + Organization.deleted_at.is_(None), + Organization.is_active == True, # noqa: E712 + ).count() + + # Total users + total_users = User.query.filter(User.deleted_at.is_(None)).count() + + # Active sessions + active_sessions = Session.query.filter( + Session.deleted_at.is_(None), + Session.status == "active", + ).count() + + # New signups in last 30 days + new_users_30d = User.query.filter( + User.deleted_at.is_(None), + User.created_at >= thirty_days_ago, + ).count() + + # New organizations in last 30 days + new_orgs_30d = Organization.query.filter( + Organization.deleted_at.is_(None), + Organization.created_at >= thirty_days_ago, + ).count() + + # Suspended organizations + suspended_orgs = Organization.query.filter( + Organization.deleted_at.is_(None), + Organization.is_active == False, # noqa: E712 + ).count() + + return { + "total_organizations": total_orgs, + "active_organizations": active_orgs, + "suspended_organizations": suspended_orgs, + "total_users": total_users, + "active_sessions": active_sessions, + "new_users_30d": new_users_30d, + "new_orgs_30d": new_orgs_30d, + "generated_at": now.isoformat() + "Z", + } + + @staticmethod + def get_signup_trends(days: int = 30) -> dict: + """Get signup trends over time. + + Args: + days: Number of days to analyze + + Returns: + Daily signup data + """ + now = datetime.now(timezone.utc) + start_date = now - timedelta(days=days) + + # Get all users created in period + users = User.query.filter( + User.deleted_at.is_(None), + User.created_at >= start_date, + ).all() + + # Group by day + daily_signups = {} + for i in range(days): + date = (start_date + timedelta(days=i)).strftime("%Y-%m-%d") + daily_signups[date] = 0 + + for user in users: + date = user.created_at.strftime("%Y-%m-%d") + if date in daily_signups: + daily_signups[date] += 1 + + # Convert to list + history = [ + {"date": date, "value": count} + for date, count in sorted(daily_signups.items()) + ] + + return { + "period_start": start_date.isoformat() + "Z", + "period_end": now.isoformat() + "Z", + "total": len(users), + "history": history, + } + + @staticmethod + def get_org_distribution() -> dict: + """Get distribution of organizations by size. + + Returns: + Organization size distribution + """ + orgs = Organization.query.filter(Organization.deleted_at.is_(None)).all() + + distribution = { + "solo": 0, # 1 user + "small": 0, # 2-10 users + "medium": 0, # 11-50 users + "large": 0, # 51-200 users + "enterprise": 0, # 200+ users + } + + for org in orgs: + count = org.get_member_count() + if count == 1: + distribution["solo"] += 1 + elif count <= 10: + distribution["small"] += 1 + elif count <= 50: + distribution["medium"] += 1 + elif count <= 200: + distribution["large"] += 1 + else: + distribution["enterprise"] += 1 + + return { + "distribution": distribution, + "total_orgs": len(orgs), + } + + @staticmethod + def get_recent_activity(limit: int = 20) -> list: + """Get recent superadmin actions. + + Args: + limit: Maximum number of actions to return + + Returns: + List of recent audit log entries + """ + logs = SuperadminAuditLog.query.filter( + SuperadminAuditLog.deleted_at.is_(None), + ).order_by( + SuperadminAuditLog.created_at.desc() + ).limit(limit).all() + + return [ + { + "id": log.id, + "superadmin_id": log.superadmin_id, + "action": log.action, + "resource_type": log.resource_type, + "resource_id": log.resource_id, + "extra_data": log.extra_data, + "ip_address": log.ip_address, + "user_agent": log.user_agent, + "created_at": log.created_at.isoformat() + "Z" if log.created_at else None, + } + for log in logs + ] diff --git a/gatehouse_app/services/superadmin_auth_service.py b/gatehouse_app/services/superadmin_auth_service.py new file mode 100644 index 0000000..dde6199 --- /dev/null +++ b/gatehouse_app/services/superadmin_auth_service.py @@ -0,0 +1,239 @@ +"""Superadmin authentication service.""" +import logging +import secrets +from datetime import datetime, timedelta, timezone +from typing import Optional + +from flask import request, current_app +from gatehouse_app.extensions import db, bcrypt +from gatehouse_app.models.superadmin import Superadmin, SuperadminSession +from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError + + +logger = logging.getLogger(__name__) + + +class SuperadminAuthService: + """Service for superadmin authentication operations.""" + + @staticmethod + def authenticate(email, credentials): + """Authenticate superadmin with email/password credentials. + + Args: + email: Superadmin email + credentials: Plain text credential + + Returns: + Superadmin instance if authentication succeeds + + Raises: + InvalidCredentialsError: If credentials are invalid or account is disabled + """ + # Find superadmin by email + superadmin = Superadmin.query.filter_by(email=email.lower()).first() + + if not superadmin: + logger.warning(f"[SuperadminAuth] Login attempt for non-existent email: {email}") + raise InvalidCredentialsError() + + # Check if account is active + if not superadmin.is_active: + logger.warning(f"[SuperadminAuth] Login attempt for disabled account: {email}") + raise InvalidCredentialsError("Account is disabled") + + # Check credential + if not superadmin.password_hash: + logger.warning(f"[SuperadminAuth] Login attempt for account with no credential set: {email}") + raise InvalidCredentialsError() + + # Verify credential + password_valid = bcrypt.check_password_hash(superadmin.password_hash, credentials) + + if not password_valid: + logger.warning(f"[SuperadminAuth] Invalid password for: {email}") + raise InvalidCredentialsError() + + # Update last login + superadmin.last_login_at = datetime.now(timezone.utc) + db.session.commit() + + logger.info(f"[SuperadminAuth] Successful login for: {email}") + return superadmin + + @staticmethod + def create_session(superadmin_id, duration_seconds=28800): + """Create a new session for superadmin. + + Args: + superadmin_id: Superadmin ID + duration_seconds: Session duration in seconds (default 8 hours) + + Returns: + SuperadminSession instance + """ + # Generate secure token + token = secrets.token_urlsafe(32) + + # Create session + session = SuperadminSession( + superadmin_id=superadmin_id, + token=token, + expires_at=datetime.now(timezone.utc) + timedelta(seconds=duration_seconds), + last_activity_at=datetime.now(timezone.utc), + ip_address=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + ) + session.save() + + logger.info(f"[SuperadminAuth] Session created for superadmin_id={superadmin_id}") + return session + + @staticmethod + def revoke_session(session_id, reason=None): + """Revoke a superadmin session. + + Args: + session_id: Session ID to revoke + reason: Optional revocation reason + """ + session = SuperadminSession.query.get(session_id) + if session: + session.revoke(reason=reason) + logger.info(f"[SuperadminAuth] Session {session_id} revoked: {reason or 'No reason'}") + + @staticmethod + def revoke_all_sessions(superadmin_id, except_token=None, reason=None): + """Revoke all sessions for a superadmin. + + Args: + superadmin_id: Superadmin ID + except_token: Optional token to keep (current session) + reason: Optional revocation reason + """ + query = SuperadminSession.query.filter_by(superadmin_id=superadmin_id) + if except_token: + query = query.filter(SuperadminSession.token != except_token) + + sessions = query.all() + for session in sessions: + session.revoke(reason=reason) + + logger.info(f"[SuperadminAuth] Revoked {len(sessions)} sessions for superadmin_id={superadmin_id}") + return len(sessions) + + @staticmethod + def create_emergency_access(superadmin_id, target_user_id, reason, duration_minutes=15): + """Create emergency access to a user's account. + + This creates a special emergency session that grants temporary elevated access. + + Args: + superadmin_id: Superadmin ID initiating emergency access + target_user_id: User ID to access + reason: Reason for emergency access + duration_minutes: Duration of emergency access in minutes + + Returns: + Dictionary with emergency session info + """ + from gatehouse_app.models.user.user import User + from gatehouse_app.services.session_service import SessionService + from gatehouse_app.services.audit_service import AuditService + + # Verify target user exists + target_user = User.query.get(target_user_id) + if not target_user: + raise ValueError(f"Target user not found: {target_user_id}") + + # Create emergency session for the target user + emergency_session = SessionService.create_session( + user=target_user, + duration_seconds=duration_minutes * 60, + is_compliance_only=False + ) + + # Log the emergency access + logger.warning( + f"[SuperadminAuth] EMERGENCY ACCESS: superadmin_id={superadmin_id} " + f"accessed user_id={target_user_id} reason={reason}" + ) + + return { + "session": emergency_session, + "expires_at": emergency_session.expires_at, + "reason": reason, + "target_user_id": target_user_id, + } + + @staticmethod + def hash_password(plain_credential): + """Hash a credential for storage. + + Args: + plain_credential: Plain text credential + + Returns: + Hashed credential string + """ + return bcrypt.generate_password_hash(plain_credential).decode("utf-8") + + @staticmethod + def create_superadmin(email, credential, full_name=None): + """Create a new superadmin. + + Args: + email: Superadmin email + credential: Plain text credential + full_name: Optional full name + + Returns: + Superadmin instance + """ + # Check if email already exists + existing = Superadmin.query.filter_by(email=email.lower()).first() + if existing: + raise ValueError(f"Superadmin with email {email} already exists") + + # Hash credential + password_hash = bcrypt.generate_password_hash(credential).decode("utf-8") + + # Create superadmin + superadmin = Superadmin( + email=email.lower(), + password_hash=password_hash, + full_name=full_name, + is_active=True, + ) + superadmin.save() + + logger.info(f"[SuperadminAuth] Created new superadmin: {email}") + return superadmin + + @staticmethod + def update_superadmin(superadmin_id, **kwargs): + """Update superadmin details. + + Args: + superadmin_id: Superadmin ID + **kwargs: Fields to update (email, full_name, is_active, credential) + + Returns: + Updated Superadmin instance + """ + superadmin = Superadmin.query.get(superadmin_id) + if not superadmin: + raise ValueError(f"Superadmin not found: {superadmin_id}") + + # Handle credential update + if 'password' in kwargs: + kwargs['password_hash'] = bcrypt.generate_password_hash(kwargs.pop('password')).decode("utf-8") + + # Update fields + for key, value in kwargs.items(): + if hasattr(superadmin, key): + setattr(superadmin, key, value) + + superadmin.save() + logger.info(f"[SuperadminAuth] Updated superadmin_id={superadmin_id}") + return superadmin diff --git a/gatehouse_app/services/superadmin_organization_service.py b/gatehouse_app/services/superadmin_organization_service.py new file mode 100644 index 0000000..295e6a4 --- /dev/null +++ b/gatehouse_app/services/superadmin_organization_service.py @@ -0,0 +1,244 @@ +"""Superadmin organization management service.""" +import logging +from typing import Optional +from gatehouse_app.extensions import db +from gatehouse_app.models.organization.organization import Organization +from gatehouse_app.models.user.session import Session + +logger = logging.getLogger(__name__) + + +class SuperadminOrganizationService: + """Service for superadmin organization management operations.""" + + @staticmethod + def list_organizations( + page: int = 1, + per_page: int = 20, + search: Optional[str] = None, + status: Optional[str] = None, + plan_slug: Optional[str] = None, + ) -> dict: + """List organizations with pagination and filtering. + + Args: + page: Page number (1-indexed) + per_page: Items per page + search: Search by name or slug + status: Filter by status (active, suspended) + plan_slug: Filter by plan slug (not implemented yet - requires Subscription model) + + Returns: + Paginated response with organization summaries + """ + query = Organization.query + + # Search filter + if search: + search_term = f"%{search}%" + query = query.filter( + db.or_( + Organization.name.ilike(search_term), + Organization.slug.ilike(search_term), + ) + ) + + # Status filter + if status == "active": + query = query.filter(Organization.is_active.is_(True)) + elif status == "suspended": + query = query.filter(Organization.is_active.is_(False)) + + # Note: plan_slug filtering requires Plan/Subscription models (Phase 4) + # Currently ignored but parameter is accepted for API compatibility + + # Order by created_at desc + query = query.order_by(Organization.created_at.desc()) + + # Paginate + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + + # Build response + items = [] + for org in pagination.items: + item = { + "id": org.id, + "name": org.name, + "slug": org.slug, + "description": org.description, + "is_active": org.is_active, + "member_count": org.get_member_count(), + "created_at": org.created_at.isoformat() + "Z" if org.created_at else None, + "updated_at": org.updated_at.isoformat() + "Z" if org.updated_at else None, + } + items.append(item) + + return { + "items": items, + "total": pagination.total, + "page": page, + "per_page": per_page, + "pages": pagination.pages, + } + + @staticmethod + def get_organization_detail(org_id: str) -> dict: + """Get detailed organization information. + + Args: + org_id: Organization UUID + + Returns: + Organization detail with member_count, owner, stats + + Raises: + ValueError: If organization not found + """ + org = Organization.query.get(org_id) + if not org: + raise ValueError("Organization not found") + + owner = org.get_owner() + + # Count active sessions for org members + member_user_ids = [m.user_id for m in org.members if m.deleted_at is None] + active_sessions = Session.query.filter( + Session.user_id.in_(member_user_ids), + Session.deleted_at.is_(None), + ).count() + + return { + "id": org.id, + "name": org.name, + "slug": org.slug, + "description": org.description, + "is_active": org.is_active, + "settings": org.settings or {}, + "member_count": org.get_member_count(), + "owner": { + "id": owner.id, + "email": owner.email, + "full_name": owner.full_name, + } if owner else None, + "active_sessions": active_sessions, + "created_at": org.created_at.isoformat() + "Z" if org.created_at else None, + "updated_at": org.updated_at.isoformat() + "Z" if org.updated_at else None, + } + + @staticmethod + def update_organization( + org_id: str, + name: Optional[str] = None, + description: Optional[str] = None, + is_active: Optional[bool] = None, + ) -> Organization: + """Update organization details. + + Args: + org_id: Organization UUID + name: New name (optional) + description: New description (optional) + is_active: New active status (optional) + + Returns: + Updated organization + + Raises: + ValueError: If organization not found + """ + org = Organization.query.get(org_id) + if not org: + raise ValueError("Organization not found") + + if name is not None: + org.name = name + if description is not None: + org.description = description + if is_active is not None: + org.is_active = is_active + + db.session.commit() + logger.info(f"[SuperadminOrg] Updated organization {org_id}") + + return org + + @staticmethod + def suspend_organization(org_id: str) -> Organization: + """Suspend an organization. + + Sets is_active=False and invalidates all member sessions. + + Args: + org_id: Organization UUID + + Returns: + Suspended organization + + Raises: + ValueError: If organization not found + """ + org = Organization.query.get(org_id) + if not org: + raise ValueError("Organization not found") + + org.is_active = False + + # Invalidate all member sessions + member_user_ids = [m.user_id for m in org.members if m.deleted_at is None] + Session.query.filter( + Session.user_id.in_(member_user_ids), + Session.deleted_at.is_(None), + ).update({"deleted_at": db.func.now()}) + + db.session.commit() + logger.warning(f"[SuperadminOrg] Suspended organization {org_id}") + + return org + + @staticmethod + def restore_organization(org_id: str) -> Organization: + """Restore a suspended organization. + + Args: + org_id: Organization UUID + + Returns: + Restored organization + + Raises: + ValueError: If organization not found + """ + org = Organization.query.get(org_id) + if not org: + raise ValueError("Organization not found") + + org.is_active = True + db.session.commit() + logger.info(f"[SuperadminOrg] Restored organization {org_id}") + + return org + + @staticmethod + def soft_delete_organization(org_id: str) -> Organization: + """Soft-delete an organization. + + Args: + org_id: Organization UUID + + Returns: + Soft-deleted organization + + Raises: + ValueError: If organization not found + """ + from datetime import datetime, timezone + + org = Organization.query.get(org_id) + if not org: + raise ValueError("Organization not found") + + org.deleted_at = datetime.now(timezone.utc) + db.session.commit() + logger.warning(f"[SuperadminOrg] Soft-deleted organization {org_id}") + + return org diff --git a/gatehouse_app/services/superadmin_usage_service.py b/gatehouse_app/services/superadmin_usage_service.py new file mode 100644 index 0000000..c200066 --- /dev/null +++ b/gatehouse_app/services/superadmin_usage_service.py @@ -0,0 +1,199 @@ +"""Usage tracking service for superadmin operations.""" +import logging +from datetime import datetime, timedelta, timezone +from gatehouse_app.models.organization.organization import Organization +from gatehouse_app.models.user.session import Session +from gatehouse_app.models.organization.organization_member import OrganizationMember + +logger = logging.getLogger(__name__) + + +class UsageMetric: + """Usage metric types.""" + USERS = "users" + SESSIONS = "sessions" + ACTIVE_SESSIONS = "active_sessions" + API_CALLS = "api_calls" + + +class SuperadminUsageService: + """Service for tracking and retrieving usage metrics.""" + + @staticmethod + def get_current_usage(org_id: str) -> dict: + """Get current period usage for an organization. + + Args: + org_id: Organization UUID + + Returns: + Current usage metrics including user count and active sessions + """ + org = Organization.query.get(org_id) + if not org: + raise ValueError("Organization not found") + + # Get active member count + member_count = org.get_member_count() + + # Get active sessions count + member_user_ids = [m.user_id for m in org.members if m.deleted_at is None] + active_sessions = Session.query.filter( + Session.user_id.in_(member_user_ids), + Session.deleted_at.is_(None), + Session.status == "active", + ).count() + + # Get max concurrent sessions this month + now = datetime.now(timezone.utc) + period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + # For simplicity, we'll track peak concurrent sessions + # In production, you'd want a separate tracking table + max_sessions_this_month = active_sessions # Placeholder + + return { + "organization_id": org_id, + "period_start": period_start.isoformat() + "Z", + "period_end": now.isoformat() + "Z", + "metrics": { + "users": { + "current": member_count, + "limit": None, # Will come from plan + "description": "Total organization members", + }, + "active_sessions": { + "current": active_sessions, + "max_this_month": max_sessions_this_month, + "description": "Currently active user sessions", + }, + }, + } + + @staticmethod + def get_usage_history( + org_id: str, + metric: str, + days: int = 30, + ) -> dict: + """Get usage history for a specific metric. + + Args: + org_id: Organization UUID + metric: Metric type (users, sessions) + days: Number of days of history + + Returns: + List of daily usage data points + """ + org = Organization.query.get(org_id) + if not org: + raise ValueError("Organization not found") + + now = datetime.now(timezone.utc) + start_date = now - timedelta(days=days) + + # Get member history (simplified - would need a history table in production) + history = [] + current_count = org.get_member_count() + + # Generate daily data points (placeholder - real implementation needs history table) + for i in range(days): + date = start_date + timedelta(days=i) + history.append({ + "date": date.strftime("%Y-%m-%d"), + "value": current_count, # Simplified + }) + + return { + "organization_id": org_id, + "metric": metric, + "period_start": start_date.isoformat() + "Z", + "period_end": now.isoformat() + "Z", + "history": history, + } + + @staticmethod + def get_seat_count_for_period(org_id: str, year: int, month: int) -> dict: + """Calculate maximum seat count used in a given month. + + For billing purposes - tracks the peak number of users. + + Args: + org_id: Organization UUID + year: Year + month: Month + + Returns: + Seat count data for the period + """ + org = Organization.query.get(org_id) + if not org: + raise ValueError("Organization not found") + + # Calculate first and last day of month + first_day = datetime(year, month, 1, tzinfo=timezone.utc) + if month == 12: + last_day = datetime(year + 1, 1, 1, tzinfo=timezone.utc) - timedelta(seconds=1) + else: + last_day = datetime(year, month + 1, 1, tzinfo=timezone.utc) - timedelta(seconds=1) + + # Get all members that existed during this period + members = OrganizationMember.query.filter( + OrganizationMember.organization_id == org_id, + OrganizationMember.deleted_at.is_(None), + ).all() + + # Count unique users who were members at any point during the month + max_seats = len(members) + + # Current count at end of month + current_seats = len([m for m in members if m.deleted_at is None or m.deleted_at > last_day]) + + return { + "organization_id": org_id, + "period": f"{year}-{month:02d}", + "max_seats": max_seats, + "current_seats": current_seats, + "period_start": first_day.isoformat() + "Z", + "period_end": last_day.isoformat() + "Z", + } + + @staticmethod + def adjust_usage( + org_id: str, + metric: str, + adjustment: int, + reason: str, + superadmin_id: str, + ) -> dict: + """Apply a manual usage adjustment (credit or charge). + + Args: + org_id: Organization UUID + metric: Metric to adjust + adjustment: Positive (credit) or negative (charge) + reason: Reason for adjustment + superadmin_id: Superadmin making the adjustment + + Returns: + Adjustment confirmation + """ + org = Organization.query.get(org_id) + if not org: + raise ValueError("Organization not found") + + # In production, you'd create a UsageAdjustment record + # For now, just log and return + logger.warning( + f"[SuperadminUsage] Adjustment: org={org_id}, metric={metric}, " + f"adjustment={adjustment}, reason={reason}, by={superadmin_id}" + ) + + return { + "organization_id": org_id, + "metric": metric, + "adjustment": adjustment, + "reason": reason, + "applied_at": datetime.now(timezone.utc).isoformat() + "Z", + } diff --git a/gatehouse_app/services/superadmin_user_service.py b/gatehouse_app/services/superadmin_user_service.py new file mode 100644 index 0000000..551e616 --- /dev/null +++ b/gatehouse_app/services/superadmin_user_service.py @@ -0,0 +1,371 @@ +"""User management service for superadmin operations.""" +import logging +from datetime import datetime, timezone +from gatehouse_app.models.user.user import User +from gatehouse_app.models.user.session import Session +from gatehouse_app.models.organization.organization import Organization +from gatehouse_app.models.organization.organization_member import OrganizationMember +from gatehouse_app.extensions import db + +logger = logging.getLogger(__name__) + + +class SuperadminUserService: + """Service for managing users across the platform.""" + + @staticmethod + def list_users( + page: int = 1, + per_page: int = 20, + org_id: str = None, + status: str = None, + search: str = None, + ) -> dict: + """List users with filters and pagination. + + Args: + page: Page number + per_page: Items per page + org_id: Filter by organization + status: Filter by status (active/suspended) + search: Search by email or name + + Returns: + Paginated user list with metadata + """ + query = User.query.filter(User.deleted_at.is_(None)) + + # Filter by organization + if org_id: + member_user_ids = db.session.query(OrganizationMember.user_id).filter( + OrganizationMember.organization_id == org_id, + OrganizationMember.deleted_at.is_(None), + ).all() + user_ids = [m.user_id for m in member_user_ids] + query = query.filter(User.id.in_(user_ids)) + + # Filter by status + if status == "suspended": + query = query.filter(User.status == "GLOBAL_SUSPENDED") + elif status == "active": + query = query.filter(User.status != "GLOBAL_SUSPENDED") + + # Search + if search: + search_filter = f"%{search}%" + query = query.filter( + db.or_( + User.email.ilike(search_filter), + User.full_name.ilike(search_filter), + ) + ) + + query = query.order_by(User.created_at.desc()) + + total = query.count() + users = query.offset((page - 1) * per_page).limit(per_page).all() + + items = [] + for user in users: + # Get org memberships + memberships = OrganizationMember.query.filter( + OrganizationMember.user_id == user.id, + OrganizationMember.deleted_at.is_(None), + ).all() + + orgs = [] + for m in memberships: + org = Organization.query.get(m.organization_id) + if org: + orgs.append({ + "org_id": org.id, + "org_name": org.name, + "role": m.role, + }) + + # Get active sessions count + active_sessions = Session.query.filter( + Session.user_id == user.id, + Session.deleted_at.is_(None), + Session.status == "active", + ).count() + + items.append({ + "id": user.id, + "email": user.email, + "full_name": user.full_name, + "status": user.status, + "org_count": len(orgs), + "orgs": orgs, + "active_sessions": active_sessions, + "last_login_at": user.last_login_at.isoformat() + "Z" if user.last_login_at else None, + "created_at": user.created_at.isoformat() + "Z" if user.created_at else None, + }) + + return { + "items": items, + "total": total, + "page": page, + "per_page": per_page, + "pages": (total + per_page - 1) // per_page if per_page > 0 else 0, + } + + @staticmethod + def get_user_detail(user_id: str) -> dict: + """Get detailed user information. + + Args: + user_id: User UUID + + Returns: + User detail with orgs, sessions, security methods + """ + user = User.query.get(user_id) + if not user or user.deleted_at is not None: + raise ValueError("User not found") + + # Get org memberships + memberships = OrganizationMember.query.filter( + OrganizationMember.user_id == user_id, + OrganizationMember.deleted_at.is_(None), + ).all() + + orgs = [] + for m in memberships: + org = Organization.query.get(m.organization_id) + if org: + orgs.append({ + "org_id": org.id, + "org_name": org.name, + "org_slug": org.slug, + "role": m.role, + "joined_at": m.created_at.isoformat() + "Z" if m.created_at else None, + }) + + # Get active sessions + sessions = Session.query.filter( + Session.user_id == user_id, + Session.deleted_at.is_(None), + Session.status == "active", + ).all() + + active_sessions = [{ + "id": s.id, + "ip_address": s.ip_address, + "user_agent": s.user_agent, + "created_at": s.created_at.isoformat() + "Z" if s.created_at else None, + } for s in sessions] + + # Security methods + security_methods = [] + if hasattr(user, 'totp_enabled') and user.totp_enabled: + security_methods.append({"type": "totp", "enabled": True}) + if hasattr(user, 'webauthn_enabled') and user.webauthn_enabled: + security_methods.append({"type": "webauthn", "enabled": True}) + + return { + "user": { + "id": user.id, + "email": user.email, + "full_name": user.full_name, + "status": user.status, + "mfa_enabled": user.mfa_enabled if hasattr(user, 'mfa_enabled') else False, + "last_login_at": user.last_login_at.isoformat() + "Z" if user.last_login_at else None, + "created_at": user.created_at.isoformat() + "Z" if user.created_at else None, + }, + "organizations": orgs, + "active_sessions": active_sessions, + "security_methods": security_methods, + } + + @staticmethod + def suspend_user(user_id: str) -> dict: + """Globally suspend a user. + + Args: + user_id: User UUID + + Returns: + Updated user info and count of revoked sessions + """ + user = User.query.get(user_id) + if not user or user.deleted_at is not None: + raise ValueError("User not found") + + if user.status == "GLOBAL_SUSPENDED": + raise ValueError("User is already suspended") + + user.status = "GLOBAL_SUSPENDED" + db.session.commit() + + # Revoke all sessions + revoked_count = Session.query.filter( + Session.user_id == user_id, + Session.deleted_at.is_(None), + ).update({"status": "revoked", "deleted_at": db.func.now()}) + db.session.commit() + + return { + "user": { + "id": user.id, + "email": user.email, + "status": user.status, + }, + "sessions_revoked": revoked_count, + } + + @staticmethod + def unsuspend_user(user_id: str) -> dict: + """Remove global suspension from a user. + + Args: + user_id: User UUID + + Returns: + Updated user info + """ + user = User.query.get(user_id) + if not user or user.deleted_at is not None: + raise ValueError("User not found") + + if user.status != "GLOBAL_SUSPENDED": + raise ValueError("User is not suspended") + + user.status = "active" + db.session.commit() + + return { + "user": { + "id": user.id, + "email": user.email, + "status": user.status, + }, + } + + @staticmethod + def reset_password(user_id: str) -> dict: + """Trigger password reset for user. + + Args: + user_id: User UUID + + Returns: + Email of user + """ + user = User.query.get(user_id) + if not user or user.deleted_at is not None: + raise ValueError("User not found") + + # In production, this would call AuthService.send_password_reset_email + logger.info(f"[SuperadminUserService] Password reset requested for {user.email}") + + return {"email": user.email} + + @staticmethod + def revoke_all_sessions(user_id: str) -> dict: + """Revoke all sessions for a user. + + Args: + user_id: User UUID + + Returns: + Count of revoked sessions + """ + user = User.query.get(user_id) + if not user or user.deleted_at is not None: + raise ValueError("User not found") + + result = Session.query.filter( + Session.user_id == user_id, + Session.deleted_at.is_(None), + ).update({"status": "revoked", "deleted_at": db.func.now()}) + db.session.commit() + + return { + "user_id": user_id, + "count": result, + } + + @staticmethod + def add_to_org(user_id: str, org_id: str, role: str = "member") -> dict: + """Add a user to an organization. + + Args: + user_id: User UUID + org_id: Organization UUID + role: Membership role + + Returns: + Membership details + """ + user = User.query.get(user_id) + if not user or user.deleted_at is not None: + raise ValueError("User not found") + + org = Organization.query.get(org_id) + if not org or org.deleted_at is not None: + raise ValueError("Organization not found") + + # Check if already a member + existing = OrganizationMember.query.filter( + OrganizationMember.user_id == user_id, + OrganizationMember.organization_id == org_id, + OrganizationMember.deleted_at.is_(None), + ).first() + + if existing: + raise ValueError("User is already a member of this organization") + + membership = OrganizationMember( + user_id=user_id, + organization_id=org_id, + role=role, + ) + db.session.add(membership) + db.session.commit() + + return { + "user_id": user_id, + "organization_id": org_id, + "role": role, + "joined_at": membership.created_at.isoformat() + "Z" if membership.created_at else None, + } + + @staticmethod + def remove_from_org(user_id: str, org_id: str) -> dict: + """Remove a user from an organization. + + Args: + user_id: User UUID + org_id: Organization UUID + + Returns: + Confirmation + """ + membership = OrganizationMember.query.filter( + OrganizationMember.user_id == user_id, + OrganizationMember.organization_id == org_id, + OrganizationMember.deleted_at.is_(None), + ).first() + + if not membership: + raise ValueError("User is not a member of this organization") + + # Check if user is the only owner + if membership.role == "owner": + owner_count = OrganizationMember.query.filter( + OrganizationMember.organization_id == org_id, + OrganizationMember.role == "owner", + OrganizationMember.deleted_at.is_(None), + ).count() + + if owner_count <= 1: + raise ValueError("Cannot remove the only owner from an organization. Transfer ownership first.") + + membership.deleted_at = db.func.now() + db.session.commit() + + return { + "user_id": user_id, + "organization_id": org_id, + } diff --git a/migrations/versions/b4cd6c6b3b1c_superadmin.py b/migrations/versions/b4cd6c6b3b1c_superadmin.py new file mode 100644 index 0000000..9d7ed33 --- /dev/null +++ b/migrations/versions/b4cd6c6b3b1c_superadmin.py @@ -0,0 +1,112 @@ +"""Superadmin + +Revision ID: b4cd6c6b3b1c +Revises: 6a4c4ed4a5c6 +Create Date: 2026-04-08 16:55:52.646980 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b4cd6c6b3b1c' +down_revision = '6a4c4ed4a5c6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint(None, 'activation_sessions', ['id']) + op.create_unique_constraint(None, 'application_provider_configs', ['id']) + op.create_unique_constraint(None, 'audit_logs', ['id']) + op.create_unique_constraint(None, 'authentication_methods', ['id']) + op.create_unique_constraint(None, 'ca_permissions', ['id']) + op.create_unique_constraint(None, 'cas', ['id']) + op.create_unique_constraint(None, 'certificate_audit_logs', ['id']) + op.create_unique_constraint(None, 'department_cert_policies', ['id']) + op.create_unique_constraint(None, 'department_memberships', ['id']) + op.create_unique_constraint(None, 'department_principals', ['id']) + op.create_unique_constraint(None, 'departments', ['id']) + op.create_unique_constraint(None, 'device_network_memberships', ['id']) + op.create_unique_constraint(None, 'devices', ['id']) + op.create_unique_constraint(None, 'email_verification_tokens', ['id']) + op.create_unique_constraint(None, 'external_provider_configs', ['id']) + op.create_unique_constraint(None, 'kill_switch_events', ['id']) + op.create_unique_constraint(None, 'mfa_policy_compliance', ['id']) + op.create_unique_constraint(None, 'oauth_states', ['id']) + op.create_unique_constraint(None, 'oidc_audit_logs', ['id']) + op.create_unique_constraint(None, 'oidc_authorization_codes', ['id']) + op.create_unique_constraint(None, 'oidc_clients', ['id']) + op.create_unique_constraint(None, 'oidc_refresh_tokens', ['id']) + op.create_unique_constraint(None, 'oidc_sessions', ['id']) + op.create_unique_constraint(None, 'org_invite_tokens', ['id']) + op.create_unique_constraint(None, 'organization_api_keys', ['id']) + op.create_unique_constraint(None, 'organization_members', ['id']) + op.create_unique_constraint(None, 'organization_provider_overrides', ['id']) + op.create_unique_constraint(None, 'organization_security_policies', ['id']) + op.create_unique_constraint(None, 'organizations', ['id']) + op.create_unique_constraint(None, 'password_reset_tokens', ['id']) + op.create_unique_constraint(None, 'portal_networks', ['id']) + op.create_unique_constraint(None, 'principal_memberships', ['id']) + op.create_unique_constraint(None, 'principals', ['id']) + op.create_unique_constraint(None, 'sessions', ['id']) + op.create_unique_constraint(None, 'ssh_certificates', ['id']) + op.create_unique_constraint(None, 'ssh_keys', ['id']) + op.create_unique_constraint(None, 'superadmin_audit_logs', ['id']) + op.create_unique_constraint(None, 'superadmin_sessions', ['id']) + op.create_unique_constraint(None, 'superadmins', ['id']) + op.create_unique_constraint(None, 'user_network_approvals', ['id']) + op.create_unique_constraint(None, 'user_security_policies', ['id']) + op.create_unique_constraint(None, 'users', ['id']) + op.create_unique_constraint(None, 'zerotier_memberships', ['id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'zerotier_memberships', type_='unique') + op.drop_constraint(None, 'users', type_='unique') + op.drop_constraint(None, 'user_security_policies', type_='unique') + op.drop_constraint(None, 'user_network_approvals', type_='unique') + op.drop_constraint(None, 'superadmins', type_='unique') + op.drop_constraint(None, 'superadmin_sessions', type_='unique') + op.drop_constraint(None, 'superadmin_audit_logs', type_='unique') + op.drop_constraint(None, 'ssh_keys', type_='unique') + op.drop_constraint(None, 'ssh_certificates', type_='unique') + op.drop_constraint(None, 'sessions', type_='unique') + op.drop_constraint(None, 'principals', type_='unique') + op.drop_constraint(None, 'principal_memberships', type_='unique') + op.drop_constraint(None, 'portal_networks', type_='unique') + op.drop_constraint(None, 'password_reset_tokens', type_='unique') + op.drop_constraint(None, 'organizations', type_='unique') + op.drop_constraint(None, 'organization_security_policies', type_='unique') + op.drop_constraint(None, 'organization_provider_overrides', type_='unique') + op.drop_constraint(None, 'organization_members', type_='unique') + op.drop_constraint(None, 'organization_api_keys', type_='unique') + op.drop_constraint(None, 'org_invite_tokens', type_='unique') + op.drop_constraint(None, 'oidc_sessions', type_='unique') + op.drop_constraint(None, 'oidc_refresh_tokens', type_='unique') + op.drop_constraint(None, 'oidc_clients', type_='unique') + op.drop_constraint(None, 'oidc_authorization_codes', type_='unique') + op.drop_constraint(None, 'oidc_audit_logs', type_='unique') + op.drop_constraint(None, 'oauth_states', type_='unique') + op.drop_constraint(None, 'mfa_policy_compliance', type_='unique') + op.drop_constraint(None, 'kill_switch_events', type_='unique') + op.drop_constraint(None, 'external_provider_configs', type_='unique') + op.drop_constraint(None, 'email_verification_tokens', type_='unique') + op.drop_constraint(None, 'devices', type_='unique') + op.drop_constraint(None, 'device_network_memberships', type_='unique') + op.drop_constraint(None, 'departments', type_='unique') + op.drop_constraint(None, 'department_principals', type_='unique') + op.drop_constraint(None, 'department_memberships', type_='unique') + op.drop_constraint(None, 'department_cert_policies', type_='unique') + op.drop_constraint(None, 'certificate_audit_logs', type_='unique') + op.drop_constraint(None, 'cas', type_='unique') + op.drop_constraint(None, 'ca_permissions', type_='unique') + op.drop_constraint(None, 'authentication_methods', type_='unique') + op.drop_constraint(None, 'audit_logs', type_='unique') + op.drop_constraint(None, 'application_provider_configs', type_='unique') + op.drop_constraint(None, 'activation_sessions', type_='unique') + # ### end Alembic commands ### diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..42b18b8 --- /dev/null +++ b/tests/api/__init__.py @@ -0,0 +1 @@ +# API tests package diff --git a/tests/api/v1/__init__.py b/tests/api/v1/__init__.py new file mode 100644 index 0000000..395961b --- /dev/null +++ b/tests/api/v1/__init__.py @@ -0,0 +1 @@ +# API v1 tests package diff --git a/tests/api/v1/ssh/__init__.py b/tests/api/v1/ssh/__init__.py new file mode 100644 index 0000000..973e118 --- /dev/null +++ b/tests/api/v1/ssh/__init__.py @@ -0,0 +1 @@ +# SSH tests package