feat(security): Restrict audit access to single designated user
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

Audits (SEO, IT, GBP, Social Media) are now visible only to the
designated audit owner (maciej.pienczyn@inpi.pl). All other users,
including admins, see 404 for audit routes and no audit links in
navigation. KRS Audit and Digital Maturity remain unchanged.

Adds /admin/access-overview panel showing the access matrix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-09 12:31:10 +01:00
parent e8b7f2214f
commit 7f77d7ebcd
14 changed files with 236 additions and 15 deletions

6
app.py
View File

@ -346,6 +346,12 @@ def inject_globals():
}
@app.context_processor
def inject_audit_access():
from utils.decorators import is_audit_owner
return dict(is_audit_owner=is_audit_owner())
@app.context_processor
def inject_notifications():
"""Inject unread notifications count into all templates"""

View File

@ -8,7 +8,7 @@ SEO and GBP audit dashboards for admin panel.
import logging
from datetime import datetime
from flask import render_template, request, redirect, url_for, flash
from flask import abort, render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from . import bp
@ -17,7 +17,7 @@ from database import (
CompanyDigitalMaturity, KRSAudit, CompanyPKD, CompanyPerson,
ITAudit, ITCollaborationMatch, SystemRole
)
from utils.decorators import role_required
from utils.decorators import role_required, is_audit_owner
logger = logging.getLogger(__name__)
@ -44,6 +44,8 @@ def admin_seo():
Query Parameters:
- company: Slug of company to highlight/filter (optional)
"""
if not is_audit_owner():
abort(404)
# Get optional company filter from URL
filter_company_slug = request.args.get('company', '')
@ -154,6 +156,8 @@ def admin_gbp_audit():
- Review metrics (avg rating, review counts)
- Photo statistics
"""
if not is_audit_owner():
abort(404)
db = SessionLocal()
try:
from sqlalchemy import func
@ -499,6 +503,8 @@ def admin_it_audit():
Access: Office Manager and above
"""
if not is_audit_owner():
abort(404)
db = SessionLocal()
try:
from sqlalchemy import func
@ -723,3 +729,32 @@ def admin_it_audit():
finally:
db.close()
# ============================================================
# ACCESS OVERVIEW DASHBOARD
# ============================================================
@bp.route('/access-overview')
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def admin_access_overview():
"""Panel kontroli dostepu — kto widzi co."""
if not is_audit_owner():
abort(404)
from database import User
db = SessionLocal()
try:
users = db.query(User).filter(
User.is_active == True,
User.role.in_(['OFFICE_MANAGER', 'ADMIN'])
).order_by(User.name).all()
from utils.decorators import AUDIT_OWNER_EMAIL
return render_template('admin/access_overview.html',
users=users,
audit_owner_email=AUDIT_OWNER_EMAIL
)
finally:
db.close()

View File

@ -16,7 +16,7 @@ from . import bp
from database import (
SessionLocal, Company, Category, CompanySocialMedia, SystemRole
)
from utils.decorators import role_required
from utils.decorators import role_required, is_audit_owner
logger = logging.getLogger(__name__)
@ -132,6 +132,9 @@ def admin_social_audit():
- Sortable table with platform icons per company
- Followers aggregate statistics
"""
if not is_audit_owner():
from flask import abort
abort(404)
db = SessionLocal()
try:
# Platform definitions

View File

@ -7,8 +7,9 @@ Uses audit_ai_service.py for Gemini integration.
import logging
from flask import jsonify, request
from flask import abort, jsonify, request
from flask_login import current_user, login_required
from utils.decorators import is_audit_owner
from database import SessionLocal, Company
from . import bp
@ -30,6 +31,8 @@ def api_audit_analyze():
Returns:
JSON with summary and actions list
"""
if not is_audit_owner():
abort(404)
import audit_ai_service
data = request.get_json()
@ -86,6 +89,8 @@ def api_audit_generate_content():
Returns:
JSON with generated content
"""
if not is_audit_owner():
abort(404)
import audit_ai_service
data = request.get_json()
@ -133,6 +138,8 @@ def api_audit_actions_by_slug(slug):
Returns:
JSON with list of actions
"""
if not is_audit_owner():
abort(404)
import audit_ai_service
audit_type = request.args.get('audit_type')
@ -173,6 +180,8 @@ def api_audit_action_update_status(action_id):
Returns:
JSON with updated status
"""
if not is_audit_owner():
abort(404)
import audit_ai_service
from database import AuditAction

View File

@ -8,8 +8,9 @@ Contains API routes for Google Business Profile audit functionality.
import logging
from datetime import datetime
from flask import jsonify, request, current_app
from flask import abort, jsonify, request, current_app
from flask_login import current_user, login_required
from utils.decorators import is_audit_owner
from database import SessionLocal, Company
from . import bp
@ -50,6 +51,8 @@ def api_gbp_audit_health():
Returns service status and version information.
Used by monitoring systems to verify service availability.
"""
if not is_audit_owner():
return jsonify({'success': False, 'error': 'Nie znaleziono'}), 404
if GBP_AUDIT_AVAILABLE:
return jsonify({
'status': 'ok',
@ -83,6 +86,8 @@ def api_gbp_audit_get():
Example: GET /api/gbp/audit?company_id=26
Example: GET /api/gbp/audit?slug=pixlab-sp-z-o-o
"""
if not is_audit_owner():
return jsonify({'success': False, 'error': 'Nie znaleziono'}), 404
if not GBP_AUDIT_AVAILABLE:
return jsonify({
'success': False,
@ -173,6 +178,8 @@ def api_gbp_audit_by_slug(slug):
Example: GET /api/gbp/audit/pixlab-sp-z-o-o
"""
if not is_audit_owner():
return jsonify({'success': False, 'error': 'Nie znaleziono'}), 404
if not GBP_AUDIT_AVAILABLE:
return jsonify({
'success': False,
@ -246,6 +253,8 @@ def api_gbp_audit_trigger():
Rate limited to 20 requests per hour per user.
"""
if not is_audit_owner():
abort(404)
if not GBP_AUDIT_AVAILABLE:
return jsonify({
'success': False,

View File

@ -10,8 +10,9 @@ import sys
from datetime import datetime
from pathlib import Path
from flask import jsonify, request, current_app
from flask import abort, jsonify, request, current_app
from flask_login import current_user, login_required
from utils.decorators import is_audit_owner
from database import SessionLocal, Company, CompanyWebsiteAnalysis
from . import bp
@ -267,6 +268,8 @@ def api_seo_audit():
- technical checks (ssl, sitemap, robots.txt, mobile-friendly)
- issues list with severity levels
"""
if not is_audit_owner():
return jsonify({'success': False, 'error': 'Nie znaleziono'}), 404
company_id = request.args.get('company_id', type=int)
slug = request.args.get('slug', type=str)
@ -305,6 +308,8 @@ def api_seo_audit_by_slug(slug):
Example: GET /api/seo/audit/pixlab-sp-z-o-o
"""
if not is_audit_owner():
return jsonify({'success': False, 'error': 'Nie znaleziono'}), 404
db = SessionLocal()
try:
# Find company by slug
@ -342,6 +347,8 @@ def api_seo_audit_trigger():
- Success: Full SEO audit results saved to database
- Error: Error message with status code
"""
if not is_audit_owner():
abort(404)
# Check admin panel access
if not current_user.can_access_admin_panel():
return jsonify({

View File

@ -9,8 +9,9 @@ import logging
import sys
from pathlib import Path
from flask import jsonify, request
from flask import abort, jsonify, request
from flask_login import current_user, login_required
from utils.decorators import is_audit_owner
from database import SessionLocal, Company
from . import bp
@ -44,6 +45,8 @@ def api_social_audit_trigger():
Rate limited to 10 requests per hour per user.
"""
if not is_audit_owner():
abort(404)
# Import the SocialMediaAuditor from scripts
try:
scripts_dir = Path(__file__).parent.parent.parent / 'scripts'

View File

@ -7,8 +7,9 @@ Contains user-facing audit dashboard pages for SEO, GBP, Social Media, and IT.
import logging
from flask import flash, redirect, render_template, url_for
from flask import abort, flash, redirect, render_template, url_for
from flask_login import current_user, login_required
from utils.decorators import is_audit_owner
from database import (
SessionLocal, Company, CompanyWebsiteAnalysis,
@ -57,6 +58,8 @@ def seo_audit_dashboard(slug):
Returns:
Rendered seo_audit.html template with company and audit data
"""
if not is_audit_owner():
abort(404)
db = SessionLocal()
try:
# Find company by slug
@ -236,6 +239,8 @@ def social_audit_dashboard(slug):
Returns:
Rendered social_audit.html template with company and social data
"""
if not is_audit_owner():
abort(404)
db = SessionLocal()
try:
# Find company by slug
@ -338,6 +343,8 @@ def gbp_audit_dashboard(slug):
Returns:
Rendered gbp_audit.html template with company and audit data
"""
if not is_audit_owner():
abort(404)
if not GBP_AUDIT_AVAILABLE:
flash('Usługa audytu Google Business Profile jest tymczasowo niedostępna.', 'error')
return redirect(url_for('dashboard'))
@ -443,6 +450,8 @@ def it_audit_dashboard(slug):
Returns:
Rendered it_audit.html template with company and audit data
"""
if not is_audit_owner():
abort(404)
db = SessionLocal()
try:
# Find company by slug

View File

@ -10,8 +10,9 @@ import json
import logging
from io import StringIO
from flask import flash, jsonify, redirect, render_template, request, Response, url_for
from flask import abort, flash, jsonify, redirect, render_template, request, Response, url_for
from flask_login import current_user, login_required
from utils.decorators import is_audit_owner
from database import SessionLocal, Company
from . import bp
@ -60,6 +61,8 @@ def it_audit_form():
Returns:
Rendered it_audit_form.html template with company and audit data
"""
if not is_audit_owner():
abort(404)
db = SessionLocal()
try:
from database import ITAudit
@ -136,6 +139,8 @@ def it_audit_save():
Rate limited to 30 requests per hour per user.
"""
if not is_audit_owner():
abort(404)
# Apply rate limiting manually since decorator doesn't work with blueprint
limiter = get_limiter()
if limiter:

View File

@ -9,8 +9,9 @@ import csv
import logging
from io import StringIO
from flask import jsonify, request, Response
from flask import abort, jsonify, request, Response
from flask_login import current_user, login_required
from utils.decorators import is_audit_owner
from database import SessionLocal, Company
from . import bp
@ -43,6 +44,8 @@ def api_it_audit_matches(company_id):
- partner company info (id, name, slug)
- match_reason and shared_attributes
"""
if not is_audit_owner():
abort(404)
# Only users with admin panel access can view collaboration matches
if not current_user.can_access_admin_panel():
return jsonify({
@ -136,6 +139,8 @@ def api_it_audit_history(company_id):
- audit_id, audit_date, overall_score, scores, maturity_level
- is_current flag (True for the most recent audit)
"""
if not is_audit_owner():
abort(404)
from it_audit_service import get_company_audit_history
# Access control: users with company edit rights can view history
@ -210,6 +215,8 @@ def api_it_audit_export():
Returns:
CSV file with IT audit data
"""
if not is_audit_owner():
abort(404)
if not current_user.can_access_admin_panel():
return jsonify({
'success': False,

View File

@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block title %}Kontrola dostepu - Admin{% endblock %}
{% block content %}
<div class="admin-container">
<div class="page-header">
<h1>Kontrola dostepu</h1>
<p style="color: var(--text-secondary); margin-top: var(--spacing-xs);">
Matryca dostepu do funkcji audytowych
</p>
</div>
<!-- Rule explanation -->
<div class="card" style="margin-bottom: var(--spacing-lg); background: #eff6ff; border: 1px solid #bfdbfe;">
<div style="padding: var(--spacing-md);">
<h3 style="margin: 0 0 var(--spacing-sm) 0; color: #1e40af;">Zasada ograniczenia dostepu</h3>
<p style="margin: 0; color: #1e3a5f;">
Audyty SEO, IT, GBP i Social Media sa widoczne wylacznie dla <strong>{{ audit_owner_email }}</strong>.
Pozostali administratorzy nie widza tych funkcji w menu ani na stronach firm.
Audyt KRS i Digital Maturity pozostaja dostepne dla wszystkich z rola OFFICE_MANAGER+.
</p>
</div>
</div>
<!-- Access matrix -->
<div class="card">
<div style="padding: var(--spacing-md); overflow-x: auto;">
<table class="data-table" style="width: 100%;">
<thead>
<tr>
<th>Uzytkownik</th>
<th>Email</th>
<th>Rola</th>
<th style="text-align: center;">Audyt SEO</th>
<th style="text-align: center;">Audyt IT</th>
<th style="text-align: center;">Audyt GBP</th>
<th style="text-align: center;">Audyt Social</th>
<th style="text-align: center;">Audyt KRS</th>
<th style="text-align: center;">Digital Maturity</th>
<th style="text-align: center;">Inne admin</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td><strong>{{ user.name or 'Brak nazwy' }}</strong></td>
<td>{{ user.email }}</td>
<td>
<span class="badge badge-{{ 'success' if user.role == 'ADMIN' else 'info' }}">
{{ user.role }}
</span>
</td>
{% set is_owner = user.email == audit_owner_email %}
<td style="text-align: center;">
{% if is_owner %}
<span style="color: #16a34a; font-weight: bold;" title="Dostep">&#10003;</span>
{% else %}
<span style="color: #dc2626;" title="Brak dostepu">&#10007;</span>
{% endif %}
</td>
<td style="text-align: center;">
{% if is_owner %}
<span style="color: #16a34a; font-weight: bold;" title="Dostep">&#10003;</span>
{% else %}
<span style="color: #dc2626;" title="Brak dostepu">&#10007;</span>
{% endif %}
</td>
<td style="text-align: center;">
{% if is_owner %}
<span style="color: #16a34a; font-weight: bold;" title="Dostep">&#10003;</span>
{% else %}
<span style="color: #dc2626;" title="Brak dostepu">&#10007;</span>
{% endif %}
</td>
<td style="text-align: center;">
{% if is_owner %}
<span style="color: #16a34a; font-weight: bold;" title="Dostep">&#10003;</span>
{% else %}
<span style="color: #dc2626;" title="Brak dostepu">&#10007;</span>
{% endif %}
</td>
<td style="text-align: center;">
<span style="color: #16a34a; font-weight: bold;" title="Dostep">&#10003;</span>
</td>
<td style="text-align: center;">
<span style="color: #16a34a; font-weight: bold;" title="Dostep">&#10003;</span>
</td>
<td style="text-align: center;">
<span style="color: #16a34a; font-weight: bold;" title="Dostep">&#10003;</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Reversibility note -->
<div class="card" style="margin-top: var(--spacing-lg); background: #fefce8; border: 1px solid #fde68a;">
<div style="padding: var(--spacing-md);">
<h3 style="margin: 0 0 var(--spacing-sm) 0; color: #92400e;">Odwracalnosc</h3>
<p style="margin: 0; color: #78350f;">
Aby przywrocic dostep do audytow dla wszystkich administratorow,
nalezy zmienic funkcje <code>is_audit_owner()</code> w pliku
<code>utils/decorators.py</code> na sprawdzanie roli OFFICE_MANAGER.
</p>
</div>
</div>
</div>
{% endblock %}

View File

@ -1518,6 +1518,7 @@
</svg>
</button>
<div class="admin-dropdown-menu">
{% if is_audit_owner %}
<a href="{{ url_for('admin.admin_seo') }}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
@ -1543,6 +1544,13 @@
</svg>
Audyt Social
</a>
<a href="{{ url_for('admin.admin_access_overview') }}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
Kontrola dostepu
</a>
{% endif %}
<a href="{{ url_for('admin.admin_krs_audit') }}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>

View File

@ -818,7 +818,7 @@
</div>
{# Audit links - separate row, visible only to authorized users #}
{% if current_user.is_authenticated and current_user.can_edit_company(company.id) %}
{% if is_audit_owner %}
<div class="contact-bar" style="margin-top: var(--spacing-sm, 8px);">
<a href="{{ url_for('gbp_audit_dashboard', slug=company.slug) }}" class="contact-bar-item gbp-audit" title="Audyt Google Business Profile">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -2747,7 +2747,7 @@
{% endif %}
<!-- SEO Metrics Section - Only show if SEO audit was performed -->
{% if website_analysis and website_analysis.seo_audited_at %}
{% if website_analysis and website_analysis.seo_audited_at and is_audit_owner %}
<div class="company-section" id="seo-metrics">
<h2 class="section-title">
Analiza SEO
@ -3078,7 +3078,7 @@
{% endif %}
<!-- GBP Audit Section - Only show if GBP audit was performed -->
{% if gbp_audit and gbp_audit.completeness_score is not none %}
{% if gbp_audit and gbp_audit.completeness_score is not none and is_audit_owner %}
<div class="company-section" id="gbp-audit">
<h2 class="section-title">
Audyt Google Business Profile
@ -3291,7 +3291,7 @@
{% endif %}
<!-- Social Media Audit Section -->
{% if social_media and social_media|length > 0 %}
{% if social_media and social_media|length > 0 and is_audit_owner %}
<div class="company-section" id="social-media-audit">
<h2 class="section-title">
Audyt Social Media
@ -3362,7 +3362,7 @@
{% endif %}
<!-- IT Audit Section - Only show if IT audit was performed -->
{% if it_audit and it_audit.overall_score is not none %}
{% if it_audit and it_audit.overall_score is not none and is_audit_owner %}
<div class="company-section" id="it-audit">
<h2 class="section-title">
Audyt IT

View File

@ -18,6 +18,15 @@ from functools import wraps
from flask import abort, flash, redirect, url_for, request
from flask_login import current_user
# Audit access restriction — only this user sees SEO/IT/GBP/Social audits
AUDIT_OWNER_EMAIL = 'maciej.pienczyn@inpi.pl'
def is_audit_owner():
"""True only for the designated audit owner."""
if not current_user.is_authenticated:
return False
return current_user.email == AUDIT_OWNER_EMAIL
# Import role enums (lazy import to avoid circular dependencies)
def _get_system_role():
from database import SystemRole