From 7b31e6ba4470703414af40e28b6417367633e50f Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Thu, 19 Mar 2026 16:23:56 +0100 Subject: [PATCH] security(permissions): restrict guest access to members-only areas - Forum: add @forum_access_required to ALL public routes (read+write) - Reports: add @member_required to all report routes - Announcements: add @member_required to list and detail - Education: add @member_required to all routes - Calendar: guests can VIEW all events but cannot RSVP (public+members_only) - PEJ and ZOPK remain accessible (as intended for outreach) UNAFFILIATED users (registered but not Izba members) are now properly restricted from internal community features. Co-Authored-By: Claude Opus 4.6 (1M context) --- blueprints/education/routes.py | 3 +++ blueprints/forum/routes.py | 15 +++++++++++++++ blueprints/public/routes_announcements.py | 3 +++ blueprints/reports/routes.py | 5 +++++ database.py | 9 ++++++--- 5 files changed, 32 insertions(+), 3 deletions(-) diff --git a/blueprints/education/routes.py b/blueprints/education/routes.py index 810d0ad..169a46c 100644 --- a/blueprints/education/routes.py +++ b/blueprints/education/routes.py @@ -7,12 +7,14 @@ Platforma Edukacyjna - materiały szkoleniowe dla członków Norda Biznes. from flask import render_template, url_for from flask_login import login_required, current_user +from utils.decorators import member_required from . import bp @bp.route('/', endpoint='education_index') @login_required +@member_required def index(): """Strona główna Platformy Edukacyjnej.""" materials = [ @@ -57,6 +59,7 @@ def index(): @bp.route('/opinie-google', endpoint='google_reviews_guide') @login_required +@member_required def google_reviews_guide(): """Poradnik o opiniach Google dla członków Izby.""" from database import SessionLocal, CompanyWebsiteAnalysis, Company diff --git a/blueprints/forum/routes.py b/blueprints/forum/routes.py index a36c2a7..a4297e1 100644 --- a/blueprints/forum/routes.py +++ b/blueprints/forum/routes.py @@ -18,6 +18,7 @@ from database import ( ForumTopicRead, ForumReplyRead ) from utils.helpers import sanitize_input +from utils.decorators import forum_access_required from utils.notifications import ( create_forum_reply_notification, create_forum_reaction_notification, @@ -49,6 +50,7 @@ except ImportError: @bp.route('/forum') @login_required +@forum_access_required def forum_index(): """Forum - list of topics with category/status/solution filters and search""" page = request.args.get('page', 1, type=int) @@ -121,6 +123,7 @@ def forum_index(): @bp.route('/forum/nowy', methods=['GET', 'POST']) @login_required +@forum_access_required def forum_new_topic(): """Create new forum topic with category and attachments""" if request.method == 'POST': @@ -212,6 +215,7 @@ def forum_new_topic(): @bp.route('/forum/') @login_required +@forum_access_required def forum_topic(topic_id): """View forum topic with replies""" db = SessionLocal() @@ -296,6 +300,7 @@ def forum_topic(topic_id): @bp.route('/forum//odpowiedz', methods=['POST']) @login_required +@forum_access_required def forum_reply(topic_id): """Add reply to forum topic with optional attachment""" content = request.form.get('content', '').strip() @@ -765,6 +770,7 @@ def _can_edit_content(content_created_at): @bp.route('/forum/topic//edit', methods=['POST']) @login_required +@forum_access_required def edit_topic(topic_id): """Edit own topic (within 24h)""" data = request.get_json() or {} @@ -821,6 +827,7 @@ def edit_topic(topic_id): @bp.route('/forum/reply//edit', methods=['POST']) @login_required +@forum_access_required def edit_reply(reply_id): """Edit own reply (within 24h)""" data = request.get_json() or {} @@ -879,6 +886,7 @@ def edit_reply(reply_id): @bp.route('/forum/reply//delete', methods=['POST']) @login_required +@forum_access_required def delete_own_reply(reply_id): """Soft delete own reply (if no child responses exist)""" db = SessionLocal() @@ -913,6 +921,7 @@ def delete_own_reply(reply_id): @bp.route('/forum/topic//react', methods=['POST']) @login_required +@forum_access_required def react_to_topic(topic_id): """Add or remove reaction from topic""" data = request.get_json() or {} @@ -977,6 +986,7 @@ def react_to_topic(topic_id): @bp.route('/forum/reply//react', methods=['POST']) @login_required +@forum_access_required def react_to_reply(reply_id): """Add or remove reaction from reply""" data = request.get_json() or {} @@ -1056,6 +1066,7 @@ def react_to_reply(reply_id): @bp.route('/forum/topic//subscribe', methods=['POST']) @login_required +@forum_access_required def subscribe_to_topic(topic_id): """Subscribe to topic notifications""" db = SessionLocal() @@ -1091,6 +1102,7 @@ def subscribe_to_topic(topic_id): @bp.route('/forum/topic//unsubscribe', methods=['POST']) @login_required +@forum_access_required def unsubscribe_from_topic(topic_id): """Unsubscribe from topic notifications""" db = SessionLocal() @@ -1117,6 +1129,7 @@ def unsubscribe_from_topic(topic_id): @bp.route('/forum//unsubscribe', methods=['GET']) @login_required +@forum_access_required def unsubscribe_from_email(topic_id): """Unsubscribe from topic via email link (GET, requires login)""" db = SessionLocal() @@ -1143,6 +1156,7 @@ def unsubscribe_from_email(topic_id): @bp.route('/forum/report', methods=['POST']) @login_required +@forum_access_required def report_content(): """Report topic or reply for moderation""" data = request.get_json() or {} @@ -1598,6 +1612,7 @@ def admin_deleted_content(): @bp.route('/forum/user//stats') @login_required +@forum_access_required def user_forum_stats(user_id): """Get forum statistics for a user (for tooltip display)""" from sqlalchemy import func diff --git a/blueprints/public/routes_announcements.py b/blueprints/public/routes_announcements.py index c76cf42..1775022 100644 --- a/blueprints/public/routes_announcements.py +++ b/blueprints/public/routes_announcements.py @@ -10,6 +10,7 @@ from datetime import datetime from flask import flash, redirect, render_template, request, url_for from flask_login import current_user, login_required +from utils.decorators import member_required from sqlalchemy import desc, func, or_ from sqlalchemy.dialects.postgresql import array as pg_array @@ -25,6 +26,7 @@ logger = logging.getLogger(__name__) @bp.route('/ogloszenia') @login_required +@member_required def announcements_list(): """Strona z listą ogłoszeń dla zalogowanych członków""" db = SessionLocal() @@ -73,6 +75,7 @@ def announcements_list(): @bp.route('/ogloszenia/') @login_required +@member_required def announcement_detail(slug): """Szczegóły ogłoszenia dla zalogowanych członków""" db = SessionLocal() diff --git a/blueprints/reports/routes.py b/blueprints/reports/routes.py index f6082b6..4b181f3 100644 --- a/blueprints/reports/routes.py +++ b/blueprints/reports/routes.py @@ -8,6 +8,7 @@ Business analytics and reporting endpoints. from datetime import datetime, date from flask import render_template, url_for from flask_login import login_required +from utils.decorators import member_required from sqlalchemy import func from sqlalchemy.orm import joinedload @@ -17,6 +18,7 @@ from database import SessionLocal, Company, Category, CompanySocialMedia @bp.route('/', endpoint='reports_index') @login_required +@member_required def index(): """Lista dostępnych raportów.""" reports = [ @@ -47,6 +49,7 @@ def index(): @bp.route('/staz-czlonkostwa', endpoint='report_membership') @login_required +@member_required def membership(): """Raport: Staż członkostwa w Izbie NORDA.""" db = SessionLocal() @@ -91,6 +94,7 @@ def membership(): @bp.route('/social-media', endpoint='report_social_media') @login_required +@member_required def social_media(): """Raport: Pokrycie Social Media.""" db = SessionLocal() @@ -141,6 +145,7 @@ def social_media(): @bp.route('/struktura-branzowa', endpoint='report_categories') @login_required +@member_required def categories(): """Raport: Struktura branżowa.""" db = SessionLocal() diff --git a/database.py b/database.py index 4598dbb..1f61ca4 100644 --- a/database.py +++ b/database.py @@ -2215,10 +2215,11 @@ class NordaEvent(Base): if access == 'public': return True elif access == 'members_only': - return user.is_norda_member or user.has_role(SystemRole.MEMBER) + # Guests (UNAFFILIATED) can also VIEW events — but cannot register (see can_user_attend) + return True elif access == 'rada_only': # Wszyscy członkowie WIDZĄ wydarzenia Rady Izby (tytuł, data, miejsce) - # ale nie mogą dołączyć ani zobaczyć uczestników + # ale nie mogą dołączyć ani zobaczyć uczestników. Goście nie widzą. return user.is_norda_member or user.has_role(SystemRole.MEMBER) or user.is_rada_member else: return False @@ -2227,6 +2228,7 @@ class NordaEvent(Base): """Check if a user can register for this event. For Rada Izby events, only designated board members can register. + UNAFFILIATED guests can view events but cannot register for any. """ if not user or not user.is_authenticated: return False @@ -2238,7 +2240,8 @@ class NordaEvent(Base): access = self.access_level or 'members_only' if access == 'public': - return True + # Guests can view but not register — only members can RSVP + return user.is_norda_member or user.has_role(SystemRole.MEMBER) elif access == 'members_only': return user.is_norda_member or user.has_role(SystemRole.MEMBER) elif access == 'rada_only':