feat: Add hierarchical role system with delegated permissions

Implements 6-tier role hierarchy:
- ADMIN (100): Full system access
- OFFICE_MANAGER (50): Admin panel without user management
- MANAGER (40): Full company control + user management
- EMPLOYEE (30): Edit company data (with delegated permissions)
- MEMBER (20): Full content access (forum, contacts, chat)
- UNAFFILIATED (10): Public profiles only

Features:
- SystemRole and CompanyRole enums in database.py
- UserCompanyPermissions model for delegation
- New decorators: @role_required(), @company_permission()
- Auto-detection of MANAGER role from KRS data
- Backward compatible with is_admin flag

Migration: 035_add_role_system.sql

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-01 06:42:39 +01:00
parent 1bf56148f5
commit ae70ad326e
4 changed files with 1100 additions and 11 deletions

View File

@ -27,12 +27,102 @@ Updated: 2026-01-11 (AI Usage Tracking)
import os
import json
from datetime import datetime
from sqlalchemy import create_engine, Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Table, Numeric, Date, Time, TypeDecorator, UniqueConstraint
from enum import IntEnum
from sqlalchemy import create_engine, Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Table, Numeric, Date, Time, TypeDecorator, UniqueConstraint, Enum
from sqlalchemy.dialects.postgresql import ARRAY as PG_ARRAY, JSONB as PG_JSONB
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
from flask_login import UserMixin
# ============================================================
# ROLE SYSTEM ENUMS
# ============================================================
class SystemRole(IntEnum):
"""
System-wide user roles with hierarchical access levels.
Higher value = more permissions.
Role hierarchy:
- UNAFFILIATED (10): Firma spoza Izby - tylko publiczne profile (bez kontaktów)
- MEMBER (20): Członek Norda bez firmy - pełny dostęp do treści
- EMPLOYEE (30): Pracownik firmy członkowskiej - może edytować dane firmy
- MANAGER (40): Kadra zarządzająca - pełna kontrola firmy + zarządzanie użytkownikami
- OFFICE_MANAGER (50): Kierownik biura Norda - panel admina (bez użytkowników)
- ADMIN (100): Administrator portalu - pełne prawa
"""
UNAFFILIATED = 10 # Niezrzeszony (firma spoza Izby)
MEMBER = 20 # Członek Norda bez firmy
EMPLOYEE = 30 # Pracownik firmy członkowskiej
MANAGER = 40 # Kadra zarządzająca firmy
OFFICE_MANAGER = 50 # Kierownik biura Norda
ADMIN = 100 # Administrator portalu
@classmethod
def choices(cls):
"""Return list of (value, label) tuples for forms."""
labels = {
cls.UNAFFILIATED: 'Niezrzeszony',
cls.MEMBER: 'Członek',
cls.EMPLOYEE: 'Pracownik',
cls.MANAGER: 'Kadra Zarządzająca',
cls.OFFICE_MANAGER: 'Kierownik Biura',
cls.ADMIN: 'Administrator',
}
return [(role.value, labels[role]) for role in cls]
@classmethod
def from_string(cls, value: str) -> 'SystemRole':
"""Convert string to SystemRole enum."""
mapping = {
'UNAFFILIATED': cls.UNAFFILIATED,
'MEMBER': cls.MEMBER,
'EMPLOYEE': cls.EMPLOYEE,
'MANAGER': cls.MANAGER,
'OFFICE_MANAGER': cls.OFFICE_MANAGER,
'ADMIN': cls.ADMIN,
}
return mapping.get(value.upper(), cls.UNAFFILIATED)
class CompanyRole(IntEnum):
"""
User's role within their assigned company.
Determines what actions they can perform on company data.
- NONE (0): Brak powiązania z firmą
- VIEWER (10): Może przeglądać dashboard firmy
- EMPLOYEE (20): Może edytować dane firmy (opis, usługi, kompetencje)
- MANAGER (30): Pełna kontrola + zarządzanie użytkownikami firmy
"""
NONE = 0 # Brak powiązania z firmą
VIEWER = 10 # Może przeglądać dashboard firmy
EMPLOYEE = 20 # Może edytować dane firmy
MANAGER = 30 # Pełna kontrola + zarządzanie użytkownikami firmy
@classmethod
def choices(cls):
"""Return list of (value, label) tuples for forms."""
labels = {
cls.NONE: 'Brak',
cls.VIEWER: 'Podgląd',
cls.EMPLOYEE: 'Pracownik',
cls.MANAGER: 'Zarządzający',
}
return [(role.value, labels[role]) for role in cls]
@classmethod
def from_string(cls, value: str) -> 'CompanyRole':
"""Convert string to CompanyRole enum."""
mapping = {
'NONE': cls.NONE,
'VIEWER': cls.VIEWER,
'EMPLOYEE': cls.EMPLOYEE,
'MANAGER': cls.MANAGER,
}
return mapping.get(value.upper(), cls.NONE)
# Database configuration
# WARNING: The fallback DATABASE_URL uses a placeholder password.
# Production credentials MUST be set via the DATABASE_URL environment variable.
@ -173,7 +263,7 @@ Base = declarative_base()
# ============================================================
class User(Base, UserMixin):
"""User accounts"""
"""User accounts with role-based access control."""
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
@ -187,10 +277,16 @@ class User(Base, UserMixin):
person = relationship('Person', backref='users', lazy='joined') # Link to Person (KRS data)
phone = Column(String(50))
# Status
# === ROLE SYSTEM (added 2026-02-01) ===
# System-wide role determining overall access level
role = Column(String(20), default='UNAFFILIATED', nullable=False)
# Role within assigned company (if any)
company_role = Column(String(20), default='NONE', nullable=False)
# Status (is_admin kept for backward compatibility, synced with role)
is_active = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False)
is_admin = Column(Boolean, default=False)
is_admin = Column(Boolean, default=False) # Deprecated: use role == ADMIN instead
is_norda_member = Column(Boolean, default=False)
# Timestamps
@ -231,8 +327,255 @@ class User(Base, UserMixin):
forum_replies = relationship('ForumReply', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumReply.author_id')
forum_subscriptions = relationship('ForumTopicSubscription', back_populates='user', cascade='all, delete-orphan')
# === ROLE SYSTEM HELPER METHODS ===
@property
def system_role(self) -> SystemRole:
"""Get the user's SystemRole enum value."""
return SystemRole.from_string(self.role or 'UNAFFILIATED')
@property
def company_role_enum(self) -> CompanyRole:
"""Get the user's CompanyRole enum value."""
return CompanyRole.from_string(self.company_role or 'NONE')
def has_role(self, required_role: SystemRole) -> bool:
"""
Check if user has at least the required role level.
Args:
required_role: Minimum required SystemRole
Returns:
True if user's role >= required_role
Example:
if user.has_role(SystemRole.OFFICE_MANAGER):
# User is Office Manager or Admin
"""
return self.system_role >= required_role
def can_view_contacts(self) -> bool:
"""
Check if user can view contact information (email, phone) of other members.
Requires at least MEMBER role.
"""
return self.has_role(SystemRole.MEMBER)
def can_access_forum(self) -> bool:
"""
Check if user can read and write on the forum.
Requires at least MEMBER role.
"""
return self.has_role(SystemRole.MEMBER)
def can_access_chat(self) -> bool:
"""
Check if user can use NordaGPT chat with full features.
UNAFFILIATED users get limited access.
"""
return self.has_role(SystemRole.MEMBER)
def can_edit_company(self, company_id: int = None) -> bool:
"""
Check if user can edit a company's profile.
Args:
company_id: Company to check. If None, checks user's own company.
Returns:
True if user can edit the company.
"""
# Admins and Office Managers can edit any company
if self.has_role(SystemRole.OFFICE_MANAGER):
return True
# Check user's own company
target_company = company_id or self.company_id
if not target_company or self.company_id != target_company:
return False
# EMPLOYEE or MANAGER of the company can edit
return self.company_role_enum >= CompanyRole.EMPLOYEE
def can_manage_company(self, company_id: int = None) -> bool:
"""
Check if user can manage a company (including user management).
Args:
company_id: Company to check. If None, checks user's own company.
Returns:
True if user has full management rights.
"""
# Admins can manage any company
if self.has_role(SystemRole.ADMIN):
return True
# Check user's own company
target_company = company_id or self.company_id
if not target_company or self.company_id != target_company:
return False
# Only MANAGER of the company can manage users
return self.company_role_enum >= CompanyRole.MANAGER
def can_manage_users(self) -> bool:
"""
Check if user can manage all portal users.
Only ADMIN role has this permission.
"""
return self.has_role(SystemRole.ADMIN)
def can_access_admin_panel(self) -> bool:
"""
Check if user can access the admin panel.
OFFICE_MANAGER and ADMIN roles have access.
"""
return self.has_role(SystemRole.OFFICE_MANAGER)
def can_moderate_forum(self) -> bool:
"""
Check if user can moderate forum content.
OFFICE_MANAGER and ADMIN roles have this permission.
"""
return self.has_role(SystemRole.OFFICE_MANAGER)
def has_delegated_permission(self, permission: str, session=None) -> bool:
"""
Check if user has a specific delegated permission for their company.
This checks UserCompanyPermissions table for fine-grained permissions
granted by a MANAGER.
Args:
permission: One of: 'edit_description', 'edit_services', 'edit_contacts',
'edit_social', 'manage_classifieds', 'post_forum', 'view_analytics'
session: SQLAlchemy session (optional, uses relationship if available)
Returns:
True if user has this specific permission
"""
# Managers have all permissions by default
if self.company_role_enum >= CompanyRole.MANAGER:
return True
# Check delegated permissions
if self.company_permissions:
for perm in self.company_permissions:
if perm.company_id == self.company_id:
attr_name = f'can_{permission}'
return getattr(perm, attr_name, False)
return False
def can_edit_company_field(self, field_category: str) -> bool:
"""
Check if user can edit a specific category of company fields.
Args:
field_category: 'description', 'services', 'contacts', or 'social'
Returns:
True if user can edit fields in this category
"""
# Admins and Office Managers can edit everything
if self.has_role(SystemRole.OFFICE_MANAGER):
return True
# Must have company
if not self.company_id:
return False
# Managers can edit everything in their company
if self.company_role_enum >= CompanyRole.MANAGER:
return True
# Employees need delegated permission
if self.company_role_enum >= CompanyRole.EMPLOYEE:
return self.has_delegated_permission(f'edit_{field_category}')
return False
def set_role(self, new_role: SystemRole, sync_is_admin: bool = True):
"""
Set the user's system role.
Args:
new_role: The new SystemRole to assign
sync_is_admin: If True, also update is_admin field for backward compatibility
"""
self.role = new_role.name
if sync_is_admin:
self.is_admin = (new_role == SystemRole.ADMIN)
def set_company_role(self, new_role: CompanyRole):
"""Set the user's company role."""
self.company_role = new_role.name
def __repr__(self):
return f'<User {self.email}>'
return f'<User {self.email} role={self.role}>'
class UserCompanyPermissions(Base):
"""
Delegated permissions for company employees.
Allows MANAGER to grant specific permissions to EMPLOYEE users,
enabling fine-grained control over what each employee can do.
Example:
- Jan Kowalski (EMPLOYEE) gets permission to edit social media
- Anna Nowak (EMPLOYEE) gets permission to manage B2B classifieds
"""
__tablename__ = 'user_company_permissions'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False)
# Content editing permissions
can_edit_description = Column(Boolean, default=True) # Opis firmy, historia, wartości
can_edit_services = Column(Boolean, default=True) # Usługi, kompetencje, technologie
can_edit_contacts = Column(Boolean, default=False) # Email, telefon, adres
can_edit_social = Column(Boolean, default=False) # Social media, strona www
# Feature permissions
can_manage_classifieds = Column(Boolean, default=True) # B2B ogłoszenia w imieniu firmy
can_post_forum = Column(Boolean, default=True) # Posty na forum w imieniu firmy
can_view_analytics = Column(Boolean, default=False) # Statystyki firmy, wyświetlenia
# Granted by (for audit trail)
granted_by_id = Column(Integer, ForeignKey('users.id'), nullable=True)
granted_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
user = relationship('User', foreign_keys=[user_id], backref='company_permissions')
company = relationship('Company', backref='user_permissions')
granted_by = relationship('User', foreign_keys=[granted_by_id])
__table_args__ = (
UniqueConstraint('user_id', 'company_id', name='uq_user_company_permissions'),
)
def __repr__(self):
return f'<UserCompanyPermissions user={self.user_id} company={self.company_id}>'
@classmethod
def get_or_create(cls, session, user_id: int, company_id: int) -> 'UserCompanyPermissions':
"""Get existing permissions or create default ones."""
perms = session.query(cls).filter_by(
user_id=user_id,
company_id=company_id
).first()
if not perms:
perms = cls(user_id=user_id, company_id=company_id)
session.add(perms)
session.flush()
return perms
# ============================================================

View File

@ -0,0 +1,183 @@
-- Migration: 035_add_role_system.sql
-- Description: Add hierarchical role system for granular access control
-- Author: Claude Code
-- Date: 2026-02-01
--
-- Role hierarchy:
-- UNAFFILIATED (10) - Firma spoza Izby - tylko publiczne profile
-- MEMBER (20) - Członek Norda bez firmy - pełny dostęp do treści
-- EMPLOYEE (30) - Pracownik firmy członkowskiej - edycja danych firmy
-- MANAGER (40) - Kadra zarządzająca - pełna kontrola firmy + użytkownicy
-- OFFICE_MANAGER (50) - Kierownik biura Norda - panel admina
-- ADMIN (100) - Administrator portalu - pełne prawa
--
-- Company roles:
-- NONE (0) - Brak powiązania z firmą
-- VIEWER (10) - Może przeglądać dashboard firmy
-- EMPLOYEE (20) - Może edytować dane firmy
-- MANAGER (30) - Pełna kontrola + zarządzanie użytkownikami
-- ============================================================
-- STEP 1: Add new columns
-- ============================================================
-- Add role column with default UNAFFILIATED
ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) DEFAULT 'UNAFFILIATED' NOT NULL;
-- Add company_role column with default NONE
ALTER TABLE users ADD COLUMN IF NOT EXISTS company_role VARCHAR(20) DEFAULT 'NONE' NOT NULL;
-- Add index for role lookups
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
-- ============================================================
-- STEP 2: Migrate existing admins
-- ============================================================
UPDATE users
SET role = 'ADMIN'
WHERE is_admin = TRUE AND (role IS NULL OR role = 'UNAFFILIATED');
-- ============================================================
-- STEP 3: Auto-detect MANAGER role from KRS data
-- Users linked to company management (zarząd) get MANAGER role
-- ============================================================
UPDATE users u
SET
role = 'MANAGER',
company_role = 'MANAGER'
WHERE
u.company_id IS NOT NULL
AND u.role NOT IN ('ADMIN', 'OFFICE_MANAGER')
AND EXISTS (
SELECT 1 FROM company_people cp
WHERE cp.person_id = u.person_id
AND cp.company_id = u.company_id
AND cp.role_category = 'zarzad'
);
-- ============================================================
-- STEP 4: Set EMPLOYEE role for users with company but no KRS link
-- ============================================================
UPDATE users
SET
role = 'EMPLOYEE',
company_role = 'EMPLOYEE'
WHERE
company_id IS NOT NULL
AND role NOT IN ('ADMIN', 'OFFICE_MANAGER', 'MANAGER');
-- ============================================================
-- STEP 5: Set MEMBER role for Norda members without company
-- ============================================================
UPDATE users
SET role = 'MEMBER'
WHERE
is_norda_member = TRUE
AND company_id IS NULL
AND role = 'UNAFFILIATED';
-- ============================================================
-- STEP 6: Create user_company_permissions table for delegation
-- ============================================================
CREATE TABLE IF NOT EXISTS user_company_permissions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
-- Content editing permissions
can_edit_description BOOLEAN DEFAULT TRUE, -- Opis firmy, historia, wartości
can_edit_services BOOLEAN DEFAULT TRUE, -- Usługi, kompetencje, technologie
can_edit_contacts BOOLEAN DEFAULT FALSE, -- Email, telefon, adres
can_edit_social BOOLEAN DEFAULT FALSE, -- Social media, strona www
-- Feature permissions
can_manage_classifieds BOOLEAN DEFAULT TRUE, -- B2B ogłoszenia w imieniu firmy
can_post_forum BOOLEAN DEFAULT TRUE, -- Posty na forum w imieniu firmy
can_view_analytics BOOLEAN DEFAULT FALSE, -- Statystyki firmy, wyświetlenia
-- Audit trail
granted_by_id INTEGER REFERENCES users(id),
granted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Unique constraint: one permission record per user-company pair
CONSTRAINT uq_user_company_permissions UNIQUE (user_id, company_id)
);
-- Indexes for lookups
CREATE INDEX IF NOT EXISTS idx_user_company_permissions_user ON user_company_permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_company_permissions_company ON user_company_permissions(company_id);
-- Grant permissions to application user
GRANT ALL ON TABLE user_company_permissions TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE user_company_permissions_id_seq TO nordabiz_app;
-- ============================================================
-- STEP 7: Create default permissions for existing EMPLOYEE users
-- ============================================================
INSERT INTO user_company_permissions (user_id, company_id, can_edit_description, can_edit_services)
SELECT id, company_id, TRUE, TRUE
FROM users
WHERE role = 'EMPLOYEE' AND company_id IS NOT NULL
ON CONFLICT (user_id, company_id) DO NOTHING;
-- ============================================================
-- STEP 8: Verify migration results
-- ============================================================
-- Log migration statistics (view in PostgreSQL logs)
DO $$
DECLARE
admin_count INTEGER;
office_mgr_count INTEGER;
manager_count INTEGER;
employee_count INTEGER;
member_count INTEGER;
unaffiliated_count INTEGER;
permissions_count INTEGER;
BEGIN
SELECT COUNT(*) INTO admin_count FROM users WHERE role = 'ADMIN';
SELECT COUNT(*) INTO office_mgr_count FROM users WHERE role = 'OFFICE_MANAGER';
SELECT COUNT(*) INTO manager_count FROM users WHERE role = 'MANAGER';
SELECT COUNT(*) INTO employee_count FROM users WHERE role = 'EMPLOYEE';
SELECT COUNT(*) INTO member_count FROM users WHERE role = 'MEMBER';
SELECT COUNT(*) INTO unaffiliated_count FROM users WHERE role = 'UNAFFILIATED';
SELECT COUNT(*) INTO permissions_count FROM user_company_permissions;
RAISE NOTICE 'Role migration complete:';
RAISE NOTICE ' ADMIN: %', admin_count;
RAISE NOTICE ' OFFICE_MANAGER: %', office_mgr_count;
RAISE NOTICE ' MANAGER: %', manager_count;
RAISE NOTICE ' EMPLOYEE: %', employee_count;
RAISE NOTICE ' MEMBER: %', member_count;
RAISE NOTICE ' UNAFFILIATED: %', unaffiliated_count;
RAISE NOTICE ' Delegated permissions records: %', permissions_count;
END $$;
-- ============================================================
-- VERIFICATION QUERIES (run manually to verify)
-- ============================================================
-- Check for any users with NULL role (should be 0)
-- SELECT COUNT(*) FROM users WHERE role IS NULL;
-- View role distribution
-- SELECT role, COUNT(*) as count FROM users GROUP BY role ORDER BY count DESC;
-- View users with company but no company_role
-- SELECT id, email, company_id, role, company_role
-- FROM users
-- WHERE company_id IS NOT NULL AND company_role = 'NONE';
-- Check KRS-linked managers
-- SELECT u.id, u.email, u.role, u.company_role, c.name as company_name
-- FROM users u
-- JOIN companies c ON u.company_id = c.id
-- WHERE u.role = 'MANAGER'
-- LIMIT 10;

View File

@ -3,17 +3,223 @@ Custom Decorators
=================
Reusable decorators for access control and validation.
Role-based decorators (new system):
- @role_required(SystemRole.OFFICE_MANAGER) - Minimum role check
- @company_permission('edit') - Company-specific permissions
- @member_required - Shortcut for role >= MEMBER
Legacy decorators (backward compatibility):
- @admin_required - Alias for @role_required(SystemRole.ADMIN)
- @company_owner_or_admin - Uses new can_edit_company() method
"""
from functools import wraps
from flask import abort, flash, redirect, url_for
from flask import abort, flash, redirect, url_for, request
from flask_login import current_user
# Import role enums (lazy import to avoid circular dependencies)
def _get_system_role():
from database import SystemRole
return SystemRole
# ============================================================
# NEW ROLE-BASED DECORATORS
# ============================================================
def role_required(min_role):
"""
Decorator that requires user to have at least the specified role.
Args:
min_role: Minimum SystemRole required (e.g., SystemRole.OFFICE_MANAGER)
Usage:
@bp.route('/admin/companies')
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def admin_companies():
...
Note: Always use @login_required BEFORE @role_required
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
if not current_user.has_role(min_role):
SystemRole = _get_system_role()
role_names = {
SystemRole.MEMBER: 'członka Izby',
SystemRole.EMPLOYEE: 'pracownika firmy',
SystemRole.MANAGER: 'kadry zarządzającej',
SystemRole.OFFICE_MANAGER: 'kierownika biura',
SystemRole.ADMIN: 'administratora',
}
role_name = role_names.get(min_role, 'wyższych uprawnień')
flash(f'Ta strona wymaga uprawnień {role_name}.', 'error')
return redirect(url_for('public.index'))
return f(*args, **kwargs)
return decorated_function
return decorator
def member_required(f):
"""
Decorator that requires user to be at least a MEMBER.
Shortcut for @role_required(SystemRole.MEMBER).
Usage:
@bp.route('/forum')
@login_required
@member_required
def forum_index():
...
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
SystemRole = _get_system_role()
if not current_user.has_role(SystemRole.MEMBER):
flash('Ta strona jest dostępna tylko dla członków Izby NORDA.', 'warning')
return redirect(url_for('public.index'))
return f(*args, **kwargs)
return decorated_function
def company_permission(permission_type='edit'):
"""
Decorator that checks user's permission for a company.
Args:
permission_type: 'view', 'edit', or 'manage'
The company_id is extracted from:
1. URL parameter 'company_id'
2. Query parameter 'company_id'
3. User's own company_id
Usage:
@bp.route('/company/<int:company_id>/edit')
@login_required
@company_permission('edit')
def edit_company(company_id):
...
@bp.route('/company/<int:company_id>/users')
@login_required
@company_permission('manage')
def manage_company_users(company_id):
...
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
# Get company_id from various sources
company_id = (
kwargs.get('company_id') or
request.args.get('company_id', type=int) or
current_user.company_id
)
if company_id is None:
flash('Nie określono firmy.', 'error')
return redirect(url_for('public.index'))
# Check permission based on type
has_permission = False
if permission_type == 'view':
# Anyone can view public companies, but for dashboard check company membership
has_permission = (
current_user.can_access_admin_panel() or
current_user.company_id == company_id
)
elif permission_type == 'edit':
has_permission = current_user.can_edit_company(company_id)
elif permission_type == 'manage':
has_permission = current_user.can_manage_company(company_id)
else:
abort(500, f"Unknown permission type: {permission_type}")
if not has_permission:
flash('Nie masz uprawnień do tej operacji.', 'error')
return redirect(url_for('public.index'))
return f(*args, **kwargs)
return decorated_function
return decorator
def forum_access_required(f):
"""
Decorator that requires user to have forum access.
Members and above can access the forum.
Usage:
@bp.route('/forum/topic/<int:topic_id>')
@login_required
@forum_access_required
def view_topic(topic_id):
...
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
if not current_user.can_access_forum():
flash('Forum jest dostępne tylko dla członków Izby NORDA.', 'warning')
return redirect(url_for('public.index'))
return f(*args, **kwargs)
return decorated_function
def moderator_required(f):
"""
Decorator that requires forum moderator permissions.
OFFICE_MANAGER and ADMIN roles have this permission.
Usage:
@bp.route('/forum/topic/<int:topic_id>/delete')
@login_required
@moderator_required
def delete_topic(topic_id):
...
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
if not current_user.can_moderate_forum():
flash('Ta akcja wymaga uprawnień moderatora.', 'error')
return redirect(url_for('public.index'))
return f(*args, **kwargs)
return decorated_function
# ============================================================
# LEGACY DECORATORS (backward compatibility)
# ============================================================
def admin_required(f):
"""
Decorator that requires user to be logged in AND be an admin.
DEPRECATED: Use @role_required(SystemRole.ADMIN) instead.
Usage:
@bp.route('/admin/users')
@login_required
@ -27,7 +233,10 @@ def admin_required(f):
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
if not current_user.is_admin:
# Use new role system, fallback to is_admin for backward compatibility
SystemRole = _get_system_role()
if not (current_user.has_role(SystemRole.ADMIN) or current_user.is_admin):
flash('Brak uprawnień administratora.', 'error')
return redirect(url_for('public.index'))
return f(*args, **kwargs)
@ -61,6 +270,8 @@ def company_owner_or_admin(f):
Decorator for routes that accept company_id.
Allows access only if user is admin OR owns the company.
DEPRECATED: Use @company_permission('edit') instead.
Usage:
@bp.route('/company/<int:company_id>/edit')
@login_required
@ -77,10 +288,8 @@ def company_owner_or_admin(f):
if company_id is None:
abort(400)
if current_user.is_admin:
return f(*args, **kwargs)
if current_user.company_id == company_id:
# Use new permission system
if current_user.can_edit_company(company_id):
return f(*args, **kwargs)
flash('Nie masz uprawnień do tej firmy.', 'error')

354
utils/permissions.py Normal file
View File

@ -0,0 +1,354 @@
"""
Permission Helpers
==================
Helper functions for complex permission checks beyond simple role checks.
This module provides:
- Content access checks (company profiles, contact info)
- Feature access checks (forum, chat, classifieds)
- Data visibility filters (what user can see)
Usage:
from utils.permissions import can_view_contact_info, filter_visible_companies
if can_view_contact_info(current_user, company):
show_contact_details()
visible_companies = filter_visible_companies(current_user, all_companies)
"""
from typing import List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from database import User, Company, SystemRole
def can_view_contact_info(user: 'User', target_company: 'Company' = None) -> bool:
"""
Check if user can view contact information (email, phone, address).
Non-members (UNAFFILIATED) cannot see contact details.
Members and above can see all contact info (respecting privacy settings).
Args:
user: The user requesting access
target_company: The company whose info is being accessed (optional)
Returns:
True if user can view contact information
"""
if not user or not user.is_authenticated:
return False
return user.can_view_contacts()
def can_view_company_dashboard(user: 'User', company_id: int) -> bool:
"""
Check if user can view a company's internal dashboard.
Args:
user: The user requesting access
company_id: ID of the company dashboard
Returns:
True if user can view the dashboard
"""
if not user or not user.is_authenticated:
return False
# Admins and Office Managers can view any dashboard
if user.can_access_admin_panel():
return True
# User can view their own company's dashboard
return user.company_id == company_id
def can_invite_user_to_company(user: 'User', company_id: int) -> bool:
"""
Check if user can invite new users to a company.
Only MANAGER role (within company) and ADMIN can invite users.
Args:
user: The user attempting to invite
company_id: ID of the company to invite to
Returns:
True if user can invite users to this company
"""
if not user or not user.is_authenticated:
return False
return user.can_manage_company(company_id)
def get_editable_company_fields(user: 'User', company_id: int) -> List[str]:
"""
Get list of company profile fields that user can edit.
Access is determined by:
1. System role (OFFICE_MANAGER/ADMIN can edit everything)
2. Company role (MANAGER has full access to their company)
3. Delegated permissions (EMPLOYEE gets specific permissions from MANAGER)
Args:
user: The user requesting edit access
company_id: ID of the company to edit
Returns:
List of field names user can edit
"""
from database import SystemRole, CompanyRole
if not user or not user.is_authenticated:
return []
# Not allowed to edit this company at all
if not user.can_edit_company(company_id):
return []
# Field categories
description_fields = [
'description_short',
'description_full',
'core_values',
'founding_history',
]
services_fields = [
'services_offered',
'technologies_used',
'operational_area',
'languages_offered',
]
contact_fields = [
'email',
'phone',
'address_street',
'address_city',
'address_postal',
]
social_fields = [
'website',
# Social media handled via related tables
]
admin_only_fields = [
'name',
'legal_name',
'nip',
'regon',
'krs',
'year_established',
'employees_count',
'capital_amount',
'status',
'category_id',
]
# Admins and Office Managers can edit everything
if user.has_role(SystemRole.OFFICE_MANAGER):
return description_fields + services_fields + contact_fields + social_fields + admin_only_fields
# Check user's own company
if user.company_id != company_id:
return []
# Managers can edit everything except admin-only fields
if user.company_role_enum >= CompanyRole.MANAGER:
return description_fields + services_fields + contact_fields + social_fields
# Employees get permissions based on delegation
if user.company_role_enum >= CompanyRole.EMPLOYEE:
fields = []
if user.can_edit_company_field('description'):
fields.extend(description_fields)
if user.can_edit_company_field('services'):
fields.extend(services_fields)
if user.can_edit_company_field('contacts'):
fields.extend(contact_fields)
if user.can_edit_company_field('social'):
fields.extend(social_fields)
return fields
return []
def filter_visible_companies(user: 'User', companies: List['Company']) -> List['Company']:
"""
Filter companies list based on user's access level.
All users can see basic company info (name, category, description).
Only members can see contact details.
This function doesn't filter the list - all companies are visible.
Use can_view_contact_info() to determine what details to show.
Args:
user: The user viewing companies
companies: List of companies to filter
Returns:
Same list (filtering happens at template level)
"""
# All companies are visible to everyone
# Contact info visibility is handled in templates using can_view_contact_info
return companies
def get_chat_access_level(user: 'User') -> str:
"""
Get user's NordaGPT chat access level.
Args:
user: The user accessing chat
Returns:
'full' - Full access to all features
'limited' - Basic Q&A only, no company recommendations
'none' - No access
"""
from database import SystemRole
if not user or not user.is_authenticated:
return 'none'
if user.has_role(SystemRole.MEMBER):
return 'full'
# UNAFFILIATED users get limited access
return 'limited'
def can_access_b2b_classifieds(user: 'User') -> bool:
"""
Check if user can access B2B classifieds (tablica ogłoszeń).
Requires at least MEMBER role.
Args:
user: The user requesting access
Returns:
True if user can access B2B classifieds
"""
from database import SystemRole
if not user or not user.is_authenticated:
return False
return user.has_role(SystemRole.MEMBER)
def can_create_classified(user: 'User') -> bool:
"""
Check if user can create B2B classified ads.
Requires at least EMPLOYEE role (must be associated with a company).
Args:
user: The user attempting to create
Returns:
True if user can create classifieds
"""
from database import SystemRole
if not user or not user.is_authenticated:
return False
# Must have a company association and be at least EMPLOYEE
return user.company_id is not None and user.has_role(SystemRole.EMPLOYEE)
def can_access_calendar(user: 'User') -> bool:
"""
Check if user can access the events calendar.
Requires at least MEMBER role.
Args:
user: The user requesting access
Returns:
True if user can access calendar
"""
from database import SystemRole
if not user or not user.is_authenticated:
return False
return user.has_role(SystemRole.MEMBER)
def can_create_event(user: 'User') -> bool:
"""
Check if user can create calendar events.
Requires OFFICE_MANAGER or ADMIN role.
Args:
user: The user attempting to create
Returns:
True if user can create events
"""
from database import SystemRole
if not user or not user.is_authenticated:
return False
return user.has_role(SystemRole.OFFICE_MANAGER)
def get_user_permissions_summary(user: 'User') -> dict:
"""
Get a summary of all permissions for a user.
Useful for debugging and displaying in user profile.
Args:
user: The user to summarize
Returns:
Dictionary with permission flags
"""
from database import SystemRole
if not user or not user.is_authenticated:
return {
'role': None,
'company_role': None,
'can_view_contacts': False,
'can_access_forum': False,
'can_access_chat': 'none',
'can_access_admin_panel': False,
'can_manage_users': False,
'can_moderate_forum': False,
'can_edit_own_company': False,
'can_manage_own_company': False,
}
return {
'role': user.role,
'role_label': dict(SystemRole.choices()).get(user.system_role.value, 'Nieznany'),
'company_role': user.company_role,
'can_view_contacts': user.can_view_contacts(),
'can_access_forum': user.can_access_forum(),
'can_access_chat': get_chat_access_level(user),
'can_access_admin_panel': user.can_access_admin_panel(),
'can_manage_users': user.can_manage_users(),
'can_moderate_forum': user.can_moderate_forum(),
'can_edit_own_company': user.can_edit_company() if user.company_id else False,
'can_manage_own_company': user.can_manage_company() if user.company_id else False,
}