- 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) updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
class ConsentStatus(Enum): class ConsentStatus(str, Enum):
CONSENTED = 'CONSENTED' CONSENTED = 'CONSENTED'
NOT_CONSENTED = 'NOT_CONSENTED' NOT_CONSENTED = 'NOT_CONSENTED'
RENEWAL_REQUIRED = 'RENEWAL_REQUIRED' RENEWAL_REQUIRED = 'RENEWAL_REQUIRED'

View File

@@ -6,7 +6,6 @@ from sqlalchemy.exc import SQLAlchemyError
from common.models.entitlements import PartnerServiceLicenseTier from common.models.entitlements import PartnerServiceLicenseTier
from common.utils.eveai_exceptions import EveAINoManagementPartnerService, EveAINoSessionPartner from common.utils.eveai_exceptions import EveAINoManagementPartnerService, EveAINoSessionPartner
from common.utils.security_utils import current_user_has_role
class PartnerServices: 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 common.utils.model_logging_utils import set_logging_information
from datetime import datetime as dt, timezone as tz from datetime import datetime as dt, timezone as tz
from common.utils.security_utils import current_user_has_role
class TenantServices: class TenantServices:
@@ -201,3 +200,29 @@ class TenantServices:
break break
return status 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.database import Database
from common.utils.eveai_exceptions import EveAITenantNotFound, EveAITenantInvalid, EveAINoActiveLicense from common.utils.eveai_exceptions import EveAITenantNotFound, EveAITenantInvalid, EveAINoActiveLicense
from datetime import datetime as dt, timezone as tz from datetime import datetime as dt, timezone as tz
from common.services.user import TenantServices
# Definition of Trigger Handlers # Definition of Trigger Handlers
@@ -19,12 +20,15 @@ def set_tenant_session_data(sender, user, **kwargs):
# Remove partner from session if it exists # Remove partner from session if it exists
session.pop('partner', None) session.pop('partner', None)
session['consent_status'] = str(TenantServices.get_consent_status(user.tenant_id))
def clear_tenant_session_data(sender, user, **kwargs): def clear_tenant_session_data(sender, user, **kwargs):
session.pop('tenant', None) session.pop('tenant', None)
session.pop('default_language', None) session.pop('default_language', None)
session.pop('default_llm_model', None) session.pop('default_llm_model', None)
session.pop('partner', None) session.pop('partner', None)
session.pop('consent_status', None)
def is_valid_tenant(tenant_id): 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 flask_security import current_user
from itsdangerous import URLSafeTimedSerializer 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.nginx_utils import prefixed_url_for
from common.utils.mail_utils import send_email from common.utils.mail_utils import send_email
@@ -96,3 +96,94 @@ def current_user_roles():
def all_user_roles(): def all_user_roles():
roles = [(role.id, role.name) for role in Role.query.all()] 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 # Whether to use dynamic fallback (X-Forwarded-Prefix/Referer) when EVEAI_APP_PREFIX is empty
EVEAI_USE_DYNAMIC_PREFIX_FALLBACK = False 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): class DevConfig(Config):
DEVELOPMENT = True DEVELOPMENT = True

View File

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

View File

@@ -1,5 +1,5 @@
# views/security_views.py # 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 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.utils import verify_and_update_password, get_message, do_flash, config_value, hash_password
from flask_security.forms import LoginForm from flask_security.forms import LoginForm
@@ -11,7 +11,7 @@ from itsdangerous import URLSafeTimedSerializer
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from common.models.user import User, ConsentStatus 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.eveai_exceptions import EveAIException, EveAINoActiveLicense
from common.utils.nginx_utils import prefixed_url_for from common.utils.nginx_utils import prefixed_url_for
from eveai_app.views.security_forms import SetPasswordForm, ResetPasswordForm, ForgotPasswordForm 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)) return redirect(prefixed_url_for('user_bp.tenants', for_redirect=True))
if current_user.has_roles('Partner Admin'): if current_user.has_roles('Partner Admin'):
return redirect(prefixed_url_for('user_bp.tenants', for_redirect=True)) return redirect(prefixed_url_for('user_bp.tenants', for_redirect=True))
consent_status = TenantServices.get_consent_status(user.tenant_id) # After login, rely on global consent guard; just go to default start
match consent_status:
case ConsentStatus.CONSENTED:
return redirect(prefixed_url_for('user_bp.tenant_overview', for_redirect=True)) 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))
else: else:
flash('Invalid username or password', 'danger') flash('Invalid username or password', 'danger')
current_app.logger.error(f'Invalid username or password for given email: {user.email}') 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) 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()]) 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 from sqlalchemy.exc import SQLAlchemyError, IntegrityError
import ast import ast
from wtforms import BooleanField
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant, TenantMake, \ from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant, TenantMake, \
ConsentVersion ConsentVersion
from common.extensions import db, security, minio_client, simple_encryption, cache_manager 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') 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 @user_bp.before_request
def log_before_request(): def log_before_request():
current_app.logger.debug(f'Before request: {request.path} =====================================') 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) 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): def reset_uniquifier(user):
security.datastore.set_uniquifier(user) security.datastore.set_uniquifier(user)
db.session.add(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 ###