security(permissions): restrict guest access to members-only areas
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

- 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) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-19 16:23:56 +01:00
parent b8f18c94e5
commit 7b31e6ba44
5 changed files with 32 additions and 3 deletions

View File

@ -7,12 +7,14 @@ Platforma Edukacyjna - materiały szkoleniowe dla członków Norda Biznes.
from flask import render_template, url_for from flask import render_template, url_for
from flask_login import login_required, current_user from flask_login import login_required, current_user
from utils.decorators import member_required
from . import bp from . import bp
@bp.route('/', endpoint='education_index') @bp.route('/', endpoint='education_index')
@login_required @login_required
@member_required
def index(): def index():
"""Strona główna Platformy Edukacyjnej.""" """Strona główna Platformy Edukacyjnej."""
materials = [ materials = [
@ -57,6 +59,7 @@ def index():
@bp.route('/opinie-google', endpoint='google_reviews_guide') @bp.route('/opinie-google', endpoint='google_reviews_guide')
@login_required @login_required
@member_required
def google_reviews_guide(): def google_reviews_guide():
"""Poradnik o opiniach Google dla członków Izby.""" """Poradnik o opiniach Google dla członków Izby."""
from database import SessionLocal, CompanyWebsiteAnalysis, Company from database import SessionLocal, CompanyWebsiteAnalysis, Company

View File

@ -18,6 +18,7 @@ from database import (
ForumTopicRead, ForumReplyRead ForumTopicRead, ForumReplyRead
) )
from utils.helpers import sanitize_input from utils.helpers import sanitize_input
from utils.decorators import forum_access_required
from utils.notifications import ( from utils.notifications import (
create_forum_reply_notification, create_forum_reply_notification,
create_forum_reaction_notification, create_forum_reaction_notification,
@ -49,6 +50,7 @@ except ImportError:
@bp.route('/forum') @bp.route('/forum')
@login_required @login_required
@forum_access_required
def forum_index(): def forum_index():
"""Forum - list of topics with category/status/solution filters and search""" """Forum - list of topics with category/status/solution filters and search"""
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
@ -121,6 +123,7 @@ def forum_index():
@bp.route('/forum/nowy', methods=['GET', 'POST']) @bp.route('/forum/nowy', methods=['GET', 'POST'])
@login_required @login_required
@forum_access_required
def forum_new_topic(): def forum_new_topic():
"""Create new forum topic with category and attachments""" """Create new forum topic with category and attachments"""
if request.method == 'POST': if request.method == 'POST':
@ -212,6 +215,7 @@ def forum_new_topic():
@bp.route('/forum/<int:topic_id>') @bp.route('/forum/<int:topic_id>')
@login_required @login_required
@forum_access_required
def forum_topic(topic_id): def forum_topic(topic_id):
"""View forum topic with replies""" """View forum topic with replies"""
db = SessionLocal() db = SessionLocal()
@ -296,6 +300,7 @@ def forum_topic(topic_id):
@bp.route('/forum/<int:topic_id>/odpowiedz', methods=['POST']) @bp.route('/forum/<int:topic_id>/odpowiedz', methods=['POST'])
@login_required @login_required
@forum_access_required
def forum_reply(topic_id): def forum_reply(topic_id):
"""Add reply to forum topic with optional attachment""" """Add reply to forum topic with optional attachment"""
content = request.form.get('content', '').strip() content = request.form.get('content', '').strip()
@ -765,6 +770,7 @@ def _can_edit_content(content_created_at):
@bp.route('/forum/topic/<int:topic_id>/edit', methods=['POST']) @bp.route('/forum/topic/<int:topic_id>/edit', methods=['POST'])
@login_required @login_required
@forum_access_required
def edit_topic(topic_id): def edit_topic(topic_id):
"""Edit own topic (within 24h)""" """Edit own topic (within 24h)"""
data = request.get_json() or {} data = request.get_json() or {}
@ -821,6 +827,7 @@ def edit_topic(topic_id):
@bp.route('/forum/reply/<int:reply_id>/edit', methods=['POST']) @bp.route('/forum/reply/<int:reply_id>/edit', methods=['POST'])
@login_required @login_required
@forum_access_required
def edit_reply(reply_id): def edit_reply(reply_id):
"""Edit own reply (within 24h)""" """Edit own reply (within 24h)"""
data = request.get_json() or {} data = request.get_json() or {}
@ -879,6 +886,7 @@ def edit_reply(reply_id):
@bp.route('/forum/reply/<int:reply_id>/delete', methods=['POST']) @bp.route('/forum/reply/<int:reply_id>/delete', methods=['POST'])
@login_required @login_required
@forum_access_required
def delete_own_reply(reply_id): def delete_own_reply(reply_id):
"""Soft delete own reply (if no child responses exist)""" """Soft delete own reply (if no child responses exist)"""
db = SessionLocal() db = SessionLocal()
@ -913,6 +921,7 @@ def delete_own_reply(reply_id):
@bp.route('/forum/topic/<int:topic_id>/react', methods=['POST']) @bp.route('/forum/topic/<int:topic_id>/react', methods=['POST'])
@login_required @login_required
@forum_access_required
def react_to_topic(topic_id): def react_to_topic(topic_id):
"""Add or remove reaction from topic""" """Add or remove reaction from topic"""
data = request.get_json() or {} data = request.get_json() or {}
@ -977,6 +986,7 @@ def react_to_topic(topic_id):
@bp.route('/forum/reply/<int:reply_id>/react', methods=['POST']) @bp.route('/forum/reply/<int:reply_id>/react', methods=['POST'])
@login_required @login_required
@forum_access_required
def react_to_reply(reply_id): def react_to_reply(reply_id):
"""Add or remove reaction from reply""" """Add or remove reaction from reply"""
data = request.get_json() or {} data = request.get_json() or {}
@ -1056,6 +1066,7 @@ def react_to_reply(reply_id):
@bp.route('/forum/topic/<int:topic_id>/subscribe', methods=['POST']) @bp.route('/forum/topic/<int:topic_id>/subscribe', methods=['POST'])
@login_required @login_required
@forum_access_required
def subscribe_to_topic(topic_id): def subscribe_to_topic(topic_id):
"""Subscribe to topic notifications""" """Subscribe to topic notifications"""
db = SessionLocal() db = SessionLocal()
@ -1091,6 +1102,7 @@ def subscribe_to_topic(topic_id):
@bp.route('/forum/topic/<int:topic_id>/unsubscribe', methods=['POST']) @bp.route('/forum/topic/<int:topic_id>/unsubscribe', methods=['POST'])
@login_required @login_required
@forum_access_required
def unsubscribe_from_topic(topic_id): def unsubscribe_from_topic(topic_id):
"""Unsubscribe from topic notifications""" """Unsubscribe from topic notifications"""
db = SessionLocal() db = SessionLocal()
@ -1117,6 +1129,7 @@ def unsubscribe_from_topic(topic_id):
@bp.route('/forum/<int:topic_id>/unsubscribe', methods=['GET']) @bp.route('/forum/<int:topic_id>/unsubscribe', methods=['GET'])
@login_required @login_required
@forum_access_required
def unsubscribe_from_email(topic_id): def unsubscribe_from_email(topic_id):
"""Unsubscribe from topic via email link (GET, requires login)""" """Unsubscribe from topic via email link (GET, requires login)"""
db = SessionLocal() db = SessionLocal()
@ -1143,6 +1156,7 @@ def unsubscribe_from_email(topic_id):
@bp.route('/forum/report', methods=['POST']) @bp.route('/forum/report', methods=['POST'])
@login_required @login_required
@forum_access_required
def report_content(): def report_content():
"""Report topic or reply for moderation""" """Report topic or reply for moderation"""
data = request.get_json() or {} data = request.get_json() or {}
@ -1598,6 +1612,7 @@ def admin_deleted_content():
@bp.route('/forum/user/<int:user_id>/stats') @bp.route('/forum/user/<int:user_id>/stats')
@login_required @login_required
@forum_access_required
def user_forum_stats(user_id): def user_forum_stats(user_id):
"""Get forum statistics for a user (for tooltip display)""" """Get forum statistics for a user (for tooltip display)"""
from sqlalchemy import func from sqlalchemy import func

View File

@ -10,6 +10,7 @@ from datetime import datetime
from flask import flash, redirect, render_template, request, url_for from flask import flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required from flask_login import current_user, login_required
from utils.decorators import member_required
from sqlalchemy import desc, func, or_ from sqlalchemy import desc, func, or_
from sqlalchemy.dialects.postgresql import array as pg_array from sqlalchemy.dialects.postgresql import array as pg_array
@ -25,6 +26,7 @@ logger = logging.getLogger(__name__)
@bp.route('/ogloszenia') @bp.route('/ogloszenia')
@login_required @login_required
@member_required
def announcements_list(): def announcements_list():
"""Strona z listą ogłoszeń dla zalogowanych członków""" """Strona z listą ogłoszeń dla zalogowanych członków"""
db = SessionLocal() db = SessionLocal()
@ -73,6 +75,7 @@ def announcements_list():
@bp.route('/ogloszenia/<slug>') @bp.route('/ogloszenia/<slug>')
@login_required @login_required
@member_required
def announcement_detail(slug): def announcement_detail(slug):
"""Szczegóły ogłoszenia dla zalogowanych członków""" """Szczegóły ogłoszenia dla zalogowanych członków"""
db = SessionLocal() db = SessionLocal()

View File

@ -8,6 +8,7 @@ Business analytics and reporting endpoints.
from datetime import datetime, date from datetime import datetime, date
from flask import render_template, url_for from flask import render_template, url_for
from flask_login import login_required from flask_login import login_required
from utils.decorators import member_required
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
@ -17,6 +18,7 @@ from database import SessionLocal, Company, Category, CompanySocialMedia
@bp.route('/', endpoint='reports_index') @bp.route('/', endpoint='reports_index')
@login_required @login_required
@member_required
def index(): def index():
"""Lista dostępnych raportów.""" """Lista dostępnych raportów."""
reports = [ reports = [
@ -47,6 +49,7 @@ def index():
@bp.route('/staz-czlonkostwa', endpoint='report_membership') @bp.route('/staz-czlonkostwa', endpoint='report_membership')
@login_required @login_required
@member_required
def membership(): def membership():
"""Raport: Staż członkostwa w Izbie NORDA.""" """Raport: Staż członkostwa w Izbie NORDA."""
db = SessionLocal() db = SessionLocal()
@ -91,6 +94,7 @@ def membership():
@bp.route('/social-media', endpoint='report_social_media') @bp.route('/social-media', endpoint='report_social_media')
@login_required @login_required
@member_required
def social_media(): def social_media():
"""Raport: Pokrycie Social Media.""" """Raport: Pokrycie Social Media."""
db = SessionLocal() db = SessionLocal()
@ -141,6 +145,7 @@ def social_media():
@bp.route('/struktura-branzowa', endpoint='report_categories') @bp.route('/struktura-branzowa', endpoint='report_categories')
@login_required @login_required
@member_required
def categories(): def categories():
"""Raport: Struktura branżowa.""" """Raport: Struktura branżowa."""
db = SessionLocal() db = SessionLocal()

View File

@ -2215,10 +2215,11 @@ class NordaEvent(Base):
if access == 'public': if access == 'public':
return True return True
elif access == 'members_only': 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': elif access == 'rada_only':
# Wszyscy członkowie WIDZĄ wydarzenia Rady Izby (tytuł, data, miejsce) # 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 return user.is_norda_member or user.has_role(SystemRole.MEMBER) or user.is_rada_member
else: else:
return False return False
@ -2227,6 +2228,7 @@ class NordaEvent(Base):
"""Check if a user can register for this event. """Check if a user can register for this event.
For Rada Izby events, only designated board members can register. 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: if not user or not user.is_authenticated:
return False return False
@ -2238,7 +2240,8 @@ class NordaEvent(Base):
access = self.access_level or 'members_only' access = self.access_level or 'members_only'
if access == 'public': 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': elif access == 'members_only':
return user.is_norda_member or user.has_role(SystemRole.MEMBER) return user.is_norda_member or user.has_role(SystemRole.MEMBER)
elif access == 'rada_only': elif access == 'rada_only':