- Check for consent before allowing users to perform activities in the administrative app.

This commit is contained in:
Josako
2025-10-14 16:20:30 +02:00
parent 37819cd7e5
commit 3ea3a06de6
11 changed files with 316 additions and 23 deletions

View File

@@ -347,7 +347,7 @@ class ConsentVersion(db.Model):
updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
class ConsentStatus(Enum):
class ConsentStatus(str, Enum):
CONSENTED = 'CONSENTED'
NOT_CONSENTED = 'NOT_CONSENTED'
RENEWAL_REQUIRED = 'RENEWAL_REQUIRED'

View File

@@ -6,7 +6,6 @@ from sqlalchemy.exc import SQLAlchemyError
from common.models.entitlements import PartnerServiceLicenseTier
from common.utils.eveai_exceptions import EveAINoManagementPartnerService, EveAINoSessionPartner
from common.utils.security_utils import current_user_has_role
class PartnerServices:

View File

@@ -11,7 +11,6 @@ from common.utils.eveai_exceptions import EveAINoManagementPartnerService
from common.utils.model_logging_utils import set_logging_information
from datetime import datetime as dt, timezone as tz
from common.utils.security_utils import current_user_has_role
class TenantServices:
@@ -201,3 +200,29 @@ class TenantServices:
break
return status
@staticmethod
def get_consent_status_details(tenant_id: int) -> Dict[str, str]:
cts = current_app.config.get("CONSENT_TYPES")
details = {}
for ct in cts:
ct = cv.consent_type
consent = (TenantConsent.query.filter_by(tenant_id=tenant_id, consent_type=ct)
.order_by(desc(TenantConsent.id))
.first())
if not consent:
details[ct] = {
'status': str(ConsentStatus.NOT_CONSENTED),
'version': str(cv.consent_version)
}
continue
if cv.consent_valid_to.date >= dt.now(tz.utc).date():
details[ct] = {
'status': str(ConsentStatus.RENEWAL_REQUIRED),
'version': str(cv.consent_version)
}
details[ct] = {
'status': str(ConsentStatus.CONSENTED),
'version': str(cv.consent_version)
}

View File

@@ -6,6 +6,7 @@ from common.models.entitlements import License
from common.utils.database import Database
from common.utils.eveai_exceptions import EveAITenantNotFound, EveAITenantInvalid, EveAINoActiveLicense
from datetime import datetime as dt, timezone as tz
from common.services.user import TenantServices
# Definition of Trigger Handlers
@@ -19,12 +20,15 @@ def set_tenant_session_data(sender, user, **kwargs):
# Remove partner from session if it exists
session.pop('partner', None)
session['consent_status'] = str(TenantServices.get_consent_status(user.tenant_id))
def clear_tenant_session_data(sender, user, **kwargs):
session.pop('tenant', None)
session.pop('default_language', None)
session.pop('default_llm_model', None)
session.pop('partner', None)
session.pop('consent_status', None)
def is_valid_tenant(tenant_id):

View File

@@ -1,8 +1,8 @@
from flask import current_app, render_template
from flask import current_app, render_template, request, redirect, session, flash
from flask_security import current_user
from itsdangerous import URLSafeTimedSerializer
from common.models.user import Role
from common.models.user import Role, ConsentStatus
from common.utils.nginx_utils import prefixed_url_for
from common.utils.mail_utils import send_email
@@ -96,3 +96,94 @@ def current_user_roles():
def all_user_roles():
roles = [(role.id, role.name) for role in Role.query.all()]
def is_exempt_endpoint(endpoint: str) -> bool:
"""Check if the endpoint is exempt from consent guard"""
if not endpoint:
return False
cfg = current_app.config or {}
endpoints_cfg = set(cfg.get('CONSENT_GUARD_EXEMPT_ENDPOINTS', []))
prefix_cfg = list(cfg.get('CONSENT_GUARD_EXEMPT_PREFIXES', []))
default_endpoints = {
'security_bp.login',
'security_bp.logout',
'security_bp.confirm_email',
'security_bp.forgot_password',
'security_bp.reset_password',
'security_bp.reset_password_request',
'user_bp.tenant_consent',
'user_bp.no_consent',
'user_bp.tenant_consent_renewal',
'user_bp.consent_renewal',
'security_bp.consent_sign',
}
default_prefixes = [
'security_bp.',
'healthz_bp.',
]
endpoints = default_endpoints.union(endpoints_cfg)
prefixes = default_prefixes + [p for p in prefix_cfg if isinstance(p, str)]
for p in prefixes:
if endpoint.startswith(p):
return True
if endpoint in endpoints:
return True
return False
def enforce_tenant_consent_ui():
"""Check if the user has consented to the terms of service"""
path = getattr(request, 'path', '') or ''
if path.startswith('/healthz') or path.startswith('/_healthz'):
current_app.logger.debug(f'Health check request, bypassing consent guard: {path}')
return None
if not current_user.is_authenticated:
current_app.logger.debug('Not authenticated, bypassing consent guard')
return None
endpoint = request.endpoint or ''
if is_exempt_endpoint(endpoint) or request.method == 'OPTIONS':
current_app.logger.debug(f'Endpoint exempt from consent guard: {endpoint}')
return None
# Global bypass: Super User and Partner Admin always allowed
if current_user.has_roles('Super User') or current_user.has_roles('Partner Admin'):
current_app.logger.debug('Global bypass: Super User or Partner Admin')
return None
tenant_id = getattr(current_user, 'tenant_id', None)
if not tenant_id:
tenant_id = session.get('tenant', {}).get('id') if session.get('tenant') else None
if not tenant_id:
return redirect(prefixed_url_for('security_bp.login', for_redirect=True))
status = session.get('consent_status', ConsentStatus.NOT_CONSENTED)
if status == ConsentStatus.CONSENTED:
current_app.logger.debug('User has consented')
return None
if status == ConsentStatus.NOT_CONSENTED:
current_app.logger.debug('User has not consented')
if current_user.has_roles('Tenant Admin'):
return redirect(prefixed_url_for('user_bp.tenant_consent', for_redirect=True))
return redirect(prefixed_url_for('user_bp.no_consent', for_redirect=True))
if status == ConsentStatus.RENEWAL_REQUIRED:
current_app.logger.debug('Consent renewal required')
if current_user.has_roles('Tenant Admin'):
flash(
"You need to renew your consent to our DPA or T&Cs. Failing to do so in time will stop you from accessing our services.",
"danger")
elif current_user.has_roles('Partner Admin'):
flash(
"Please ensure renewal of our DPA or T&Cs for the current Tenant. Failing to do so in time will stop the tenant from accessing our services.",
"danger")
else:
flash(
"Please inform your administrator or partner to renew your consent to our DPA or T&Cs. Failing to do so in time will stop you from accessing our services.",
"danger")
return None
current_app.logger.debug('Unknown consent status')
return redirect(prefixed_url_for('user_bp.no_consent', for_redirect=True))

View File

@@ -364,6 +364,16 @@ class Config(object):
# Whether to use dynamic fallback (X-Forwarded-Prefix/Referer) when EVEAI_APP_PREFIX is empty
EVEAI_USE_DYNAMIC_PREFIX_FALLBACK = False
# Consent guard configuration (config-driven whitelist)
# List of endpoint names to exempt from the global consent guard
# Example: ['security_bp.login', 'security_bp.logout', 'user_bp.tenant_consent']
CONSENT_GUARD_EXEMPT_ENDPOINTS = []
# List of endpoint name prefixes; any endpoint starting with one of these is exempt
# Example: ['security_bp.', 'healthz_bp.']
CONSENT_GUARD_EXEMPT_PREFIXES = []
# TTL for consent status stored in session (seconds)
CONSENT_SESSION_TTL_SECONDS = int(environ.get('CONSENT_SESSION_TTL_SECONDS', '45'))
class DevConfig(Config):
DEVELOPMENT = True

View File

@@ -12,6 +12,7 @@ from common.models.user import User, Role, Tenant, TenantDomain
import common.models.interaction
import common.models.entitlements
import common.models.document
from common.utils.security_utils import enforce_tenant_consent_ui
from config.logging_config import configure_logging
from common.utils.security import set_tenant_session_data
from common.utils.errors import register_error_handlers
@@ -109,6 +110,12 @@ def create_app(config_file=None):
sqlalchemy_logger.setLevel(logging.DEBUG)
# log_request_middleware(app) # Add this when debugging nginx or another proxy
# Register global consent guard via extension
@app.before_request
def enforce_tenant_consent():
app.logger.debug("Enforcing tenant consent")
return enforce_tenant_consent_ui()
# @app.before_request
# def before_request():
# # app.logger.debug(f"Before request - Session ID: {session.sid}")

View File

@@ -1,5 +1,5 @@
# views/security_views.py
from flask import Blueprint, render_template, redirect, request, flash, current_app, abort, session
from flask import Blueprint, render_template, redirect, request, flash, current_app, abort, session, jsonify
from flask_security import current_user, login_required, login_user, logout_user
from flask_security.utils import verify_and_update_password, get_message, do_flash, config_value, hash_password
from flask_security.forms import LoginForm
@@ -11,7 +11,7 @@ from itsdangerous import URLSafeTimedSerializer
from sqlalchemy.exc import SQLAlchemyError
from common.models.user import User, ConsentStatus
from common.services.user import TenantServices
from common.services.user import TenantServices, UserServices
from common.utils.eveai_exceptions import EveAIException, EveAINoActiveLicense
from common.utils.nginx_utils import prefixed_url_for
from eveai_app.views.security_forms import SetPasswordForm, ResetPasswordForm, ForgotPasswordForm
@@ -59,22 +59,8 @@ def login():
return redirect(prefixed_url_for('user_bp.tenants', for_redirect=True))
if current_user.has_roles('Partner Admin'):
return redirect(prefixed_url_for('user_bp.tenants', for_redirect=True))
consent_status = TenantServices.get_consent_status(user.tenant_id)
match consent_status:
case ConsentStatus.CONSENTED:
return redirect(prefixed_url_for('user_bp.tenant_overview', for_redirect=True))
case ConsentStatus.NOT_CONSENTED:
if current_user.has_roles('Tenant Admin'):
return redirect(prefixed_url_for('user_bp.tenant_consent', for_redirect=True))
else:
return redirect(prefixed_url_for('user_bp.no_consent', for_redirect=True))
case ConsentStatus.RENEWAL_REQUIRED:
if current_user.has_roles('Tenant Admin'):
return redirect(prefixed_url_for('user_bp.tenant_consent_renewal', for_redirect=True))
else:
return redirect(prefixed_url_for('user_bp.consent_renewal', for_redirect=True))
case _:
return redirect(prefixed_url_for('basic_bp.index', for_redirect=True))
# After login, rely on global consent guard; just go to default start
return redirect(prefixed_url_for('user_bp.tenant_overview', for_redirect=True))
else:
flash('Invalid username or password', 'danger')
current_app.logger.error(f'Invalid username or password for given email: {user.email}')
@@ -160,6 +146,102 @@ def reset_password(token):
return render_template('security/reset_password.html', reset_password_form=form)
@security_bp.route('/consent/sign', methods=['POST'])
@login_required
def consent_sign():
try:
# Determine tenant context
tenant_id = None
# Payload may provide a tenant_id for admins signing for others
if request.is_json:
payload = request.get_json(silent=True) or {}
tenant_id = payload.get('tenant_id')
consent_data = payload.get('consent_data', {})
else:
tenant_id = request.form.get('tenant_id')
consent_data = {}
if tenant_id is None:
# default to user's tenant (Tenant Admin)
tenant_id = current_user.tenant_id
tenant_id = int(tenant_id)
# Authorization
allowed = False
if current_user.has_roles('Super User'):
allowed = True
elif current_user.has_roles('Partner Admin') and UserServices.can_user_edit_tenant(tenant_id):
allowed = True
elif current_user.has_roles('Tenant Admin') and getattr(current_user, 'tenant_id', None) == tenant_id:
allowed = True
if not allowed:
abort(403)
# Determine consent versions/types to record
cts = current_app.config.get('CONSENT_TYPES', [])
from common.models.user import TenantConsent, ConsentVersion, PartnerService
from common.services.user.partner_services import PartnerServices
# Resolve partner and management service if available in session (for Partner Admin)
partner_id = None
partner_service_id = None
try:
if 'partner' in session and session['partner'].get('services'):
partner_id = session['partner'].get('id')
mgmt = PartnerServices.get_management_service()
if mgmt:
partner_service_id = mgmt.get('id')
except Exception:
pass
# Fallbacks if not Partner Admin context
if partner_id is None:
# Try find partner by tenant (one-to-one in model)
from common.models.user import Partner, Tenant
t = Tenant.query.get(tenant_id)
if t and t.partner:
partner_id = t.partner.id
if partner_service_id is None and partner_id is not None:
ps = PartnerService.query.filter_by(partner_id=partner_id, type='MANAGEMENT_SERVICE').first()
if ps:
partner_service_id = ps.id
# For each consent type, record acceptance of latest version
now = dt.now(tz.utc)
for ct in cts:
cv = ConsentVersion.query.filter_by(consent_type=ct).order_by(ConsentVersion.consent_valid_from.desc()).first()
if not cv:
current_app.logger.error(f'No ConsentVersion found for type {ct}; skipping')
continue
tc = TenantConsent(
tenant_id=tenant_id,
partner_id=partner_id or 0,
partner_service_id=partner_service_id or 0,
user_id=current_user.id,
consent_type=ct,
consent_version=cv.consent_version,
consent_data=consent_data or {}
)
db.session.add(tc)
db.session.commit()
if request.is_json or 'application/json' in request.headers.get('Accept', ''):
return jsonify({'ok': True, 'tenant_id': tenant_id}), 200
# Default UX: go to overview
return redirect(prefixed_url_for('user_bp.tenant_overview', for_redirect=True))
except CSRFError:
if request.is_json:
return jsonify({'ok': False, 'error': 'csrf_error'}), 400
flash('Session expired. Please retry.', 'danger')
return redirect(prefixed_url_for('user_bp.no_consent', for_redirect=True))
except Exception as e:
current_app.logger.error(f'Consent signing failed: {e}')
db.session.rollback()
if request.is_json:
return jsonify({'ok': False, 'error': str(e)}), 400
flash('Failed to sign consent.', 'danger')
return redirect(prefixed_url_for('user_bp.no_consent', for_redirect=True))

View File

@@ -232,3 +232,5 @@ class EditConsentVersionForm(FlaskForm):
consent_valid_to = DateField('Consent Valid To', id='form-control datepicker', validators=[Optional()])
class TenantConsentForm(FlaskForm):

View File

@@ -6,6 +6,8 @@ from flask_security import roles_accepted, current_user
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
import ast
from wtforms import BooleanField
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant, TenantMake, \
ConsentVersion
from common.extensions import db, security, minio_client, simple_encryption, cache_manager
@@ -33,6 +35,32 @@ from eveai_app.views.list_views.list_view_utils import render_list_view
user_bp = Blueprint('user_bp', __name__, url_prefix='/user')
# --- Consent flow placeholder views ---
@user_bp.route('/consent/tenant', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def tenant_consent():
# Placeholder view; UI can be implemented in templates
return render_template('user/tenant_consent.html') if current_app.jinja_env.loader else "Tenant Consent"
@user_bp.route('/consent/no_access', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def no_consent():
return render_template('user/no_consent.html') if current_app.jinja_env.loader else "Consent required - contact your admin"
@user_bp.route('/consent/tenant_renewal', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def tenant_consent_renewal():
return render_template('user/tenant_consent_renewal.html') if current_app.jinja_env.loader else "Tenant Consent Renewal"
@user_bp.route('/consent/renewal', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def consent_renewal():
return render_template('user/consent_renewal.html') if current_app.jinja_env.loader else "Consent renewal in progress"
@user_bp.before_request
def log_before_request():
current_app.logger.debug(f'Before request: {request.path} =====================================')
@@ -777,6 +805,15 @@ def edit_consent_version(consent_version_id):
return render_template('user/edit_consent_version.html', form=form, consent_version_id=consent_version_id)
@user_bp.route('/tenant_consent', methods=['GET', 'POST'])
@roles_accepted('Tenant Admin')
def tenant_consent():
dpa_consent = BooleanField("DPA Consent", default=False)
t_c_consent = BooleanField("T&C Consent", default=False)
def reset_uniquifier(user):
security.datastore.set_uniquifier(user)
db.session.add(user)

View File

@@ -0,0 +1,36 @@
"""consent_version iso sepearte version for t&c and dpa
Revision ID: a6ee51d72bb4
Revises: f5f1a8b8e238
Create Date: 2025-10-14 09:00:36.680468
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a6ee51d72bb4'
down_revision = 'f5f1a8b8e238'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tenant_consent', schema=None) as batch_op:
batch_op.add_column(sa.Column('consent_version', sa.String(length=20), nullable=False))
batch_op.drop_column('consent_dpa_version')
batch_op.drop_column('consent_t_c_version')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tenant_consent', schema=None) as batch_op:
batch_op.add_column(sa.Column('consent_t_c_version', sa.VARCHAR(length=20), autoincrement=False, nullable=False))
batch_op.add_column(sa.Column('consent_dpa_version', sa.VARCHAR(length=20), autoincrement=False, nullable=False))
batch_op.drop_column('consent_version')
# ### end Alembic commands ###