From 3ea3a06de65e31023298ec2d566ce80af4d79e4f Mon Sep 17 00:00:00 2001 From: Josako Date: Tue, 14 Oct 2025 16:20:30 +0200 Subject: [PATCH] - Check for consent before allowing users to perform activities in the administrative app. --- common/models/user.py | 2 +- common/services/user/partner_services.py | 1 - common/services/user/tenant_services.py | 27 +++- common/utils/security.py | 4 + common/utils/security_utils.py | 95 +++++++++++++- config/config.py | 10 ++ eveai_app/__init__.py | 7 ++ eveai_app/views/security_views.py | 118 +++++++++++++++--- eveai_app/views/user_forms.py | 2 + eveai_app/views/user_views.py | 37 ++++++ ...4_consent_version_iso_sepearte_version_.py | 36 ++++++ 11 files changed, 316 insertions(+), 23 deletions(-) create mode 100644 migrations/public/versions/a6ee51d72bb4_consent_version_iso_sepearte_version_.py diff --git a/common/models/user.py b/common/models/user.py index 45113b6..ce6bd4b 100644 --- a/common/models/user.py +++ b/common/models/user.py @@ -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' diff --git a/common/services/user/partner_services.py b/common/services/user/partner_services.py index 0ad2b12..9f1460b 100644 --- a/common/services/user/partner_services.py +++ b/common/services/user/partner_services.py @@ -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: diff --git a/common/services/user/tenant_services.py b/common/services/user/tenant_services.py index 9749a24..10d3657 100644 --- a/common/services/user/tenant_services.py +++ b/common/services/user/tenant_services.py @@ -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) + } diff --git a/common/utils/security.py b/common/utils/security.py index b8946fc..7b24793 100644 --- a/common/utils/security.py +++ b/common/utils/security.py @@ -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): diff --git a/common/utils/security_utils.py b/common/utils/security_utils.py index 991aa4a..c4105b9 100644 --- a/common/utils/security_utils.py +++ b/common/utils/security_utils.py @@ -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)) diff --git a/config/config.py b/config/config.py index 687797b..5f9b9e8 100644 --- a/config/config.py +++ b/config/config.py @@ -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 diff --git a/eveai_app/__init__.py b/eveai_app/__init__.py index 905eafc..5bcacec 100644 --- a/eveai_app/__init__.py +++ b/eveai_app/__init__.py @@ -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}") diff --git a/eveai_app/views/security_views.py b/eveai_app/views/security_views.py index 3d0fded..598666e 100644 --- a/eveai_app/views/security_views.py +++ b/eveai_app/views/security_views.py @@ -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)) + + diff --git a/eveai_app/views/user_forms.py b/eveai_app/views/user_forms.py index a9547d1..8af6976 100644 --- a/eveai_app/views/user_forms.py +++ b/eveai_app/views/user_forms.py @@ -232,3 +232,5 @@ class EditConsentVersionForm(FlaskForm): consent_valid_to = DateField('Consent Valid To', id='form-control datepicker', validators=[Optional()]) +class TenantConsentForm(FlaskForm): + diff --git a/eveai_app/views/user_views.py b/eveai_app/views/user_views.py index 1f480b0..c98fd34 100644 --- a/eveai_app/views/user_views.py +++ b/eveai_app/views/user_views.py @@ -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) diff --git a/migrations/public/versions/a6ee51d72bb4_consent_version_iso_sepearte_version_.py b/migrations/public/versions/a6ee51d72bb4_consent_version_iso_sepearte_version_.py new file mode 100644 index 0000000..dfe74c1 --- /dev/null +++ b/migrations/public/versions/a6ee51d72bb4_consent_version_iso_sepearte_version_.py @@ -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 ###