From a639d2b77a663e2654cc54a232db8faed46961f3 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Wed, 8 Oct 2025 12:37:38 +0200 Subject: [PATCH 1/3] delete expired rules method in rule service --- flowapp/services/rule_service.py | 77 ++++++++++++++++++++++++++++++++ flowapp/views/rules.py | 25 ++++++++--- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/flowapp/services/rule_service.py b/flowapp/services/rule_service.py index fca7719..7741528 100644 --- a/flowapp/services/rule_service.py +++ b/flowapp/services/rule_service.py @@ -531,3 +531,80 @@ def delete_rtbh_and_create_whitelist( current_app.logger.exception(f"Error creating whitelist entry: {e}") messages.append(f"Rule deleted but failed to create whitelist: {str(e)}") return False, messages, None + + +def delete_expired_rules() -> Dict[str, int]: + """ + Delete all expired rules older than EXPIRATION_THRESHOLD days. + Only deletes rules in withdrawn or deleted state. + + Returns: + Dictionary with deletion counts per rule type + """ + current_time = datetime.now() + expiration_threshold = current_app.config.get("EXPIRATION_THRESHOLD", 30) + deletion_date = current_time - timedelta(days=expiration_threshold) + + deletion_counts = {"rtbh": 0, "ipv4": 0, "ipv6": 0, "total": 0} + + model_map = { + "rtbh": (RTBH, RuleTypes.RTBH), + "ipv4": (Flowspec4, RuleTypes.IPv4), + "ipv6": (Flowspec6, RuleTypes.IPv6), + } + + for rule_type, (model_class, rule_enum) in model_map.items(): + # Get IDs of rules to delete + expired_rule_ids = [ + r.id + for r in db.session.query(model_class.id) + .filter( + model_class.expires < deletion_date, model_class.rstate_id.in_([2, 3]) # withdrawn or deleted state + ) + .all() + ] + + if not expired_rule_ids: + current_app.logger.info(f"No expired {model_class.__name__} rules to delete") + continue + + # Clean up whitelist cache first + cache_deleted = 0 + for rule_id in expired_rule_ids: + cache_deleted += RuleWhitelistCache.delete_by_rule_id(rule_id) + + if cache_deleted: + current_app.logger.info( + f"Deleted {cache_deleted} cache entries for {len(expired_rule_ids)} {model_class.__name__} rules" + ) + + # Bulk delete the rules + deleted = ( + db.session.query(model_class).filter(model_class.id.in_(expired_rule_ids)).delete(synchronize_session=False) + ) + + deletion_counts[rule_type] = deleted + deletion_counts["total"] += deleted + + current_app.logger.info( + f"Deleted {deleted} expired {model_class.__name__} rules " f"(older than {expiration_threshold} days)" + ) + + # Commit all deletions at once + if deletion_counts["total"] > 0: + try: + db.session.commit() + current_app.logger.info( + f"Successfully deleted {deletion_counts['total']} expired rules: " + f"RTBH={deletion_counts['rtbh']}, " + f"IPv4={deletion_counts['ipv4']}, " + f"IPv6={deletion_counts['ipv6']}" + ) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error committing rule deletions: {e}") + raise + else: + current_app.logger.info("No expired rules found to delete") + + return deletion_counts diff --git a/flowapp/views/rules.py b/flowapp/views/rules.py index 7a50dff..61359ab 100644 --- a/flowapp/views/rules.py +++ b/flowapp/views/rules.py @@ -682,12 +682,25 @@ def announce_all(): @localhost_only def withdraw_expired(): """ - cleaning endpoint - deletes expired whitelists - withdraws all expired routes from ExaBGP - deletes logs older than 30 days + Cleaning endpoint: + - Deletes expired whitelists + - Withdraws all expired routes from ExaBGP + - Deletes old expired rules + - Deletes logs older than 30 days """ - delete_expired_whitelists() + # Delete expired whitelists + whitelist_messages = delete_expired_whitelists() + for msg in whitelist_messages: + current_app.logger.info(msg) + + # Withdraw expired routes announce_all_routes(constants.WITHDRAW) + + # Delete old expired rules (in batches if needed) + deletion_counts = rule_service.delete_expired_rules() + current_app.logger.info(f"Deleted rules: {deletion_counts}") + + # Delete old logs Log.delete_old() - return " " + + return "Cleanup completed" From 3a7b92c5027fc4f7c3dffa228095002ac7c72d08 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Wed, 8 Oct 2025 13:53:00 +0200 Subject: [PATCH 2/3] Add rule access control and whitelist permission checks Introduced new auth helpers to determine which rules a user can modify Added functions get_user_allowed_rule_ids and check_user_can_modify_rule in auth module Updated rule and whitelist views to validate user permissions before deleting or updating rules Removed session based rule tracking in favor of dynamic access checks Added temporary session debug logging in dashboard Added EXPIRATION_THRESHOLD config option Bumped version to 1.1.6 --- config.example.py | 3 ++ flowapp/__about__.py | 2 +- flowapp/auth.py | 87 +++++++++++++++++++++++++++++++++++++- flowapp/views/dashboard.py | 5 ++- flowapp/views/rules.py | 60 ++++++++++++++++++++------ flowapp/views/whitelist.py | 5 ++- 6 files changed, 142 insertions(+), 20 deletions(-) diff --git a/config.example.py b/config.example.py index 80dc9f2..d5994e2 100644 --- a/config.example.py +++ b/config.example.py @@ -59,6 +59,9 @@ class Config: # list of RTBH Communities that are allowed to be used in whitelist, real ID from DB ALLOWED_COMMUNITIES = [1, 2, 3] + # treshold for expired rule retation in days + EXPIRATION_THRESHOLD = 30 + class ProductionConfig(Config): """ diff --git a/flowapp/__about__.py b/flowapp/__about__.py index c599dc3..aaf9610 100755 --- a/flowapp/__about__.py +++ b/flowapp/__about__.py @@ -1 +1 @@ -__version__ = "1.1.5" +__version__ = "1.1.6" diff --git a/flowapp/auth.py b/flowapp/auth.py index 2f12cee..a26628b 100644 --- a/flowapp/auth.py +++ b/flowapp/auth.py @@ -1,10 +1,11 @@ from functools import wraps +from typing import List, Optional from flask import current_app, redirect, request, url_for, session, abort -from flowapp import __version__ +from flowapp import __version__, db, validators +from flowapp.models import Flowspec4, Flowspec6, RTBH, Whitelist, get_user_nets -# auth atd. def auth_required(f): """ auth required decorator @@ -130,3 +131,85 @@ def get_user(): get user from session or return None """ return session.get("user_uuid", None) + + +def get_user_allowed_rule_ids(rule_type: str, user_id: int, user_role_ids: List[int]) -> List[int]: + """ + Get list of rule IDs that the user is allowed to modify. + + For admin users (role_id 3), returns all rule IDs of the given type. + For regular users, returns only rules within their network ranges. + + Args: + rule_type: Type of rule ('ipv4', 'ipv6', 'rtbh', 'whitelist') + user_id: Current user's ID + user_role_ids: List of user's role IDs + + Returns: + List of rule IDs the user can modify + """ + # Admin users can modify any rules + if 3 in user_role_ids: + if rule_type == "ipv4": + return [r.id for r in db.session.query(Flowspec4.id).all()] + elif rule_type == "ipv6": + return [r.id for r in db.session.query(Flowspec6.id).all()] + elif rule_type == "rtbh": + return [r.id for r in db.session.query(RTBH.id).all()] + elif rule_type == "whitelist": + return [r.id for r in db.session.query(Whitelist.id).all()] + return [] + + # Regular users - filter by network ranges + net_ranges = get_user_nets(user_id) + + if rule_type == "ipv4": + rules = db.session.query(Flowspec4).all() + filtered_rules = validators.filter_rules_in_network(net_ranges, rules) + return [r.id for r in filtered_rules] + + elif rule_type == "ipv6": + rules = db.session.query(Flowspec6).all() + filtered_rules = validators.filter_rules_in_network(net_ranges, rules) + return [r.id for r in filtered_rules] + + elif rule_type == "rtbh": + rules = db.session.query(RTBH).all() + filtered_rules = validators.filter_rtbh_rules(net_ranges, rules) + return [r.id for r in filtered_rules] + + elif rule_type == "whitelist": + rules = db.session.query(Whitelist).all() + filtered_rules = validators.filter_rules_in_network(net_ranges, rules) + return [r.id for r in filtered_rules] + + return [] + + +def check_user_can_modify_rule( + rule_id: int, rule_type: str, user_id: Optional[int] = None, user_role_ids: Optional[List[int]] = None +) -> bool: + """ + Check if the current user can modify a specific rule. + + Args: + rule_id: ID of the rule to check + rule_type: Type of rule ('ipv4', 'ipv6', 'rtbh', 'whitelist') + user_id: User ID (defaults to session user_id) + user_role_ids: User role IDs (defaults to session user_role_ids) + + Returns: + True if user can modify the rule, False otherwise + """ + if user_id is None: + user_id = session.get("user_id") + if user_role_ids is None: + user_role_ids = session.get("user_role_ids", []) + + # Admin users can modify any rules + if 3 in user_role_ids: + return True + + # Check if rule_id is in allowed rules for this user + allowed_ids = get_user_allowed_rule_ids(rule_type, user_id, user_role_ids) + return rule_id in allowed_ids diff --git a/flowapp/views/dashboard.py b/flowapp/views/dashboard.py index 4431927..ab1a25b 100644 --- a/flowapp/views/dashboard.py +++ b/flowapp/views/dashboard.py @@ -15,7 +15,6 @@ from flowapp import models, validators, flowspec from flowapp.auth import auth_required from flowapp.constants import ( - RULES_KEY, SORT_ARG, ORDER_ARG, DEFAULT_ORDER, @@ -110,7 +109,6 @@ def index(rtype=None, rstate="active"): # Enrich rules with whitelist information rules, whitelist_rule_ids = enrich_rules_with_whitelist_info(rules, rtype) - session[RULES_KEY] = [rule.id for rule in rules] # search rules if get_search_query: count_match = current_app.config["COUNT_MATCH"] @@ -126,6 +124,9 @@ def index(rtype=None, rstate="active"): allowed_communities = current_app.config["ALLOWED_COMMUNITIES"] + # temporary debug session content + current_app.logger.debug("Session content: %s" % session) + return view_factory( rtype=rtype, rstate=rstate, diff --git a/flowapp/views/rules.py b/flowapp/views/rules.py index 61359ab..b871eec 100644 --- a/flowapp/views/rules.py +++ b/flowapp/views/rules.py @@ -36,6 +36,8 @@ get_state_by_time, round_to_ten_minutes, ) +from flowapp.auth import get_user_allowed_rule_ids, check_user_can_modify_rule + rules = Blueprint("rules", __name__, template_folder="templates") @@ -158,6 +160,27 @@ def delete_rule(rule_type, rule_id): # Convert the integer rule_type to RuleTypes enum enum_rule_type = RuleTypes(rule_type) + # Get the rule type string for access checking + rule_type_map = {RuleTypes.IPv4.value: "ipv4", RuleTypes.IPv6.value: "ipv6", RuleTypes.RTBH.value: "rtbh"} + rule_type_str = rule_type_map.get(rule_type) + + # Check if user can modify this rule + if not check_user_can_modify_rule(rule_id, rule_type_str): + flash("You cannot delete this rule", "alert-warning") + return redirect( + url_for( + "dashboard.index", + rtype=session[constants.TYPE_ARG], + rstate=session[constants.RULE_ARG], + sort=session[constants.SORT_ARG], + squery=session[constants.SEARCH_ARG], + order=session[constants.ORDER_ARG], + ) + ) + + # Get allowed rule IDs for the service call + allowed_rule_ids = get_user_allowed_rule_ids(rule_type_str, session["user_id"], session["user_role_ids"]) + # Use the service to delete the rule success, message = rule_service.delete_rule( rule_type=enum_rule_type, @@ -165,13 +188,11 @@ def delete_rule(rule_type, rule_id): user_id=session["user_id"], user_email=session["user_email"], org_name=session["user_org"], - allowed_rule_ids=session.get(constants.RULES_KEY, []), + allowed_rule_ids=allowed_rule_ids, ) - # Flash appropriate message based on result flash(message, "alert-success" if success else "alert-warning") - # Redirect back to dashboard return redirect( url_for( "dashboard.index", @@ -190,13 +211,19 @@ def delete_rule(rule_type, rule_id): def delete_and_whitelist(rule_type, rule_id): """ Delete an RTBH rule and create a whitelist entry from it. - - :param rule_id: integer - id of the RTBH rule """ if rule_type != RuleTypes.RTBH.value: flash("Only RTBH rules can be converted to whitelists", "alert-warning") return redirect(url_for("index")) + # Check if user can modify this rule + if not check_user_can_modify_rule(rule_id, "rtbh"): + flash("You cannot delete this rule", "alert-warning") + return redirect(url_for("index")) + + # Get allowed rule IDs + allowed_rule_ids = get_user_allowed_rule_ids("rtbh", session["user_id"], session["user_role_ids"]) + # Set whitelist expiration to 7 days from now by default whitelist_expires = datetime.now() + timedelta(days=7) @@ -207,19 +234,16 @@ def delete_and_whitelist(rule_type, rule_id): org_id=session["user_org_id"], user_email=session["user_email"], org_name=session["user_org"], - allowed_rule_ids=session.get(constants.RULES_KEY, []), + allowed_rule_ids=allowed_rule_ids, whitelist_expires=whitelist_expires, ) - # Flash all messages for message in messages: flash(message, "alert-success" if success else "alert-warning") - # If successful, flash additional message about whitelist if success and whitelist: flash(f"Created whitelist entry ID {whitelist.id} from RTBH rule", "alert-info") - # Redirect back to dashboard return redirect( url_for( "dashboard.index", @@ -269,10 +293,15 @@ def group_delete(): rule_type_int = constants.RULE_TYPES_DICT[rule_type] enum_rule_type = RuleTypes(rule_type_int) route_model = ROUTE_MODELS[rule_type_int] - rules = [str(x) for x in session[constants.RULES_KEY]] + + # Get allowed rules for this user + allowed_rule_ids = get_user_allowed_rule_ids(rule_type, session["user_id"], session["user_role_ids"]) + allowed_rules_str = [str(x) for x in allowed_rule_ids] + to_delete = request.form.getlist("delete-id") - if set(to_delete).issubset(set(rules)) or is_admin(session["user_roles"]): + # Check if user has permission to delete these rules + if set(to_delete).issubset(set(allowed_rules_str)) or is_admin(session["user_roles"]): for rule_id in to_delete: # withdraw route model = db.session.get(model_name, rule_id) @@ -321,11 +350,14 @@ def group_update(): rule_type = session[constants.TYPE_ARG] form_name = DATA_FORMS_NAMED[rule_type] to_update = request.form.getlist("delete-id") - rule_type = session[constants.TYPE_ARG] rule_type_int = constants.RULE_TYPES_DICT[rule_type] - rules = [str(x) for x in session[constants.RULES_KEY]] + + # Get allowed rules for this user + allowed_rule_ids = get_user_allowed_rule_ids(rule_type, session["user_id"], session["user_role_ids"]) + allowed_rules_str = [str(x) for x in allowed_rule_ids] + # redirect bad request - if not set(to_update).issubset(set(rules)) or is_admin(session["user_roles"]): + if not set(to_update).issubset(set(allowed_rules_str)) and not is_admin(session["user_roles"]): flash("You can't edit these rules!", "alert-danger") return redirect( url_for( diff --git a/flowapp/views/whitelist.py b/flowapp/views/whitelist.py index 9cc85e4..743174c 100644 --- a/flowapp/views/whitelist.py +++ b/flowapp/views/whitelist.py @@ -10,6 +10,8 @@ from flowapp.models import get_user_nets, Whitelist from flowapp.services import create_or_update_whitelist, delete_whitelist from flowapp.utils.base import flash_errors +from flowapp.auth import check_user_can_modify_rule + whitelist = Blueprint("whitelist", __name__, template_folder="templates") @@ -106,7 +108,8 @@ def delete(wl_id): Delete whitelist :param wl_id: integer - id of the whitelist """ - if wl_id in session[constants.RULES_KEY]: + # Check if user can modify this whitelist + if check_user_can_modify_rule(wl_id, "whitelist"): messages = delete_whitelist(wl_id) flash(f"Whitelist {wl_id} deleted", "alert-success") for message in messages: From 9a5df87a64e7fa11bc52834fb01ce60f71fc0306 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Wed, 8 Oct 2025 14:38:15 +0200 Subject: [PATCH 3/3] removed session debug in dashboard --- flowapp/views/dashboard.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/flowapp/views/dashboard.py b/flowapp/views/dashboard.py index ab1a25b..b4cd176 100644 --- a/flowapp/views/dashboard.py +++ b/flowapp/views/dashboard.py @@ -124,9 +124,6 @@ def index(rtype=None, rstate="active"): allowed_communities = current_app.config["ALLOWED_COMMUNITIES"] - # temporary debug session content - current_app.logger.debug("Session content: %s" % session) - return view_factory( rtype=rtype, rstate=rstate,