05/06/2026 Initial commit
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
decorators.py — Shared route decorators.
|
||||
@require_role(*roles) — Enforce system role on a route.
|
||||
@tenant_feature_required(flag) — Gate a route behind a plan feature flag.
|
||||
@demo_readonly — Block write operations on the demo tenant.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import functools
|
||||
from flask import g, abort, jsonify, request
|
||||
from flask_login import current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def require_role(*roles):
|
||||
"""
|
||||
Decorator that enforces the current user holds one of the specified roles.
|
||||
Works for both system_users (superadmin) and tenant users / staff.
|
||||
|
||||
Usage:
|
||||
@require_role("tenant_admin")
|
||||
@require_role("tenant_admin", "tenant_manager")
|
||||
@require_role("superadmin")
|
||||
"""
|
||||
def decorator(fn):
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
abort(401)
|
||||
|
||||
user_role = getattr(current_user, "role", None)
|
||||
if user_role not in roles:
|
||||
logger.warning(
|
||||
"Role check failed: user %s has role '%s', required one of %s",
|
||||
getattr(current_user, "id", "?"), user_role, roles,
|
||||
)
|
||||
abort(403)
|
||||
return fn(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def tenant_feature_required(flag: str):
|
||||
"""
|
||||
Decorator that gates a route behind a plan feature flag.
|
||||
Checks g.tenant.plan.features_json for the given flag.
|
||||
Also respects tenant_setting_overrides for force-enable/disable.
|
||||
|
||||
Usage:
|
||||
@tenant_feature_required("marketing")
|
||||
@tenant_feature_required("inventory")
|
||||
"""
|
||||
def decorator(fn):
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
tenant = getattr(g, "tenant", None)
|
||||
if tenant is None:
|
||||
abort(403)
|
||||
|
||||
# Check for superadmin override (force-enable / force-disable)
|
||||
from app.models.platform import TenantSettingOverride
|
||||
override = TenantSettingOverride.query.filter_by(
|
||||
tenant_id=tenant.id,
|
||||
setting_key=f"feature.{flag}",
|
||||
lifted_at=None,
|
||||
).first()
|
||||
|
||||
if override is not None:
|
||||
enabled = override.setting_value in ("1", "true", "True")
|
||||
if not enabled:
|
||||
logger.info(
|
||||
"Feature '%s' force-disabled by admin override for tenant %s",
|
||||
flag, tenant.slug,
|
||||
)
|
||||
abort(403)
|
||||
# force-enabled: proceed regardless of plan
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
# Check plan feature flags
|
||||
if tenant.plan is None or not tenant.plan.has_feature(flag):
|
||||
logger.info(
|
||||
"Feature '%s' not available on plan '%s' for tenant %s",
|
||||
flag,
|
||||
tenant.plan.name if tenant.plan else "unknown",
|
||||
tenant.slug,
|
||||
)
|
||||
abort(403)
|
||||
|
||||
return fn(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def demo_readonly(fn):
|
||||
"""
|
||||
Decorator that blocks all write operations (POST, PUT, PATCH, DELETE)
|
||||
when the active tenant is the demo tenant.
|
||||
Returns 403 with a JSON or HTML response depending on the request type.
|
||||
|
||||
Usage:
|
||||
@demo_readonly
|
||||
def create_customer():
|
||||
...
|
||||
"""
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||
tenant = getattr(g, "tenant", None)
|
||||
if tenant and tenant.is_demo:
|
||||
logger.info(
|
||||
"Demo write blocked: %s %s", request.method, request.path
|
||||
)
|
||||
if request.is_json or request.path.startswith("/api/"):
|
||||
return jsonify(
|
||||
error="Demo account is read-only. "
|
||||
"Sign up for a full account to make changes."
|
||||
), 403
|
||||
from flask import flash, redirect, request as req
|
||||
flash(
|
||||
"This is a demo account. Sign up for a full account to make changes.",
|
||||
"warning",
|
||||
)
|
||||
return redirect(req.referrer or "/")
|
||||
return fn(*args, **kwargs)
|
||||
return wrapper
|
||||
Reference in New Issue
Block a user