Add superadmin routes to API

This commit is contained in:
2026-04-21 17:11:03 +09:30
parent aaec6af6ad
commit 1778dd85d5
33 changed files with 4831 additions and 31 deletions
+144
View File
@@ -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.*
@@ -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
+286
View File
@@ -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/<user_id>", 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/<user_id>", 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"
)
+568
View File
@@ -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/<plan_id>", 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/<plan_id>", 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/<plan_id>", 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/<sub_id>", 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/<org_id>", 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/<org_id>/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/<org_id>/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",
)
+56
View File
@@ -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/<org_id>/cas/<ca_id>", 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"
)
@@ -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/<org_id>/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/<org_id>/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/<org_id>/members/<user_id>", 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/<org_id>/members/<user_id>", 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/<org_id>/transfer-ownership/<user_id>", 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",
)
@@ -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/<org_id>", 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/<org_id>", 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/<org_id>/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/<org_id>/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/<org_id>", 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",
)
@@ -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/<org_id>", 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/<org_id>/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/<org_id>/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/<org_id>/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/<org_id>", 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",
)
+516
View File
@@ -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/<user_id>", 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/<user_id>/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/<user_id>/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/<user_id>/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/<user_id>/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/<user_id>/add-to-org/<org_id>", 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/<user_id>/orgs/<org_id>", 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",
)
+13 -29
View File
@@ -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/<user_id>/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},
)
+203
View File
@@ -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
+13
View File
@@ -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",
]
+5
View File
@@ -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"]
+61
View File
@@ -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"<Plan {self.slug}: ${self.price_monthly / 100}/mo>"
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,
}
@@ -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"<Subscription org={self.organization_id} status={self.status.value}>"
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,
}
@@ -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]
@@ -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"]
@@ -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"<Superadmin {self.email}>"
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)
@@ -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"<SuperadminSession superadmin_id={self.superadmin_id}>"
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)
@@ -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"<SuperadminAuditLog superadmin={self.superadmin_id} "
f"action={self.action} resource={self.resource_type}/{self.resource_id}>"
)
def to_dict(self, exclude=None):
"""Convert to dictionary."""
exclude = exclude or []
return super().to_dict(exclude=exclude)
+40
View File
@@ -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.
+4
View File
@@ -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",
]
+192
View File
@@ -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,
}
@@ -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:
@@ -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
]
@@ -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
@@ -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
@@ -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",
}
@@ -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,
}
@@ -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 ###
+1
View File
@@ -0,0 +1 @@
# API tests package
+1
View File
@@ -0,0 +1 @@
# API v1 tests package
+1
View File
@@ -0,0 +1 @@
# SSH tests package